Skip to content

Commit

Permalink
Admin Runs API (#184)
Browse files Browse the repository at this point in the history
* Add Admin Runs List(), ForceCancel(),
* Add tests.
  • Loading branch information
omarismail committed Mar 4, 2021
1 parent c64c45b commit 3ecf508
Show file tree
Hide file tree
Showing 3 changed files with 390 additions and 0 deletions.
138 changes: 138 additions & 0 deletions admin_run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package tfe

import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
)

// Compile-time proof of interface implementation.
var _ AdminRuns = (*adminRuns)(nil)

// AdminRuns describes all the admin run related methods that the Terraform
// Enterprise API supports.
// It contains endpoints to help site administrators manage their runs.
//
// TFE API docs: https://www.terraform.io/docs/cloud/api/admin/runs.html
type AdminRuns interface {
// List all the runs of the given installation.
List(ctx context.Context, options AdminRunsListOptions) (*AdminRunsList, error)

// Force-cancel a run by its ID.
ForceCancel(ctx context.Context, runID string, options AdminRunForceCancelOptions) error
}

// adminRuns implements the AdminRuns interface.
type adminRuns struct {
client *Client
}

type AdminRun struct {
ID string `jsonapi:"primary,runs"`
CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"`
HasChanges bool `jsonapi:"attr,has-changes"`
Status RunStatus `jsonapi:"attr,status"`
StatusTimestamps *RunStatusTimestamps `jsonapi:"attr,status-timestamps"`

// Relations
Workspace *AdminWorkspace `jsonapi:"relation,workspace"`
Organization *AdminOrganization `jsonapi:"relation,workspace.organization"`
}

// AdminRunsList represents a list of runs.
type AdminRunsList struct {
*Pagination
Items []*AdminRun
}

// AdminRunsListOptions represents the options for listing runs.
// https://www.terraform.io/docs/cloud/api/admin/runs.html#query-parameters
type AdminRunsListOptions struct {
ListOptions

RunStatus *string `url:"filter[status],omitempty"`
Query *string `url:"q,omitempty"`
Include *string `url:"include,omitempty"`
}

// List all the runs of the terraform enterprise installation.
// https://www.terraform.io/docs/cloud/api/admin/runs.html#list-all-runs
func (s *adminRuns) List(ctx context.Context, options AdminRunsListOptions) (*AdminRunsList, error) {
if err := options.valid(); err != nil {
return nil, err
}

u := fmt.Sprintf("admin/runs")
req, err := s.client.newRequest("GET", u, &options)
if err != nil {
return nil, err
}

rl := &AdminRunsList{}
err = s.client.do(ctx, req, rl)
if err != nil {
return nil, err
}

return rl, nil
}

// AdminRunForceCancelOptions represents the options for force-canceling a run.
type AdminRunForceCancelOptions struct {
// An optional comment explaining the reason for the force-cancel.
// https://www.terraform.io/docs/cloud/api/admin/runs.html#request-body
Comment *string `json:"comment,omitempty"`
}

// ForceCancel is used to forcefully cancel a run by its ID.
// https://www.terraform.io/docs/cloud/api/admin/runs.html#force-a-run-into-the-quot-cancelled-quot-state
func (s *adminRuns) ForceCancel(ctx context.Context, runID string, options AdminRunForceCancelOptions) error {
if !validStringID(&runID) {
return errors.New("invalid value for run ID")
}

u := fmt.Sprintf("admin/runs/%s/actions/force-cancel", url.QueryEscape(runID))
req, err := s.client.newRequest("POST", u, &options)
if err != nil {
return err
}

return s.client.do(ctx, req, nil)
}

func (o AdminRunsListOptions) valid() error {
if validString(o.RunStatus) {
validRunStatus := map[string]int{
string(RunApplied): 1,
string(RunApplyQueued): 1,
string(RunApplying): 1,
string(RunCanceled): 1,
string(RunConfirmed): 1,
string(RunCostEstimated): 1,
string(RunCostEstimating): 1,
string(RunDiscarded): 1,
string(RunErrored): 1,
string(RunPending): 1,
string(RunPlanQueued): 1,
string(RunPlanned): 1,
string(RunPlannedAndFinished): 1,
string(RunPlanning): 1,
string(RunPolicyChecked): 1,
string(RunPolicyChecking): 1,
string(RunPolicyOverride): 1,
string(RunPolicySoftFailed): 1,
}
runStatus := strings.Split(*o.RunStatus, ",")

// iterate over our statuses, and ensure it is valid.
for _, status := range runStatus {
if _, present := validRunStatus[status]; !present {
return fmt.Errorf("invalid value %s for run status", status)
}
}
}
return nil
}
250 changes: 250 additions & 0 deletions admin_run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
package tfe

import (
"context"
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAdminRuns_List(t *testing.T) {
skipIfCloud(t)

client := testClient(t)
ctx := context.Background()

org, orgCleanup := createOrganization(t, client)
defer orgCleanup()

wTest, wTestCleanup := createWorkspace(t, client, org)
defer wTestCleanup()

rTest1, _ := createRun(t, client, wTest)
rTest2, _ := createRun(t, client, wTest)

t.Run("without list options", func(t *testing.T) {
rl, err := client.Admin.Runs.List(ctx, AdminRunsListOptions{})
require.NoError(t, err)

assert.NotEmpty(t, rl.Items)
assert.Equal(t, adminRunItemsContainsID(rl.Items, rTest1.ID), true)
assert.Equal(t, adminRunItemsContainsID(rl.Items, rTest2.ID), true)
})

t.Run("with list options", func(t *testing.T) {
rl, err := client.Admin.Runs.List(ctx, AdminRunsListOptions{
ListOptions: ListOptions{
PageNumber: 999,
PageSize: 100,
},
})
require.NoError(t, err)
// Out of range page number, so the items should be empty
assert.Empty(t, rl.Items)
assert.Equal(t, 999, rl.CurrentPage)

rl, err = client.Admin.Runs.List(ctx, AdminRunsListOptions{
ListOptions: ListOptions{
PageNumber: 1,
PageSize: 100,
},
})
require.NoError(t, err)
assert.NotEmpty(t, rl.Items)
assert.Equal(t, 1, rl.CurrentPage)
assert.Equal(t, adminRunItemsContainsID(rl.Items, rTest1.ID), true)
assert.Equal(t, adminRunItemsContainsID(rl.Items, rTest2.ID), true)
})

t.Run("with workspace included", func(t *testing.T) {
rl, err := client.Admin.Runs.List(ctx, AdminRunsListOptions{
Include: String("workspace"),
})

assert.NoError(t, err)

assert.NotEmpty(t, rl.Items)
assert.NotNil(t, rl.Items[0].Workspace)
assert.NotEmpty(t, rl.Items[0].Workspace.Name)
})

t.Run("with workspace.organization included", func(t *testing.T) {
rl, err := client.Admin.Runs.List(ctx, AdminRunsListOptions{
Include: String("workspace.organization"),
})

assert.NoError(t, err)

assert.NotEmpty(t, rl.Items)
assert.NotNil(t, rl.Items[0].Workspace)
assert.NotNil(t, rl.Items[0].Workspace.Organization)
assert.NotEmpty(t, rl.Items[0].Workspace.Organization.Name)
})

t.Run("with RunStatus.pending filter", func(t *testing.T) {
r1, err := client.Runs.Read(ctx, rTest1.ID)
assert.NoError(t, err)
r2, err := client.Runs.Read(ctx, rTest2.ID)
assert.NoError(t, err)

// There should be pending Runs
rl, err := client.Admin.Runs.List(ctx, AdminRunsListOptions{
RunStatus: String(string(RunPending)),
})
assert.NoError(t, err)
assert.NotEmpty(t, rl.Items)

assert.Equal(t, r1.Status, RunPlanning)
assert.Equal(t, adminRunItemsContainsID(rl.Items, r1.ID), false)
assert.Equal(t, r2.Status, RunPending)
assert.Equal(t, adminRunItemsContainsID(rl.Items, r2.ID), true)
})

t.Run("with RunStatus.applied filter", func(t *testing.T) {
// There should be no applied Runs
rl, err := client.Admin.Runs.List(ctx, AdminRunsListOptions{
RunStatus: String(string(RunApplied)),
})
assert.NoError(t, err)
assert.Empty(t, rl.Items)
})

t.Run("with query", func(t *testing.T) {
rl, err := client.Admin.Runs.List(ctx, AdminRunsListOptions{
Query: String(rTest1.ID),
})
assert.NoError(t, err)

assert.NotEmpty(t, rl.Items)
assert.Equal(t, adminRunItemsContainsID(rl.Items, rTest1.ID), true)
assert.Equal(t, adminRunItemsContainsID(rl.Items, rTest2.ID), false)

rl, err = client.Admin.Runs.List(ctx, AdminRunsListOptions{
Query: String(rTest2.ID),
})
assert.NoError(t, err)

assert.NotEmpty(t, rl.Items)
assert.Equal(t, adminRunItemsContainsID(rl.Items, rTest1.ID), false)
assert.Equal(t, adminRunItemsContainsID(rl.Items, rTest2.ID), true)
})
}

func TestAdminRuns_ForceCancel(t *testing.T) {
skipIfCloud(t)

client := testClient(t)
ctx := context.Background()

org, orgCleanup := createOrganization(t, client)
defer orgCleanup()

wTest, wTestCleanup := createWorkspace(t, client, org)
defer wTestCleanup()

// We need to create 2 runs here.
// The first run will automatically be planned
// so that one cannot be cancelled.
rTest1, rCleanup1 := createRun(t, client, wTest)
defer rCleanup1()
// The second one will be pending until the first one is
// confirmed or discarded, so we can cancel that one.
rTest2, rCleanup2 := createRun(t, client, wTest)
defer rCleanup2()

assert.Equal(t, true, rTest1.Actions.IsCancelable)
assert.Equal(t, true, rTest1.Permissions.CanForceCancel)

assert.Equal(t, true, rTest2.Actions.IsCancelable)
assert.Equal(t, true, rTest2.Permissions.CanForceCancel)

t.Run("when the run does not exist", func(t *testing.T) {
err := client.Admin.Runs.ForceCancel(ctx, "nonexisting", AdminRunForceCancelOptions{})
assert.Equal(t, err, ErrResourceNotFound)
})

t.Run("with invalid run ID", func(t *testing.T) {
err := client.Admin.Runs.ForceCancel(ctx, badIdentifier, AdminRunForceCancelOptions{})
assert.EqualError(t, err, ErrInvalidRunID.Error())
})

t.Run("with can force cancel", func(t *testing.T) {
rTestPlanning, err := client.Runs.Read(ctx, rTest1.ID)
require.NoError(t, err)
assert.Equal(t, RunPlanning, rTestPlanning.Status)
assert.Equal(t, true, rTestPlanning.Actions.IsCancelable)
assert.Equal(t, true, rTestPlanning.Permissions.CanForceCancel)

rTestPending, err := client.Runs.Read(ctx, rTest2.ID)
require.NoError(t, err)
assert.Equal(t, RunPending, rTestPending.Status)
assert.Equal(t, true, rTestPending.Actions.IsCancelable)
assert.Equal(t, true, rTestPending.Permissions.CanForceCancel)

comment1 := "Misclick"
err = client.Admin.Runs.ForceCancel(ctx, rTestPending.ID, AdminRunForceCancelOptions{
Comment: String(comment1),
})
require.NoError(t, err)

rTestPendingResult, err := client.Runs.Read(ctx, rTestPending.ID)
require.NoError(t, err)
assert.Equal(t, RunCanceled, rTestPendingResult.Status)

comment2 := "Another misclick"
err = client.Admin.Runs.ForceCancel(ctx, rTestPlanning.ID, AdminRunForceCancelOptions{
Comment: String(comment2),
})
require.NoError(t, err)

rTestPlanningResult, err := client.Runs.Read(ctx, rTestPlanning.ID)
require.NoError(t, err)
assert.Equal(t, RunCanceled, rTestPlanningResult.Status)
})
}

func TestAdminRuns_AdminRunsListOptions_valid(t *testing.T) {
skipIfCloud(t)

t.Run("has valid status", func(t *testing.T) {
opts := AdminRunsListOptions{
RunStatus: String(string(RunPending)),
}

err := opts.valid()
assert.NoError(t, err)
})

t.Run("has invalid status", func(t *testing.T) {
opts := AdminRunsListOptions{
RunStatus: String("random_status"),
}

err := opts.valid()
assert.Error(t, err)
})

t.Run("has invalid status, even with a valid one", func(t *testing.T) {
statuses := fmt.Sprintf("%s,%s", string(RunPending), "random_status")
opts := AdminRunsListOptions{
RunStatus: String(statuses),
}

err := opts.valid()
assert.Error(t, err)
})
}

func adminRunItemsContainsID(items []*AdminRun, id string) bool {
hasID := false
for _, item := range items {
if item.ID == id {
hasID = true
break
}
}

return hasID
}

0 comments on commit 3ecf508

Please sign in to comment.