Skip to content

Commit

Permalink
feat: support creating plan-only runs (#465)
Browse files Browse the repository at this point in the history
Adds support for the specifying the `plan-only` attribute when creating
a run via the API:


https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run#create-a-run

This overrides the `speculative` attribute when specified on the
configuration version for the run.

Note: throughout TFC documentation, both "speculative" and "plan-only"
are used to refer to the same thing: a run that only performs a plan and
does not follow through to an apply. I think Hashicorp are increasingly
using the latter term, "plan-only", because it is more descriptive. I've
followed suit in this PR and updated the OTF codebase to use variants of
the latter term and minimise references to "speculative" runs.
  • Loading branch information
leg100 authored Jun 14, 2023
1 parent 186f904 commit 3f9c31e
Show file tree
Hide file tree
Showing 27 changed files with 173 additions and 166 deletions.
1 change: 1 addition & 0 deletions internal/api/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func (a *api) createRun(w http.ResponseWriter, r *http.Request) {
ConfigurationVersionID: configurationVersionID,
TargetAddrs: opts.TargetAddrs,
ReplaceAddrs: opts.ReplaceAddrs,
PlanOnly: opts.PlanOnly,
})
if err != nil {
Error(w, err)
Expand Down
3 changes: 3 additions & 0 deletions internal/api/types/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ type RunCreateOptions struct {
// set. https://jsonapi.org/format/#crud-creating
Type string `jsonapi:"primary,runs"`

// PlanOnly specifies if this is a speculative, plan-only run that Terraform cannot apply.
PlanOnly *bool `jsonapi:"attr,plan-only,omitempty"`

// Specifies if this plan is a destroy plan, which will destroy all
// provisioned resources.
IsDestroy *bool `jsonapi:"attribute" json:"is-destroy,omitempty"`
Expand Down
26 changes: 0 additions & 26 deletions internal/configversion/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ type (

Service interface {
CreateConfigurationVersion(ctx context.Context, workspaceID string, opts ConfigurationVersionCreateOptions) (*ConfigurationVersion, error)
// CloneConfigurationVersion creates a new configuration version using the
// config tarball of an existing configuration version.
CloneConfigurationVersion(ctx context.Context, cvID string, opts ConfigurationVersionCreateOptions) (*ConfigurationVersion, error)
GetConfigurationVersion(ctx context.Context, id string) (*ConfigurationVersion, error)
GetLatestConfigurationVersion(ctx context.Context, workspaceID string) (*ConfigurationVersion, error)
ListConfigurationVersions(ctx context.Context, workspaceID string, opts ConfigurationVersionListOptions) (*ConfigurationVersionList, error)
Expand Down Expand Up @@ -84,29 +81,6 @@ func (s *service) CreateConfigurationVersion(ctx context.Context, workspaceID st
return cv, nil
}

func (s *service) CloneConfigurationVersion(ctx context.Context, cvID string, opts ConfigurationVersionCreateOptions) (*ConfigurationVersion, error) {
cv, err := s.GetConfigurationVersion(ctx, cvID)
if err != nil {
return nil, err
}

cv, err = s.CreateConfigurationVersion(ctx, cv.WorkspaceID, opts)
if err != nil {
return nil, err
}

config, err := s.DownloadConfig(ctx, cvID)
if err != nil {
return nil, err
}

if err := s.UploadConfig(ctx, cv.ID, config); err != nil {
return nil, err
}

return cv, nil
}

func (s *service) ListConfigurationVersions(ctx context.Context, workspaceID string, opts ConfigurationVersionListOptions) (*ConfigurationVersionList, error) {
subject, err := s.workspace.CanAccess(ctx, rbac.ListConfigurationVersionsAction, workspaceID)
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@
<button id="queue-destroy-plan-button" class="delete" {{ insufficient $canCreateRun }} onclick="return confirm('This will destroy all infrastructure in this workspace. Please confirm.')">
Queue destroy plan
</button>
<input name="strategy" value="destroy-all" type="hidden">
<input name="operation" value="destroy-all" type="hidden">
</form>
<form action="{{ deleteWorkspacePath .Workspace.ID }}" method="POST">
<button id="delete-workspace-button" class="delete" {{ insufficient $canDelete }} onclick="return confirm('Are you sure you want to delete?')">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
<div class="actions-container">
<h5>Actions</h5>
<form id="workspace-start-run-form" action="{{ startRunWorkspacePath .Workspace.ID }}" method="POST">
<select name="strategy" id="start-run-strategy" onchange="this.form.submit()">
<select name="operation" id="start-run-operation" onchange="this.form.submit()">
<option value="" selected>-- start run --</option>
<option value="plan-only">plan only</option>
{{ if .CanApply }}
Expand Down
3 changes: 2 additions & 1 deletion internal/integration/connect_repo_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/chromedp/chromedp"
"github.com/leg100/otf/internal/cloud"
"github.com/leg100/otf/internal/github"
"github.com/leg100/otf/internal/run"
"github.com/leg100/otf/internal/testutils"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -34,7 +35,7 @@ func TestConnectRepoE2E(t *testing.T) {
connectWorkspaceTasks(t, daemon.Hostname(), org.Name, "my-test-workspace"),
// we can now start a run via the web ui, which'll retrieve the tarball from
// the fake github server
startRunTasks(t, daemon.Hostname(), org.Name, "my-test-workspace", "plan-and-apply"),
startRunTasks(t, daemon.Hostname(), org.Name, "my-test-workspace", run.PlanAndApplyOperation),
})
require.NoError(t, err)

Expand Down
4 changes: 2 additions & 2 deletions internal/integration/plan_permission_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ func TestIntegration_PlanPermission(t *testing.T) {
// go to workspace page
chromedp.Navigate(workspaceURL(svc.Hostname(), org.Name, "my-test-workspace")),
screenshot(t),
// select strategy for run
chromedp.SetValue(`//select[@id="start-run-strategy"]`, "plan-only", chromedp.BySearch),
// select operation for run
chromedp.SetValue(`//select[@id="start-run-operation"]`, "plan-only", chromedp.BySearch),
screenshot(t),
// confirm plan begins and ends
chromedp.WaitReady(`body`),
Expand Down
4 changes: 2 additions & 2 deletions internal/integration/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ func TestRun(t *testing.T) {
},
{
name: "filter out speculative runs in org1",
opts: run.RunListOptions{Organization: internal.String(ws1.Organization), Speculative: internal.Bool(false)},
opts: run.RunListOptions{Organization: internal.String(ws1.Organization), PlanOnly: internal.Bool(false)},
want: func(t *testing.T, l *run.RunList) {
// org1 has no speculative runs, so should return both runs
assert.Equal(t, 2, len(l.Items))
Expand All @@ -154,7 +154,7 @@ func TestRun(t *testing.T) {
},
{
name: "filter out speculative runs in org2",
opts: run.RunListOptions{Organization: internal.String(ws2.Organization), Speculative: internal.Bool(false)},
opts: run.RunListOptions{Organization: internal.String(ws2.Organization), PlanOnly: internal.Bool(false)},
want: func(t *testing.T, l *run.RunList) {
// org2 only has speculative runs, so should return zero
assert.Equal(t, 0, len(l.Items))
Expand Down
7 changes: 4 additions & 3 deletions internal/integration/start_run_ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

"github.com/chromedp/chromedp"
"github.com/leg100/otf/internal/run"
"github.com/stretchr/testify/require"
)

Expand All @@ -19,15 +20,15 @@ func TestStartRunUI(t *testing.T) {
_ = svc.createAndUploadConfigurationVersion(t, ctx, ws, nil)

// now we have a config version, start a run with the plan-and-apply
// strategy
// operation
browser := createBrowserCtx(t)
err := chromedp.Run(browser, chromedp.Tasks{
newSession(t, ctx, svc.Hostname(), user.Username, svc.Secret),
startRunTasks(t, svc.Hostname(), ws.Organization, ws.Name, "plan-and-apply"),
startRunTasks(t, svc.Hostname(), ws.Organization, ws.Name, run.PlanAndApplyOperation),
})
require.NoError(t, err)

// now destroy resources with the destroy-all strategy
// now destroy resources with the destroy-all operation
okDialog(t, browser)
err = chromedp.Run(browser, chromedp.Tasks{
// go to workspace page
Expand Down
7 changes: 4 additions & 3 deletions internal/integration/ui_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/leg100/otf/internal/run"
"github.com/leg100/otf/internal/tokens"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -210,13 +211,13 @@ func createGithubVCSProviderTasks(t *testing.T, hostname, org, name string) chro
}

// startRunTasks starts a run via the UI
func startRunTasks(t *testing.T, hostname, organization, workspaceName, strategy string) chromedp.Tasks {
func startRunTasks(t *testing.T, hostname, organization, workspaceName string, op run.Operation) chromedp.Tasks {
return []chromedp.Action{
// go to workspace page
chromedp.Navigate(workspaceURL(hostname, organization, workspaceName)),
screenshot(t, "connected_workspace_main_page"),
// select strategy for run
chromedp.SetValue(`//select[@id="start-run-strategy"]`, strategy, chromedp.BySearch),
// select operation for run
chromedp.SetValue(`//select[@id="start-run-operation"]`, string(op), chromedp.BySearch),
screenshot(t, "run_page_started"),
// confirm plan begins and ends
chromedp.WaitReady(`body`),
Expand Down
15 changes: 8 additions & 7 deletions internal/run/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type (
AppliedChanges *pggen.Report `json:"applied_changes"`
ConfigurationVersionID pgtype.Text `json:"configuration_version_id"`
WorkspaceID pgtype.Text `json:"workspace_id"`
Speculative bool `json:"speculative"`
PlanOnly bool `json:"plan_only"`
ExecutionMode pgtype.Text `json:"execution_mode"`
Latest bool `json:"latest"`
OrganizationName pgtype.Text `json:"organization_name"`
Expand All @@ -62,6 +62,7 @@ func (db *pgdb) CreateRun(ctx context.Context, run *Run) error {
ReplaceAddrs: run.ReplaceAddrs,
TargetAddrs: run.TargetAddrs,
AutoApply: run.AutoApply,
PlanOnly: run.PlanOnly,
ConfigurationVersionID: sql.String(run.ConfigurationVersionID),
WorkspaceID: sql.String(run.WorkspaceID),
})
Expand Down Expand Up @@ -199,16 +200,16 @@ func (db *pgdb) ListRuns(ctx context.Context, opts RunListOptions) (*RunList, er
if len(opts.Statuses) > 0 {
statuses = convertStatusSliceToStringSlice(opts.Statuses)
}
speculative := "%"
if opts.Speculative != nil {
speculative = strconv.FormatBool(*opts.Speculative)
planOnly := "%"
if opts.PlanOnly != nil {
planOnly = strconv.FormatBool(*opts.PlanOnly)
}
db.FindRunsBatch(batch, pggen.FindRunsParams{
OrganizationNames: []string{organization},
WorkspaceNames: []string{workspaceName},
WorkspaceIds: []string{workspaceID},
Statuses: statuses,
Speculative: []string{speculative},
PlanOnly: []string{planOnly},
Limit: opts.GetLimit(),
Offset: opts.GetOffset(),
})
Expand All @@ -217,7 +218,7 @@ func (db *pgdb) ListRuns(ctx context.Context, opts RunListOptions) (*RunList, er
WorkspaceNames: []string{workspaceName},
WorkspaceIds: []string{workspaceID},
Statuses: statuses,
Speculative: []string{speculative},
PlanOnly: []string{planOnly},
})

results := db.SendBatch(ctx, batch)
Expand Down Expand Up @@ -349,7 +350,7 @@ func (result pgresult) toRun() *Run {
ReplaceAddrs: result.ReplaceAddrs,
TargetAddrs: result.TargetAddrs,
AutoApply: result.AutoApply,
Speculative: result.Speculative,
PlanOnly: result.PlanOnly,
ExecutionMode: workspace.ExecutionMode(result.ExecutionMode.String),
Latest: result.Latest,
Organization: result.OrganizationName.String,
Expand Down
2 changes: 1 addition & 1 deletion internal/run/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ func (f *factory) NewRun(ctx context.Context, workspaceID string, opts RunCreate
return nil, err
}

return NewRun(cv, ws, opts), nil
return newRun(cv, ws, opts), nil
}
16 changes: 14 additions & 2 deletions internal/run/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func TestFactory(t *testing.T) {

assert.Equal(t, internal.RunPending, got.Status)
assert.NotZero(t, got.CreatedAt)
assert.False(t, got.Speculative)
assert.False(t, got.PlanOnly)
assert.True(t, got.Refresh)
assert.False(t, got.AutoApply)
})
Expand All @@ -39,7 +39,19 @@ func TestFactory(t *testing.T) {
got, err := f.NewRun(ctx, "", RunCreateOptions{})
require.NoError(t, err)

assert.True(t, got.Speculative)
assert.True(t, got.PlanOnly)
})

t.Run("plan-only run", func(t *testing.T) {
f := testFactory(
&workspace.Workspace{},
&configversion.ConfigurationVersion{},
)

got, err := f.NewRun(ctx, "", RunCreateOptions{PlanOnly: internal.Bool(true)})
require.NoError(t, err)

assert.True(t, got.PlanOnly)
})

t.Run("workspace auto-apply", func(t *testing.T) {
Expand Down
27 changes: 15 additions & 12 deletions internal/run/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ type (
PositionInQueue int `json:"position_in_queue"`
TargetAddrs []string `json:"target_addrs"`
AutoApply bool `json:"auto_apply"`
Speculative bool `json:"speculative"`
PlanOnly bool `json:"plan_only"`
Status internal.RunStatus `json:"status"`
StatusTimestamps []RunStatusTimestamp `json:"status_timestamps"`
WorkspaceID string `json:"workspace_id"`
Expand All @@ -66,7 +66,7 @@ type (
}

// RunCreateOptions represents the options for creating a new run. See
// dto.RunCreateOptions for further detail.
// api/types/RunCreateOptions for documentation on each field.
RunCreateOptions struct {
IsDestroy *bool
Refresh *bool
Expand All @@ -76,6 +76,10 @@ type (
TargetAddrs []string
ReplaceAddrs []string
AutoApply *bool
// PlanOnly specifies if this is a speculative, plan-only run that
// Terraform cannot apply. Takes precedence over whether the
// configuration version is marked as speculative or not.
PlanOnly *bool
}

// RunListOptions are options for paginating and filtering a list of runs
Expand All @@ -89,8 +93,8 @@ type (
Organization *string `schema:"organization_name,omitempty"`
// Filter by workspace name
WorkspaceName *string `schema:"workspace_name,omitempty"`
// Filter by speculative or non-speculative
Speculative *bool `schema:"-"`
// Filter by plan-only runs
PlanOnly *bool `schema:"-"`
// A list of relations to include. See available resources:
// https://www.terraform.io/docs/cloud/api/run.html#available-related-resources
Include *string `schema:"include,omitempty"`
Expand All @@ -103,16 +107,16 @@ type (
}
)

// NewRun creates a new run with defaults.
func NewRun(cv *configversion.ConfigurationVersion, ws *workspace.Workspace, opts RunCreateOptions) *Run {
// newRun creates a new run with defaults.
func newRun(cv *configversion.ConfigurationVersion, ws *workspace.Workspace, opts RunCreateOptions) *Run {
run := Run{
ID: internal.NewID("run"),
CreatedAt: internal.CurrentTimestamp(),
Refresh: defaultRefresh,
Organization: ws.Organization,
ConfigurationVersionID: cv.ID,
WorkspaceID: ws.ID,
Speculative: cv.Speculative,
PlanOnly: cv.Speculative,
ReplaceAddrs: opts.ReplaceAddrs,
TargetAddrs: opts.TargetAddrs,
ExecutionMode: ws.ExecutionMode,
Expand All @@ -134,6 +138,9 @@ func NewRun(cv *configversion.ConfigurationVersion, ws *workspace.Workspace, opt
if opts.AutoApply != nil {
run.AutoApply = *opts.AutoApply
}
if opts.PlanOnly != nil {
run.PlanOnly = *opts.PlanOnly
}
if cv.IngressAttributes != nil {
run.Commit = &cv.IngressAttributes.CommitSHA
}
Expand All @@ -148,10 +155,6 @@ func (r *Run) HasChanges() bool {
return r.Plan.HasChanges()
}

func (r *Run) PlanOnly() bool {
return r.Status == internal.RunPlannedAndFinished
}

// HasApply determines whether the run has started applying yet.
func (r *Run) HasApply() bool {
_, err := r.Apply.StatusTimestamp(PhaseRunning)
Expand Down Expand Up @@ -322,7 +325,7 @@ func (r *Run) Finish(phase internal.PhaseType, opts PhaseFinishOptions) error {
r.updateStatus(internal.RunPlanned)
r.Plan.UpdateStatus(PhaseFinished)

if !r.HasChanges() || r.Speculative {
if !r.HasChanges() || r.PlanOnly {
r.updateStatus(internal.RunPlannedAndFinished)
r.Apply.UpdateStatus(PhaseUnreachable)
} else if r.AutoApply {
Expand Down
Loading

0 comments on commit 3f9c31e

Please sign in to comment.