-
Notifications
You must be signed in to change notification settings - Fork 5
run start: add --wait to block until the run finishes #87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should use a context timeout instead of a loop condition so that the timeout is more strictly adhered to.
Suggested change
|
||||||||||
| 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)." | ||||||||||
| } | ||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you promote this message to NewRunSummary/buildRunSummary? I think this could be valuable for any command that uses NewRunSummary. NewRunSummary reads the Run and can use Actions.IsConfirmable to refine the message based on the status in buildRunSummary... and export the value so you can read it off summary as a caller. |
||||||||||
| 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 | ||||||||||
| } | ||||||||||
|
|
||||||||||
|
|
||||||||||
| 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): | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's mention the valid units and parsing arrangement.