Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/ENHANCEMENTS-20260717-023923.yaml
Original file line number Diff line number Diff line change
@@ -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
83 changes: 79 additions & 4 deletions internal/commands/run/run_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"fmt"
"strings"
"time"

"github.com/hashicorp/go-tfe/v2/api/models"

Expand All @@ -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.
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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
}

Expand Down
102 changes: 102 additions & 0 deletions internal/commands/run/run_wait.go
Original file line number Diff line number Diff line change
@@ -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):
}
}
}
Loading