diff --git a/.changes/unreleased/ENHANCEMENTS-20260717-023923.yaml b/.changes/unreleased/ENHANCEMENTS-20260717-023923.yaml new file mode 100644 index 0000000..8e25aa8 --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20260717-023923.yaml @@ -0,0 +1,3 @@ +kind: ENHANCEMENTS +body: '`run start` now accepts `--wait`, which blocks until the run reaches a terminal state, streaming each status transition and exiting non-zero if the run fails, is canceled, is discarded, or fails a mandatory policy. A run whose plan finishes but needs a manual apply (auto-apply disabled) stops instead of hanging. `--timeout` bounds how long to wait; if it elapses, tfctl stops watching and exits non-zero while the run continues in HCP Terraform. On completion the elapsed time and the run URL are printed.' +time: 2026-07-17T02:39:23-04:00 diff --git a/internal/commands/run/run_start.go b/internal/commands/run/run_start.go index 006be71..fa8f804 100644 --- a/internal/commands/run/run_start.go +++ b/internal/commands/run/run_start.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/hashicorp/go-tfe/v2/api/models" @@ -30,6 +31,14 @@ type StartOpts struct { Workspace string DryRun bool Organization string + // Wait blocks until the run reaches a terminal state, then prints its + // status and exits non-zero if the run failed. + Wait bool + // Timeout bounds how long Wait polls (0 means wait indefinitely). + Timeout time.Duration + // PollInterval overrides the wait poll cadence (0 uses defaultPollInterval). + // Primarily a test seam. + PollInterval time.Duration } // CreateOpts defines the options for running a run start, which may be shared with other commands. @@ -100,6 +109,17 @@ func NewCmdRunStart(inv *cmd.Invocation) *cmd.Command { Value: flagvalue.Simple(false, &runOpts.PlanOnly), IsBooleanFlag: true, }, + { + Name: "wait", + Description: "Wait for the run to reach a terminal state, printing status as it progresses. Exits non-zero if the run fails.", + Value: flagvalue.Simple(false, &startOpts.Wait), + IsBooleanFlag: true, + }, + { + Name: "timeout", + Description: "With --wait, the maximum time to wait for the run to finish (e.g. 30m). Defaults to waiting indefinitely.", + Value: flagvalue.Duration(0, &startOpts.Timeout), + }, }, }, Examples: []cmd.Example{ @@ -187,14 +207,69 @@ func runStart(ctx context.Context, opts StartOpts, runOpts CreateOpts) error { newRunID := *response.GetData().GetId() - fmt.Fprintln(io.Err(), heredoc.New(io).Mustf(` + runURL := fmt.Sprintf("https://%s/app/%s/workspaces/%s/runs/%s", + opts.Profile.GetHostname(), *organizationName, *ws.GetAttributes().GetName(), newRunID) + + if !opts.Wait { + fmt.Fprintln(io.Err(), heredoc.New(io).Mustf(` %s %s created. You can monitor the status of the run using: {{ Bold "$ %s run status %s" }} -or by visiting {{ Bold "https://%s/app/%s/workspaces/%s/runs/%s" }} -`, cs.SuccessIcon(), newRunID, version.Name, newRunID, opts.Profile.GetHostname(), *organizationName, *ws.GetAttributes().GetName(), newRunID)) - fmt.Fprintln(io.Err()) +or by visiting {{ Bold "%s" }} +`, cs.SuccessIcon(), newRunID, version.Name, newRunID, runURL)) + fmt.Fprintln(io.Err()) + return nil + } + + return waitForRunAndReport(ctx, opts, newRunID, runURL) +} + +// waitForRunAndReport polls a freshly created run to a terminal state, renders +// its status summary (reusing the same displayer as `run status`), and maps the +// outcome to an exit code: failed runs return cmd.ErrUnderlyingError. +func waitForRunAndReport(ctx context.Context, opts StartOpts, runID, runURL string) error { + io := opts.IO + cs := io.ColorScheme() + + start := time.Now() + fmt.Fprintf(io.Err(), "%s %s created; waiting for it to finish...\n", cs.SuccessIcon(), runID) + + _, outcome, err := pollRunUntilSettled(ctx, opts.APIClient, runID, io, opts.PollInterval, opts.Timeout) + if err != nil { + // The wait was interrupted (timeout or cancel), but the run itself keeps + // running in HCP Terraform. Point the user at it before returning. + fmt.Fprintf(io.Err(), "%s Stopped waiting; the run may still be running in HCP Terraform:\n %s\n", + cs.FailureIcon(), runURL) + return err + } + + summary, err := client.NewRunSummary(ctx, opts.APIClient, runID) + if err != nil { + return err + } + // For an awaiting-confirmation run the raw summary message is the generic + // "Run status: planned"; replace it with something actionable so the final + // line reads as cleanly as the succeeded/failed cases. + if outcome == runAwaitingConfirm { + summary.Message = "Plan finished; a manual apply is required (auto-apply is off)." + } + if err := opts.Output.Display(&summaryDisplayer{summary: summary, io: io}); err != nil { + return err + } + + // Report elapsed wait time and always surface the run URL so a waited run + // stays click-through-able. + elapsed := time.Since(start).Round(time.Second) + switch outcome { + case runFailed: + fmt.Fprintf(io.Err(), "%s Failed after %s. View the run at %s\n", cs.FailureIcon(), elapsed, runURL) + return cmd.ErrUnderlyingError + case runAwaitingConfirm: + fmt.Fprintf(io.Err(), "%s Planned in %s. Confirm the apply at %s\n", cs.SuccessIcon(), elapsed, runURL) + default: // runSucceeded + fmt.Fprintf(io.Err(), "%s Completed in %s. View the run at %s\n", cs.SuccessIcon(), elapsed, runURL) + } return nil } diff --git a/internal/commands/run/run_wait.go b/internal/commands/run/run_wait.go new file mode 100644 index 0000000..7b8e673 --- /dev/null +++ b/internal/commands/run/run_wait.go @@ -0,0 +1,102 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package run + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/tfctl-cli/internal/pkg/client" + "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" +) + +// runOutcome classifies a run's status for the purpose of `run start --wait`. +type runOutcome int + +const ( + // runInProgress means the run is still transitioning and should be polled again. + runInProgress runOutcome = iota + // runSucceeded means the run reached a successful terminal state. + runSucceeded + // runAwaitingConfirm means the plan finished but a manual apply is required + // (the workspace does not auto-apply). Nothing more happens without a human. + runAwaitingConfirm + // runFailed means the run reached a failed or aborted terminal state. + runFailed +) + +// defaultPollInterval is how often `--wait` polls the run when no interval is set. +const defaultPollInterval = 3 * time.Second + +// classifyRunStatus maps a run status string, plus whether the run is awaiting a +// manual apply confirmation, to a wait outcome. Statuses not listed are treated +// as in-progress so the poller keeps waiting; auto-apply runs transition through +// planned/confirmed/applying on their own. A run that is confirmable has finished +// planning but will not proceed without a human, so we stop there rather than +// block forever on a non-auto-apply workspace. +func classifyRunStatus(status string, confirmable bool) runOutcome { + switch status { + case "applied", "planned_and_finished", "planned_and_saved": + return runSucceeded + case "errored", "canceled", "discarded", "policy_soft_failed", "policy_override": + return runFailed + } + if confirmable { + return runAwaitingConfirm + } + return runInProgress +} + +// pollRunUntilSettled polls the run until it reaches a settled state (finished, +// failed, or awaiting manual confirmation), printing each status transition to +// stderr. It returns the final status string and its classified outcome. It +// stops early if ctx is canceled or the optional timeout elapses. +func pollRunUntilSettled(ctx context.Context, c *client.Client, runID string, io iostreams.IOStreams, interval, timeout time.Duration) (string, runOutcome, error) { + if interval <= 0 { + interval = defaultPollInterval + } + var deadline time.Time + if timeout > 0 { + deadline = time.Now().Add(timeout) + } + cs := io.ColorScheme() + + last := "" + for { + resp, err := c.TFE.API.Runs().ById(runID).Get(ctx, nil) + if err != nil { + return "", runInProgress, fmt.Errorf("polling run %s: %w", runID, err) + } + attrs := resp.GetData().GetAttributes() + if attrs == nil || attrs.GetStatus() == nil { + return "", runInProgress, fmt.Errorf("run %s has no status", runID) + } + status := attrs.GetStatus().String() + + confirmable := false + if a := attrs.GetActions(); a != nil && a.GetIsConfirmable() != nil { + confirmable = *a.GetIsConfirmable() + } + + if status != last { + fmt.Fprintln(io.Err(), cs.String(" ⋯ "+status).Faint().String()) + last = status + } + + if outcome := classifyRunStatus(status, confirmable); outcome != runInProgress { + return status, outcome, nil + } + + if !deadline.IsZero() && time.Now().After(deadline) { + return status, runInProgress, fmt.Errorf("timed out after %s waiting for run %s (last status: %s)", timeout, runID, status) + } + + select { + case <-ctx.Done(): + return status, runInProgress, ctx.Err() + case <-time.After(interval): + } + } +} diff --git a/internal/commands/run/run_wait_test.go b/internal/commands/run/run_wait_test.go new file mode 100644 index 0000000..046dd91 --- /dev/null +++ b/internal/commands/run/run_wait_test.go @@ -0,0 +1,351 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package run + +import ( + "context" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfctl-cli/internal/pkg/cmd" + "github.com/hashicorp/tfctl-cli/internal/pkg/format" + "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" + "github.com/hashicorp/tfctl-cli/internal/pkg/profile" +) + +func TestClassifyRunStatus(t *testing.T) { + t.Parallel() + + cases := []struct { + status string + confirmable bool + want runOutcome + }{ + {"applied", false, runSucceeded}, + {"planned_and_finished", false, runSucceeded}, + {"planned_and_saved", false, runSucceeded}, + {"errored", false, runFailed}, + {"canceled", false, runFailed}, + {"discarded", false, runFailed}, + {"policy_soft_failed", false, runFailed}, + {"policy_override", false, runFailed}, + {"planning", false, runInProgress}, + {"applying", false, runInProgress}, + {"pending", false, runInProgress}, + // A confirmable plan is done but needs a manual apply; not in-progress. + {"planned", true, runAwaitingConfirm}, + // Confirmable must not override a terminal failure state. + {"errored", true, runFailed}, + } + for _, tc := range cases { + assert.Equalf(t, tc.want, classifyRunStatus(tc.status, tc.confirmable), + "status=%q confirmable=%v", tc.status, tc.confirmable) + } +} + +// runGetResponder returns a handler for GET /api/v2/runs/{id} that emits the +// given statuses in order, repeating the last one for any further calls. +func runGetResponder(runID string, statuses ...string) http.HandlerFunc { + var n int32 + return func(w http.ResponseWriter, _ *http.Request) { + i := int(atomic.AddInt32(&n, 1)) - 1 + if i >= len(statuses) { + i = len(statuses) - 1 + } + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": runID, "type": "runs", + "attributes": map[string]any{"status": statuses[i]}, + }, + }) + } +} + +func TestPollRunUntilSettled_Errored(t *testing.T) { + t.Parallel() + io := iostreams.Test() + + c := testAPI(t, runGetResponder("run-x", "planning", "errored")) + + status, outcome, err := pollRunUntilSettled(context.Background(), c, "run-x", io, time.Millisecond, 0) + require.NoError(t, err) + assert.Equal(t, "errored", status) + assert.Equal(t, runFailed, outcome) + // Transitions are streamed to stderr. + assert.Contains(t, io.Error.String(), "planning") + assert.Contains(t, io.Error.String(), "errored") +} + +func TestPollRunUntilSettled_AwaitingConfirm(t *testing.T) { + t.Parallel() + io := iostreams.Test() + + c := testAPI(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "run-x", "type": "runs", + "attributes": map[string]any{ + "status": "planned", + "actions": map[string]any{"is-confirmable": true}, + }, + }, + }) + })) + + status, outcome, err := pollRunUntilSettled(context.Background(), c, "run-x", io, time.Millisecond, 0) + require.NoError(t, err) + assert.Equal(t, "planned", status) + assert.Equal(t, runAwaitingConfirm, outcome) +} + +func TestPollRunUntilSettled_Timeout(t *testing.T) { + t.Parallel() + io := iostreams.Test() + + // Never settles. + c := testAPI(t, runGetResponder("run-x", "planning")) + + _, outcome, err := pollRunUntilSettled(context.Background(), c, "run-x", io, time.Millisecond, 10*time.Millisecond) + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out") + assert.Equal(t, runInProgress, outcome) +} + +func TestRunStart_Wait_Success(t *testing.T) { + t.Parallel() + io := iostreams.Test() + + var runGets int32 + c := testAPI(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch route(r) { + case "GET /api/v2/workspaces/ws-abc123": + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "ws-resolved", "type": "workspaces", + "attributes": map[string]any{"name": "foobar"}, + "relationships": map[string]any{ + "organization": map[string]any{ + "data": map[string]any{"id": "my-org", "type": "organizations"}, + }, + }, + }, + }) + case "POST /api/v2/runs": + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "run-waited", "type": "runs", + "attributes": map[string]any{"status": "pending"}, + }, + }) + case "GET /api/v2/runs/run-waited": + status := "planning" + if atomic.AddInt32(&runGets, 1) > 1 { + status = "planned_and_finished" + } + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "run-waited", "type": "runs", + "attributes": map[string]any{"status": status}, + }, + }) + default: + http.Error(w, "unexpected: "+route(r), http.StatusInternalServerError) + } + })) + + err := runStart(context.Background(), StartOpts{ + IO: io, + APIClient: c, + Profile: profile.TestProfile(t), + Output: format.New(io), + Workspace: "ws-abc123", + Wait: true, + PollInterval: time.Millisecond, + }, CreateOpts{}) + + require.NoError(t, err) + assert.Contains(t, io.Error.String(), "waiting for it to finish") + assert.Contains(t, io.Error.String(), "planned_and_finished") + // The final run summary is rendered to stdout, same as `run status`. + assert.Contains(t, io.Output.String(), "Plan complete, no apply needed") + // Elapsed time and the run URL are always surfaced on completion. + assert.Contains(t, io.Error.String(), "Completed in") + assert.Contains(t, io.Error.String(), "View the run at") + assert.Contains(t, io.Error.String(), "workspaces/foobar/runs/run-waited") +} + +func TestRunStart_Wait_Timeout(t *testing.T) { + t.Parallel() + io := iostreams.Test() + + // The run never settles; --wait must give up and still surface the URL. + c := testAPI(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch route(r) { + case "GET /api/v2/workspaces/ws-abc123": + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "ws-resolved", "type": "workspaces", + "attributes": map[string]any{"name": "foobar"}, + "relationships": map[string]any{ + "organization": map[string]any{ + "data": map[string]any{"id": "my-org", "type": "organizations"}, + }, + }, + }, + }) + case "POST /api/v2/runs": + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "run-slow", "type": "runs", + "attributes": map[string]any{"status": "pending"}, + }, + }) + case "GET /api/v2/runs/run-slow": + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "run-slow", "type": "runs", + "attributes": map[string]any{"status": "planning"}, + }, + }) + default: + http.Error(w, "unexpected: "+route(r), http.StatusInternalServerError) + } + })) + + err := runStart(context.Background(), StartOpts{ + IO: io, + APIClient: c, + Profile: profile.TestProfile(t), + Output: format.New(io), + Workspace: "ws-abc123", + Wait: true, + PollInterval: time.Millisecond, + Timeout: 10 * time.Millisecond, + }, CreateOpts{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out") + assert.Contains(t, io.Error.String(), "still be running") + assert.Contains(t, io.Error.String(), "workspaces/foobar/runs/run-slow") +} + +func TestRunStart_Wait_AwaitingConfirm(t *testing.T) { + t.Parallel() + io := iostreams.Test() + + c := testAPI(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch route(r) { + case "GET /api/v2/workspaces/ws-abc123": + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "ws-resolved", "type": "workspaces", + "attributes": map[string]any{"name": "foobar"}, + "relationships": map[string]any{ + "organization": map[string]any{ + "data": map[string]any{"id": "my-org", "type": "organizations"}, + }, + }, + }, + }) + case "POST /api/v2/runs": + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "run-confirm", "type": "runs", + "attributes": map[string]any{"status": "pending"}, + }, + }) + case "GET /api/v2/runs/run-confirm": + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "run-confirm", "type": "runs", + "attributes": map[string]any{ + "status": "planned", + "actions": map[string]any{"is-confirmable": true}, + }, + }, + }) + default: + http.Error(w, "unexpected: "+route(r), http.StatusInternalServerError) + } + })) + + err := runStart(context.Background(), StartOpts{ + IO: io, + APIClient: c, + Profile: profile.TestProfile(t), + Output: format.New(io), + Workspace: "ws-abc123", + Wait: true, + PollInterval: time.Millisecond, + }, CreateOpts{}) + + require.NoError(t, err) + // The generic "Run status: planned" line is replaced with actionable text, + // and the apply URL is surfaced. + assert.Contains(t, io.Output.String(), "manual apply is required") + assert.NotContains(t, io.Output.String(), "Run status: planned") + assert.Contains(t, io.Error.String(), "Confirm the apply at") + assert.Contains(t, io.Error.String(), "workspaces/foobar/runs/run-confirm") +} + +func TestRunStart_Wait_Failure(t *testing.T) { + t.Parallel() + io := iostreams.Test() + + // canceled is a failure state that NewRunSummary renders without any extra + // API calls, so it exercises the full wait-then-exit-code path cleanly. + c := testAPI(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch route(r) { + case "GET /api/v2/workspaces/ws-abc123": + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "ws-resolved", "type": "workspaces", + "attributes": map[string]any{"name": "foobar"}, + "relationships": map[string]any{ + "organization": map[string]any{ + "data": map[string]any{"id": "my-org", "type": "organizations"}, + }, + }, + }, + }) + case "POST /api/v2/runs": + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "run-cancel", "type": "runs", + "attributes": map[string]any{"status": "pending"}, + }, + }) + case "GET /api/v2/runs/run-cancel": + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "run-cancel", "type": "runs", + "attributes": map[string]any{"status": "canceled"}, + }, + }) + default: + http.Error(w, "unexpected: "+route(r), http.StatusInternalServerError) + } + })) + + err := runStart(context.Background(), StartOpts{ + IO: io, + APIClient: c, + Profile: profile.TestProfile(t), + Output: format.New(io), + Workspace: "ws-abc123", + Wait: true, + PollInterval: time.Millisecond, + }, CreateOpts{}) + + require.ErrorIs(t, err, cmd.ErrUnderlyingError) + assert.Contains(t, io.Output.String(), "Run was canceled") + assert.Contains(t, io.Error.String(), "Failed after") + assert.Contains(t, io.Error.String(), "View the run at") + assert.Contains(t, io.Error.String(), "workspaces/foobar/runs/run-cancel") +}