From cc4ee772cdf7b8dc64f9320e2012b4a6d22fa090 Mon Sep 17 00:00:00 2001 From: Jordan English <6087717+jordanenglish@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:39:50 -0400 Subject: [PATCH 01/10] run start: add --wait to block until the run finishes `run start --wait` polls the newly created run to a terminal state, streaming each status transition, and maps the outcome to an exit code: applied / planned_and_finished / planned_and_saved succeed, while errored, canceled, discarded, and policy failures exit non-zero. A plan that finishes but needs a manual apply (auto-apply disabled) stops rather than hanging. --timeout bounds the wait; if it elapses, tfctl stops watching and exits non-zero, but the run keeps running in HCP Terraform. On completion the elapsed time and run URL are printed. The final summary reuses the same renderer as `run status`. --- .../ENHANCEMENTS-20260717-023923.yaml | 3 + internal/commands/run/run_start.go | 83 ++++- internal/commands/run/run_wait.go | 102 +++++ internal/commands/run/run_wait_test.go | 351 ++++++++++++++++++ 4 files changed, 535 insertions(+), 4 deletions(-) create mode 100644 .changes/unreleased/ENHANCEMENTS-20260717-023923.yaml create mode 100644 internal/commands/run/run_wait.go create mode 100644 internal/commands/run/run_wait_test.go 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 1835c95..2c19ca9 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.ErrUnessential(), 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.ErrUnessential(), 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.ErrUnessential()) +or by visiting {{ Bold "%s" }} +`, cs.SuccessIcon(), newRunID, version.Name, newRunID, runURL)) + fmt.Fprintln(io.ErrUnessential()) + 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") +} From a81b7a427ad4c84fa8b8ce9c4d7bd16bc199667a Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Mon, 20 Jul 2026 12:45:11 -0600 Subject: [PATCH 02/10] update --wait description --- internal/commands/run/run_start.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/commands/run/run_start.go b/internal/commands/run/run_start.go index 2c19ca9..296759e 100644 --- a/internal/commands/run/run_start.go +++ b/internal/commands/run/run_start.go @@ -117,7 +117,7 @@ func NewCmdRunStart(inv *cmd.Invocation) *cmd.Command { }, { Name: "timeout", - Description: "With --wait, the maximum time to wait for the run to finish (e.g. 30m). Defaults to waiting indefinitely.", + Description: "With --wait, the maximum time to wait for the run to finish. Defaults to waiting indefinitely. Examples include \"30s\", \"1.5h\" or \"2h45m\". Valid time units are \"s\", \"m\", \"h\".", Value: flagvalue.Duration(0, &startOpts.Timeout), }, }, @@ -135,6 +135,10 @@ func NewCmdRunStart(inv *cmd.Invocation) *cmd.Command { Preamble: "Start a plan-only run that will not be applied", Command: heredoc.New(inv.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Mustf(`$ %s run start ws-abc123 --plan-only`, version.Name), }, + { + Preamble: "Wait for a run to terminate for up to 90 minutes", + Command: heredoc.New(inv.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Mustf(`$ %s run start ws-abc123 --wait --timeout 1.5h`, version.Name), + }, }, RunF: func(_ *cmd.Command, args []string) error { if len(args) != 1 { From a0a855dcb8197354f8ed69483a1f7e47fe5e5cf2 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Mon, 20 Jul 2026 12:45:42 -0600 Subject: [PATCH 03/10] refactor: move run polling to client package --- internal/commands/run/run_start.go | 8 +- internal/commands/run/run_start_test.go | 237 ++++++++++++ internal/commands/run/run_wait_test.go | 351 ------------------ .../{commands/run => pkg/client}/run_wait.go | 27 +- internal/pkg/client/run_wait_test.go | 159 ++++++++ 5 files changed, 412 insertions(+), 370 deletions(-) delete mode 100644 internal/commands/run/run_wait_test.go rename internal/{commands/run => pkg/client}/run_wait.go (71%) create mode 100644 internal/pkg/client/run_wait_test.go diff --git a/internal/commands/run/run_start.go b/internal/commands/run/run_start.go index 296759e..b63210d 100644 --- a/internal/commands/run/run_start.go +++ b/internal/commands/run/run_start.go @@ -5,6 +5,7 @@ package run import ( "context" + "errors" "fmt" "strings" "time" @@ -239,7 +240,12 @@ func waitForRunAndReport(ctx context.Context, opts StartOpts, runID, runURL stri 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) + ctx, cancel := context.WithTimeoutCause(ctx, opts.Timeout, errors.New("--wait timeout exceeded")) + defer cancel() + + _, outcome, err := client.PollRunUntilTerminated(ctx, opts.APIClient, runID, io, opts.PollInterval, func(status string) { + fmt.Fprintln(io.Err(), cs.String(" ⋯ "+status).Faint().String()) + }) 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. diff --git a/internal/commands/run/run_start_test.go b/internal/commands/run/run_start_test.go index 52364d9..2a9c722 100644 --- a/internal/commands/run/run_start_test.go +++ b/internal/commands/run/run_start_test.go @@ -7,11 +7,15 @@ import ( "context" "encoding/json" "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" ) @@ -347,3 +351,236 @@ func TestRunStart(t *testing.T) { assert.Contains(t, err.Error(), "failed to start run") }) } + +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") +} diff --git a/internal/commands/run/run_wait_test.go b/internal/commands/run/run_wait_test.go deleted file mode 100644 index 046dd91..0000000 --- a/internal/commands/run/run_wait_test.go +++ /dev/null @@ -1,351 +0,0 @@ -// 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") -} diff --git a/internal/commands/run/run_wait.go b/internal/pkg/client/run_wait.go similarity index 71% rename from internal/commands/run/run_wait.go rename to internal/pkg/client/run_wait.go index 7b8e673..7c0b843 100644 --- a/internal/commands/run/run_wait.go +++ b/internal/pkg/client/run_wait.go @@ -1,14 +1,13 @@ // Copyright IBM Corp. 2026 // SPDX-License-Identifier: MPL-2.0 -package run +package client import ( "context" "fmt" "time" - "github.com/hashicorp/tfctl-cli/internal/pkg/client" "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" ) @@ -27,7 +26,7 @@ const ( runFailed ) -// defaultPollInterval is how often `--wait` polls the run when no interval is set. +// defaultPollInterval is how often run is polled when no interval is set. const defaultPollInterval = 3 * time.Second // classifyRunStatus maps a run status string, plus whether the run is awaiting a @@ -49,19 +48,13 @@ func classifyRunStatus(status string, confirmable bool) runOutcome { 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) { +// PollRunUntilTerminated polls the run indefinitely until it reaches a settled state +// (finished, failed, or awaiting manual confirmation), notifying on each status transition. +// It returns the final status string and its classified outcome. +func PollRunUntilTerminated(ctx context.Context, c *Client, runID string, io iostreams.IOStreams, interval time.Duration, statusUpdate func(string)) (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 { @@ -81,7 +74,9 @@ func pollRunUntilSettled(ctx context.Context, c *client.Client, runID string, io } if status != last { - fmt.Fprintln(io.Err(), cs.String(" ⋯ "+status).Faint().String()) + if statusUpdate != nil { + statusUpdate(status) + } last = status } @@ -89,10 +84,6 @@ func pollRunUntilSettled(ctx context.Context, c *client.Client, runID string, io 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() diff --git a/internal/pkg/client/run_wait_test.go b/internal/pkg/client/run_wait_test.go new file mode 100644 index 0000000..9b13a10 --- /dev/null +++ b/internal/pkg/client/run_wait_test.go @@ -0,0 +1,159 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package client + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" +) + +func jsonapi(w http.ResponseWriter, payload any) { + w.Header().Set("Content-Type", "application/vnd.api+json") + _ = json.NewEncoder(w).Encode(payload) +} + +func testAPI(t *testing.T, handler http.Handler) *Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + c, err := New(context.Background(), server.URL, "test-token", nil) + require.NoError(t, err) + return c +} + +func noopStatus(status string) {} + +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 TestPollRunUntilTerminated_Errored(t *testing.T) { + t.Parallel() + io := iostreams.Test() + + c := testAPI(t, runGetResponder("run-x", "planning", "errored")) + + status, outcome, err := PollRunUntilTerminated(context.Background(), c, "run-x", io, time.Millisecond, noopStatus) + require.NoError(t, err) + assert.Equal(t, "errored", status) + assert.Equal(t, runFailed, outcome) +} + +func TestPollRunUntilTerminated_Status(t *testing.T) { + t.Parallel() + io := iostreams.Test() + + c := testAPI(t, runGetResponder("run-x", "planning", "errored")) + + sawPlanning := false + sawErrored := false + + status, outcome, err := PollRunUntilTerminated(context.Background(), c, "run-x", io, time.Millisecond, func(s string) { + if s == "planning" { + sawPlanning = true + } + if s == "errored" { + sawErrored = true + } + }) + require.NoError(t, err) + assert.Equal(t, "errored", status) + assert.Equal(t, runFailed, outcome) + assert.True(t, sawPlanning, "expected to see planning status") + assert.True(t, sawErrored, "expected to see errored status") +} + +func TestPollRunUntilTerminated_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 := PollRunUntilTerminated(context.Background(), c, "run-x", io, time.Millisecond, noopStatus) + require.NoError(t, err) + assert.Equal(t, "planned", status) + assert.Equal(t, runAwaitingConfirm, outcome) +} + +func TestPollRunUntilTerminated_Timeout(t *testing.T) { + + t.Parallel() + io := iostreams.Test() + + // Never settles. + c := testAPI(t, runGetResponder("run-x", "planning")) + + ctx, cancel := context.WithTimeoutCause(context.Background(), 10*time.Millisecond, errors.New("timed out!")) + defer cancel() + + _, outcome, err := PollRunUntilTerminated(ctx, c, "run-x", io, time.Millisecond, noopStatus) + require.Error(t, err) + assert.Equal(t, "timed out!", context.Cause(ctx).Error()) + assert.Equal(t, runInProgress, outcome) +} From 068b65504586d17e65c4920ee2bab03e29ef8384 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Tue, 21 Jul 2026 15:47:03 -0600 Subject: [PATCH 04/10] refactor: move run text to summary displayer --- internal/commands/run/run_start.go | 27 +- internal/commands/run/run_start_test.go | 25 +- internal/commands/run/run_status.go | 640 ------------------- internal/commands/run/summary_displayer.go | 690 +++++++++++++++++++++ internal/pkg/client/run_summary.go | 56 +- internal/pkg/client/run_wait.go | 40 +- internal/pkg/client/run_wait_test.go | 36 +- 7 files changed, 807 insertions(+), 707 deletions(-) create mode 100644 internal/commands/run/summary_displayer.go diff --git a/internal/commands/run/run_start.go b/internal/commands/run/run_start.go index b63210d..2e7ca64 100644 --- a/internal/commands/run/run_start.go +++ b/internal/commands/run/run_start.go @@ -240,8 +240,11 @@ func waitForRunAndReport(ctx context.Context, opts StartOpts, runID, runURL stri start := time.Now() fmt.Fprintf(io.Err(), "%s %s created; waiting for it to finish...\n", cs.SuccessIcon(), runID) - ctx, cancel := context.WithTimeoutCause(ctx, opts.Timeout, errors.New("--wait timeout exceeded")) - defer cancel() + if opts.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errors.New("--wait timeout exceeded")) + defer cancel() + } _, outcome, err := client.PollRunUntilTerminated(ctx, opts.APIClient, runID, io, opts.PollInterval, func(status string) { fmt.Fprintln(io.Err(), cs.String(" ⋯ "+status).Faint().String()) @@ -258,27 +261,19 @@ func waitForRunAndReport(ctx context.Context, opts StartOpts, runID, runURL stri 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)." + summary.RunURL = runURL + + if outcome == client.RunAwaitingConfirm { + summary.Message = "Plan finished; a manual apply is required (auto-apply is off). Confirm the apply by visiting the run URL." } + 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) + if outcome == client.RunFailed { 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_start_test.go b/internal/commands/run/run_start_test.go index 2a9c722..2d74781 100644 --- a/internal/commands/run/run_start_test.go +++ b/internal/commands/run/run_start_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "net/http" + "strings" "sync/atomic" "testing" "time" @@ -409,10 +410,9 @@ func TestRunStart_Wait_Success(t *testing.T) { 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") + // The run URL is surfaced in the displayer output. + assert.Contains(t, io.Output.String(), "View run:") + assert.Contains(t, io.Output.String(), "workspaces/foobar/runs/run-waited") } func TestRunStart_Wait_Timeout(t *testing.T) { @@ -465,7 +465,11 @@ func TestRunStart_Wait_Timeout(t *testing.T) { }, CreateOpts{}) require.Error(t, err) - assert.Contains(t, err.Error(), "timed out") + // The error chain includes either the context deadline or the cause message. + errStr := err.Error() + assert.True(t, + strings.Contains(errStr, "--wait timeout exceeded") || strings.Contains(errStr, "deadline exceeded"), + "expected timeout-related error, got: %s", errStr) assert.Contains(t, io.Error.String(), "still be running") assert.Contains(t, io.Error.String(), "workspaces/foobar/runs/run-slow") } @@ -522,11 +526,11 @@ func TestRunStart_Wait_AwaitingConfirm(t *testing.T) { require.NoError(t, err) // The generic "Run status: planned" line is replaced with actionable text, - // and the apply URL is surfaced. + // and the apply URL is surfaced in the displayer output. 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") + assert.Contains(t, io.Output.String(), "Confirm the apply by") + assert.Contains(t, io.Output.String(), "workspaces/foobar/runs/run-confirm") } func TestRunStart_Wait_Failure(t *testing.T) { @@ -580,7 +584,6 @@ func TestRunStart_Wait_Failure(t *testing.T) { 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") + assert.Contains(t, io.Output.String(), "View run:") + assert.Contains(t, io.Output.String(), "workspaces/foobar/runs/run-cancel") } diff --git a/internal/commands/run/run_status.go b/internal/commands/run/run_status.go index 495ad7c..fca8a57 100644 --- a/internal/commands/run/run_status.go +++ b/internal/commands/run/run_status.go @@ -6,11 +6,8 @@ package run import ( "context" "fmt" - "regexp" "strings" - "github.com/mitchellh/go-wordwrap" - "github.com/hashicorp/tfctl-cli/internal/pkg/client" "github.com/hashicorp/tfctl-cli/internal/pkg/cmd" "github.com/hashicorp/tfctl-cli/internal/pkg/flagvalue" @@ -148,640 +145,3 @@ func runStatus(ctx context.Context, opts *StatusOpts) error { } return nil } - -// summaryDisplayer implements format.Displayer and format.StringPayload. -type summaryDisplayer struct { - summary *client.RunSummary - io iostreams.IOStreams -} - -var ( - _ format.Displayer = (*summaryDisplayer)(nil) - _ format.StringPayload = (*summaryDisplayer)(nil) -) - -func (d *summaryDisplayer) DefaultFormat() format.Format { return format.Pretty } -func (d *summaryDisplayer) Payload() any { return d.summary } -func (d *summaryDisplayer) FieldTemplates() []format.Field { - return nil -} - -// StringPayload returns pre-formatted output tailored to the given format. -func (d *summaryDisplayer) StringPayload(f format.Format) string { - multipleFailures := make([]string, 0, 1) - - if len(d.summary.Diagnostics) > 0 { - multipleFailures = append(multipleFailures, d.formatDiagnostics(f)) - } - if d.summary.PolicyCheckLog != "" { - multipleFailures = append(multipleFailures, d.formatPolicyChecks(f)) - } - if len(d.summary.PolicyEvaluations) > 0 { - multipleFailures = append(multipleFailures, d.formatPolicyEvaluations(f)) - } - if len(d.summary.TaskResults) > 0 { - multipleFailures = append(multipleFailures, d.formatTaskResults(f)) - } - - if len(multipleFailures) == 0 { - if d.summary.RawLog != "" { - if f == format.Markdown { - return stripANSI(d.summary.RawLog) - } - return d.summary.RawLog - } - return d.summary.Message - } - - if f == format.Markdown { - return strings.Join(multipleFailures, "\n\n---\n\n") - } - cs := d.io.ColorScheme() - return strings.Join(multipleFailures, cs.String("\n――――――――――――\n\n").Color(cs.Gray()).Faint().String()) -} - -var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) - -func (d *summaryDisplayer) formatDiagnostics(f format.Format) string { - switch f { - case format.Markdown: - return d.formatDiagnosticsMarkdown() - default: - return d.formatDiagnosticsPretty() - } -} - -func (d *summaryDisplayer) formatPolicyChecks(f format.Format) string { - switch f { - case format.Markdown: - return d.formatPolicyCheckLogMarkdown() - default: - return d.formatPolicyCheckLogPretty() - } -} - -func (d *summaryDisplayer) formatDiagnosticsPretty() string { - cs := d.io.ColorScheme() - const leftRuleWidth = 2 - wrapWidth := d.io.TerminalWidth() - leftRuleWidth - - var out strings.Builder - for i, diag := range d.summary.Diagnostics { - if i > 0 { - out.WriteString("\n") - } - - color := cs.Red() - label := "Error" - if diag.Severity == "warning" { - color = cs.Orange() - label = "Warning" - } - - var body strings.Builder - body.WriteString(cs.String(fmt.Sprintf("%s: ", label)).Color(color).Bold().String()) - body.WriteString(cs.String(diag.Summary).Bold().String()) - body.WriteString("\n") - - if diag.Range != nil { - body.WriteString("\n") - loc := fmt.Sprintf(" on %s line %d", diag.Range.Filename, diag.Range.Start.Line) - if diag.Snippet != nil && diag.Snippet.Context != nil { - loc += fmt.Sprintf(", in %s", *diag.Snippet.Context) - } - loc += ":" - body.WriteString(loc) - body.WriteString("\n") - } - - if diag.Snippet != nil { - body.WriteString(formatSnippet(cs, diag.Snippet)) - } - - if diag.Detail != "" { - body.WriteString("\n") - for _, line := range strings.Split(diag.Detail, "\n") { - if wrapWidth > 0 && line != "" && line[0] != ' ' { - line = wordwrap.WrapString(line, uint(wrapWidth)) - } - body.WriteString(line) - body.WriteString("\n") - } - } - - rule := cs.String("│").Color(color).String() - out.WriteString(cs.String("╷").Color(color).String()) - out.WriteString("\n") - for _, line := range strings.Split(strings.TrimRight(body.String(), "\n"), "\n") { - out.WriteString(rule) - if line != "" { - out.WriteString(" ") - out.WriteString(line) - } - out.WriteString("\n") - } - out.WriteString(cs.String("╵").Color(color).String()) - } - out.WriteString("\n") - return out.String() -} - -func (d *summaryDisplayer) formatDiagnosticsMarkdown() string { - var out strings.Builder - for i, diag := range d.summary.Diagnostics { - if i > 0 { - out.WriteString("\n\n---\n\n") - } - label := "Error" - if diag.Severity == "warning" { - label = "Warning" - } - fmt.Fprintf(&out, "**%s: %s**\n", label, diag.Summary) - if diag.Range != nil { - loc := fmt.Sprintf("on %s line %d", diag.Range.Filename, diag.Range.Start.Line) - if diag.Snippet != nil && diag.Snippet.Context != nil { - loc += fmt.Sprintf(", in %s", *diag.Snippet.Context) - } - fmt.Fprintf(&out, "\n%s:\n", loc) - } - if diag.Snippet != nil { - fmt.Fprintf(&out, "\n```hcl\n%s\n```\n", diag.Snippet.Code) - } - if diag.Detail != "" { - fmt.Fprintf(&out, "\n%s\n", diag.Detail) - } - } - return out.String() -} - -// --- Policy check log (legacy Sentinel) --- - -func policyScopeLabel(scope string) string { - switch scope { - case "organization": - return "Organization Policy Check" - case "workspace": - return "Workspace Policy Check" - default: - if scope != "" { - return fmt.Sprintf("Policy Check (%s)", scope) - } - return "Policy Check" - } -} - -// cleanPolicyCheckLog cleans up the Sentinel runner's policy set header line -// when the policy set name is empty (non-VCS policy sets produce -// "" as the name). -var policySetHeaderRe = regexp.MustCompile(`(?m)^=+ Results for policy set: =+\n`) - -func cleanPolicyCheckLog(log string) string { - return policySetHeaderRe.ReplaceAllString(log, "") -} - -func (d *summaryDisplayer) formatPolicyCheckLogPretty() string { - cs := d.io.ColorScheme() - var out strings.Builder - - header := policyScopeLabel(d.summary.PolicyCheckScope) - out.WriteString(cs.String(header + ":").Bold().String()) - out.WriteString("\n\n") - out.WriteString(cleanPolicyCheckLog(d.summary.PolicyCheckLog)) - - // Add status footer. - switch d.summary.PolicyCheckStatus { - case "hard_failed": - out.WriteString("\n") - out.WriteString(cs.String(header + " hard failed.").Color(cs.Red()).String()) - out.WriteString("\n") - case "soft_failed": - out.WriteString("\n") - out.WriteString(cs.String(header + " soft failed.").Color(cs.Orange()).String()) - out.WriteString("\n") - case "errored": - out.WriteString("\n") - out.WriteString(cs.String(header + " errored.").Color(cs.Red()).String()) - out.WriteString("\n") - } - - return out.String() -} - -func (d *summaryDisplayer) formatPolicyCheckLogMarkdown() string { - var out strings.Builder - - header := policyScopeLabel(d.summary.PolicyCheckScope) - fmt.Fprintf(&out, "## %s\n\n", header) - fmt.Fprintf(&out, "```\n%s\n```\n", stripANSI(cleanPolicyCheckLog(d.summary.PolicyCheckLog))) - - switch d.summary.PolicyCheckStatus { - case "hard_failed": - fmt.Fprintf(&out, "\n**%s hard failed.**\n", header) - case "soft_failed": - fmt.Fprintf(&out, "\n**%s soft failed.**\n", header) - case "errored": - fmt.Fprintf(&out, "\n**%s errored.**\n", header) - } - - return out.String() -} - -func policyKindLabel(kind string) string { - if kind == "sentinel" { - return "Sentinel" - } - return "OPA" -} - -// Unicode symbols matching Terraform CLI output. -const ( - symbolTick = "\u2713" // ✓ - symbolCross = "\u00d7" // × - symbolInfo = "\u24be" // Ⓘ - symbolArrow = "\u2192" // → - symbolDownArrow = "\u21b3" // ↳ - symbolDash = "\u2e3a" // ⸺ -) - -// policyIcon returns a colored icon for a policy outcome, matching TF CLI. -func policyIcon(cs *iostreams.ColorScheme, status, enforcementLevel string) iostreams.String { - switch status { - case "passed": - return cs.String(symbolTick).Color(cs.Green()).Bold() - case "failed": - if enforcementLevel == "advisory" { - return cs.String(symbolInfo).Color(cs.Orange()).Bold() - } - return cs.String(symbolCross).Color(cs.Red()).Bold() - default: - return cs.String("-") - } -} - -// policyStatusLabel returns the display label for a policy status, matching TF CLI. -func policyStatusLabel(status, enforcementLevel string) string { - switch status { - case "passed": - return "Passed" - case "failed": - if enforcementLevel == "advisory" { - return "Advisory" - } - return "Failed" - default: - return status - } -} - -// taskStatusLabel returns a colored status string for a run task, matching TF CLI. -func taskStatusLabel(cs *iostreams.ColorScheme, status, enforcementLevel string) iostreams.String { - switch status { - case "passed": - return cs.String("Passed").Color(cs.Green()) - case "failed": - label := "Failed" - if enforcementLevel != "" { - label += " (" + strings.ToUpper(enforcementLevel[:1]) + enforcementLevel[1:] + ")" - } - return cs.String(label).Color(cs.Red()) - default: - return cs.String(status) - } -} - -// --- Policy evaluations --- - -func (d *summaryDisplayer) formatPolicyEvaluations(f format.Format) string { - switch f { - case format.Markdown: - return d.formatPolicyEvaluationsMarkdown() - default: - return d.formatPolicyEvaluationsPretty() - } -} - -func (d *summaryDisplayer) formatPolicyEvaluationsPretty() string { - cs := d.io.ColorScheme() - var out strings.Builder - - out.WriteString(cs.String("Policy Evaluations:").Bold().String()) - out.WriteString("\n") - - for i, eval := range d.summary.PolicyEvaluations { - out.WriteString("\n") - kind := policyKindLabel(eval.PolicyKind) - out.WriteString(cs.String(kind + " Policy Evaluation").Bold().String()) - out.WriteString("\n") - - if eval.Error != "" { - fmt.Fprintf(&out, "%s %s %s\n", - cs.String(symbolArrow+symbolArrow).Bold(), - cs.String("Overall Result:").Bold(), - cs.String("ERRORED").Color(cs.Red()).Bold()) - fmt.Fprintf(&out, " %s\n", cs.String(eval.Error).Faint()) - if eval.PolicySetName != "" { - fmt.Fprintf(&out, "\n%s Policy set 1: %s\n", - cs.String(symbolArrow).Bold(), - cs.String(eval.PolicySetName).Bold()) - } - continue - } - - // Compute overall result. - overallResult := "PASSED" - overallColor := cs.Green() - hasAdvisoryFail := false - hasMandatoryFail := false - for _, oc := range eval.Outcomes { - if oc.Status == "failed" { - if oc.EnforcementLevel == "advisory" { - hasAdvisoryFail = true - } else { - hasMandatoryFail = true - } - } - } - if hasMandatoryFail { - overallResult = "FAILED" - overallColor = cs.Red() - } else if hasAdvisoryFail { - overallResult = "PASSED (with advisory)" - overallColor = cs.Green() - } - - fmt.Fprintf(&out, "%s %s %s\n", - cs.String(symbolArrow+symbolArrow).Bold(), - cs.String("Overall Result:").Bold(), - cs.String(overallResult).Color(overallColor).Bold()) - if hasMandatoryFail { - fmt.Fprintf(&out, " %s\n", cs.String("This result means that one or more OPA policies failed").Faint()) - } else if hasAdvisoryFail { - fmt.Fprintf(&out, " %s\n", cs.String("This result means that all OPA policies passed and the protected behavior is allowed").Faint()) - } - fmt.Fprintf(&out, "%d policies evaluated\n", len(eval.Outcomes)) - - if eval.PolicySetName != "" { - fmt.Fprintf(&out, "\n%s Policy set %d: %s (%d)\n", - cs.String(symbolArrow).Bold(), - i+1, - cs.String(eval.PolicySetName).Bold(), - len(eval.Outcomes)) - } - - for _, oc := range eval.Outcomes { - icon := policyIcon(cs, oc.Status, oc.EnforcementLevel) - label := policyStatusLabel(oc.Status, oc.EnforcementLevel) - fmt.Fprintf(&out, " %s Policy name: %s\n", cs.String(symbolDownArrow).Bold(), cs.String(oc.PolicyName).Bold()) - fmt.Fprintf(&out, " | %s %s\n", icon, label) - if oc.Description != "" { - fmt.Fprintf(&out, " | %s\n", cs.String(oc.Description).Faint()) - } - for _, line := range oc.Output { - fmt.Fprintf(&out, " | %s\n", line) - } - } - } - - return out.String() -} - -func (d *summaryDisplayer) formatPolicyEvaluationsMarkdown() string { - var out strings.Builder - - out.WriteString("## Policy Evaluations:\n\n") - - for i, eval := range d.summary.PolicyEvaluations { - kind := policyKindLabel(eval.PolicyKind) - fmt.Fprintf(&out, "### %s Policy Evaluation\n\n", kind) - - if eval.Error != "" { - fmt.Fprintf(&out, "**Overall Result: ERRORED**\n\n") - fmt.Fprintf(&out, "%s\n\n", eval.Error) - if eval.PolicySetName != "" { - fmt.Fprintf(&out, "**Policy set:** %s\n\n", eval.PolicySetName) - } - continue - } - - // Compute overall result. - overallResult := "PASSED" - hasAdvisoryFail := false - hasMandatoryFail := false - for _, oc := range eval.Outcomes { - if oc.Status == "failed" { - if oc.EnforcementLevel == "advisory" { - hasAdvisoryFail = true - } else { - hasMandatoryFail = true - } - } - } - if hasMandatoryFail { - overallResult = "FAILED" - } else if hasAdvisoryFail { - overallResult = "PASSED (with advisory)" - } - - fmt.Fprintf(&out, "**Overall Result: %s**\n\n", overallResult) - fmt.Fprintf(&out, "%d policies evaluated\n\n", len(eval.Outcomes)) - - if eval.PolicySetName != "" { - fmt.Fprintf(&out, "**Policy set %d:** %s (%d)\n\n", i+1, eval.PolicySetName, len(eval.Outcomes)) - } - - for _, oc := range eval.Outcomes { - label := policyStatusLabel(oc.Status, oc.EnforcementLevel) - fmt.Fprintf(&out, "- **%s** — %s\n", oc.PolicyName, label) - if oc.Description != "" { - fmt.Fprintf(&out, " %s\n", oc.Description) - } - for _, line := range oc.Output { - fmt.Fprintf(&out, " - %s\n", line) - } - } - out.WriteString("\n") - } - - return out.String() -} - -// --- Task results --- - -func (d *summaryDisplayer) formatTaskResults(f format.Format) string { - switch f { - case format.Markdown: - return d.formatTaskResultsMarkdown() - default: - return d.formatTaskResultsPretty() - } -} - -func (d *summaryDisplayer) formatTaskResultsPretty() string { - cs := d.io.ColorScheme() - var out strings.Builder - - // Count passed and failed. - passed, failed := 0, 0 - var mandatoryFailed []string - for _, tr := range d.summary.TaskResults { - if tr.Status == "passed" { - passed++ - } else { - failed++ - if tr.EnforcementLevel != "advisory" { - mandatoryFailed = append(mandatoryFailed, tr.TaskName) - } - } - } - - // Summary line. - fmt.Fprint(&out, cs.String("All tasks completed:").Color(cs.White()).Bold().String()) - if passed > 0 { - fmt.Fprintf(&out, cs.String(" %d passed").Color(cs.Green()).Bold().String(), passed) - fmt.Fprint(&out, cs.String(",").Color(cs.White()).Bold().String()) - } - if failed > 0 { - fmt.Fprintf(&out, cs.String(" %d failed").Color(cs.Red()).Blink().String(), failed) - } - - out.WriteString("\n") - - // Per-task output. - for _, tr := range d.summary.TaskResults { - out.WriteString("\n") - status := taskStatusLabel(cs, tr.Status, tr.EnforcementLevel) - fmt.Fprintf(&out, " %s %s %s\n", cs.String(tr.TaskName).Bold(), symbolDash, status) - if tr.Message != "" { - fmt.Fprintf(&out, " %s\n", cs.String(tr.Message).Faint()) - } - if tr.URL != "" { - fmt.Fprintf(&out, " %s\n", cs.String("Details: "+tr.URL).Faint()) - } - } - - // Error footer for mandatory failures. - if len(mandatoryFailed) > 0 { - out.WriteString("\n") - if len(mandatoryFailed) == 1 { - fmt.Fprintf(&out, "%s %s\n", - cs.String("Error:").Color(cs.Red()), - cs.String(fmt.Sprintf("the run failed because the run task, %s, is required to succeed", mandatoryFailed[0])).Bold()) - } else { - fmt.Fprintf(&out, "%s %s\n", - cs.String("Error:").Color(cs.Red()), - cs.String(fmt.Sprintf("the run failed because %d mandatory tasks are required to succeed", len(mandatoryFailed))).Bold()) - } - } - - // Overall result. - out.WriteString("\n") - overallLabel := "Passed" - overallColor := cs.Green() - if len(mandatoryFailed) > 0 { - overallLabel = "Failed" - overallColor = cs.Red() - } else if failed > 0 { - overallLabel = "Passed with advisory failures" - } - fmt.Fprintf(&out, "%s %s\n", - cs.String("Overall Result:").Bold(), - cs.String(overallLabel).Color(overallColor).Bold()) - - return out.String() -} - -func (d *summaryDisplayer) formatTaskResultsMarkdown() string { - var out strings.Builder - - // Count passed and failed. - passed, failed := 0, 0 - var mandatoryFailed []string - for _, tr := range d.summary.TaskResults { - if tr.Status == "passed" { - passed++ - } else { - failed++ - if tr.EnforcementLevel != "advisory" { - mandatoryFailed = append(mandatoryFailed, tr.TaskName) - } - } - } - - fmt.Fprintf(&out, "## Run Tasks\n\n") - fmt.Fprintf(&out, "All tasks completed: %d passed, %d failed\n\n", passed, failed) - - for _, tr := range d.summary.TaskResults { - label := "Passed" - if tr.Status == "failed" { - label = fmt.Sprintf("Failed (%s)", tr.EnforcementLevel) - } - fmt.Fprintf(&out, "- **%s** — %s\n", tr.TaskName, label) - if tr.Message != "" { - fmt.Fprintf(&out, " %s\n", tr.Message) - } - if tr.URL != "" { - fmt.Fprintf(&out, " Details: %s\n", tr.URL) - } - } - - if len(mandatoryFailed) > 0 { - out.WriteString("\n") - if len(mandatoryFailed) == 1 { - fmt.Fprintf(&out, "**Error:** the run failed because the run task, %s, is required to succeed\n", mandatoryFailed[0]) - } else { - fmt.Fprintf(&out, "**Error:** the run failed because %d mandatory tasks are required to succeed\n", len(mandatoryFailed)) - } - } - - overallLabel := "Passed" - if len(mandatoryFailed) > 0 { - overallLabel = "Failed" - } else if failed > 0 { - overallLabel = "Passed with advisory failures" - } - fmt.Fprintf(&out, "\n**Overall Result: %s**\n", overallLabel) - - return out.String() -} - -// formatSnippet renders a code snippet with ANSI underline highlighting, -// matching Terraform's diagnostic output style. -func formatSnippet(cs *iostreams.ColorScheme, snippet *client.DiagnosticSnippet) string { - var out strings.Builder - - code := snippet.Code - start := clamp(snippet.HighlightStartOffset, 0, len(code)) - end := clamp(snippet.HighlightEndOffset, start, len(code)) - - // Apply underline to the highlighted range. - var rendered string - if end > start { - before := code[:start] - highlight := code[start:end] - after := code[end:] - rendered = before + cs.String(highlight).Underline().String() + after - } else { - rendered = code - } - - lines := strings.Split(rendered, "\n") - for i, line := range lines { - fmt.Fprintf(&out, " %4d: %s\n", snippet.StartLine+i, line) - } - - return out.String() -} - -func clamp(val, lo, hi int) int { - if val < lo { - return lo - } - if val > hi { - return hi - } - return val -} - -func stripANSI(s string) string { - return ansiEscapeRe.ReplaceAllString(s, "") -} diff --git a/internal/commands/run/summary_displayer.go b/internal/commands/run/summary_displayer.go new file mode 100644 index 0000000..33630db --- /dev/null +++ b/internal/commands/run/summary_displayer.go @@ -0,0 +1,690 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package run + +import ( + "fmt" + "regexp" + "strings" + + "github.com/mitchellh/go-wordwrap" + + "github.com/hashicorp/tfctl-cli/internal/pkg/client" + "github.com/hashicorp/tfctl-cli/internal/pkg/format" + "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" +) + +// summaryDisplayer implements format.Displayer and format.StringPayload. +type summaryDisplayer struct { + summary *client.RunSummary + io iostreams.IOStreams +} + +var ( + _ format.Displayer = (*summaryDisplayer)(nil) + _ format.StringPayload = (*summaryDisplayer)(nil) +) + +func (d *summaryDisplayer) DefaultFormat() format.Format { return format.Pretty } +func (d *summaryDisplayer) Payload() any { return d.summary } +func (d *summaryDisplayer) FieldTemplates() []format.Field { + return nil +} + +// StringPayload returns pre-formatted output tailored to the given format. +func (d *summaryDisplayer) StringPayload(f format.Format) string { + multipleFailures := make([]string, 0, 1) + + if len(d.summary.Diagnostics) > 0 { + multipleFailures = append(multipleFailures, d.formatDiagnostics(f)) + } + if d.summary.PolicyCheckLog != "" { + multipleFailures = append(multipleFailures, d.formatPolicyChecks(f)) + } + if len(d.summary.PolicyEvaluations) > 0 { + multipleFailures = append(multipleFailures, d.formatPolicyEvaluations(f)) + } + if len(d.summary.TaskResults) > 0 { + multipleFailures = append(multipleFailures, d.formatTaskResults(f)) + } + + var body string + if len(multipleFailures) == 0 { + if d.summary.RawLog != "" { + if f == format.Markdown { + body = stripANSI(d.summary.RawLog) + } else { + body = d.summary.RawLog + } + } else { + body = d.summary.Message + } + } else if f == format.Markdown { + body = strings.Join(multipleFailures, "\n\n---\n\n") + } else { + cs := d.io.ColorScheme() + body = strings.Join(multipleFailures, cs.String("\n――――――――――――\n\n").Color(cs.Gray()).Faint().String()) + } + + footer := d.formatFooter(f) + if footer == "" { + return body + } + return body + "\n" + footer +} + +// formatFooter renders the elapsed time, call-to-action, and run URL when present. +func (d *summaryDisplayer) formatFooter(f format.Format) string { + s := d.summary + if s.Elapsed == 0 && s.RunURL == "" { + return "" + } + + cs := d.io.ColorScheme() + + switch f { + case format.Markdown: + var out strings.Builder + if s.Elapsed > 0 { + fmt.Fprintf(&out, "**Duration:** %d\n", s.Elapsed) + } + if s.RunURL != "" { + fmt.Fprintf(&out, "\n[View run](%s)\n", s.RunURL) + } + return strings.TrimRight(out.String(), "\n") + + default: + var parts []string + if s.RunURL != "" { + parts = append(parts, cs.String(fmt.Sprintf("View run: %s", s.RunURL)).Faint().String()) + } + return strings.Join(parts, "\n") + } +} + +var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + +func (d *summaryDisplayer) formatDiagnostics(f format.Format) string { + switch f { + case format.Markdown: + return d.formatDiagnosticsMarkdown() + default: + return d.formatDiagnosticsPretty() + } +} + +func (d *summaryDisplayer) formatPolicyChecks(f format.Format) string { + switch f { + case format.Markdown: + return d.formatPolicyCheckLogMarkdown() + default: + return d.formatPolicyCheckLogPretty() + } +} + +func (d *summaryDisplayer) formatDiagnosticsPretty() string { + cs := d.io.ColorScheme() + const leftRuleWidth = 2 + wrapWidth := d.io.TerminalWidth() - leftRuleWidth + + var out strings.Builder + for i, diag := range d.summary.Diagnostics { + if i > 0 { + out.WriteString("\n") + } + + color := cs.Red() + label := "Error" + if diag.Severity == "warning" { + color = cs.Orange() + label = "Warning" + } + + var body strings.Builder + body.WriteString(cs.String(fmt.Sprintf("%s: ", label)).Color(color).Bold().String()) + body.WriteString(cs.String(diag.Summary).Bold().String()) + body.WriteString("\n") + + if diag.Range != nil { + body.WriteString("\n") + loc := fmt.Sprintf(" on %s line %d", diag.Range.Filename, diag.Range.Start.Line) + if diag.Snippet != nil && diag.Snippet.Context != nil { + loc += fmt.Sprintf(", in %s", *diag.Snippet.Context) + } + loc += ":" + body.WriteString(loc) + body.WriteString("\n") + } + + if diag.Snippet != nil { + body.WriteString(formatSnippet(cs, diag.Snippet)) + } + + if diag.Detail != "" { + body.WriteString("\n") + for _, line := range strings.Split(diag.Detail, "\n") { + if wrapWidth > 0 && line != "" && line[0] != ' ' { + line = wordwrap.WrapString(line, uint(wrapWidth)) + } + body.WriteString(line) + body.WriteString("\n") + } + } + + rule := cs.String("│").Color(color).String() + out.WriteString(cs.String("╷").Color(color).String()) + out.WriteString("\n") + for _, line := range strings.Split(strings.TrimRight(body.String(), "\n"), "\n") { + out.WriteString(rule) + if line != "" { + out.WriteString(" ") + out.WriteString(line) + } + out.WriteString("\n") + } + out.WriteString(cs.String("╵").Color(color).String()) + } + out.WriteString("\n") + return out.String() +} + +func (d *summaryDisplayer) formatDiagnosticsMarkdown() string { + var out strings.Builder + for i, diag := range d.summary.Diagnostics { + if i > 0 { + out.WriteString("\n\n---\n\n") + } + label := "Error" + if diag.Severity == "warning" { + label = "Warning" + } + fmt.Fprintf(&out, "**%s: %s**\n", label, diag.Summary) + if diag.Range != nil { + loc := fmt.Sprintf("on %s line %d", diag.Range.Filename, diag.Range.Start.Line) + if diag.Snippet != nil && diag.Snippet.Context != nil { + loc += fmt.Sprintf(", in %s", *diag.Snippet.Context) + } + fmt.Fprintf(&out, "\n%s:\n", loc) + } + if diag.Snippet != nil { + fmt.Fprintf(&out, "\n```hcl\n%s\n```\n", diag.Snippet.Code) + } + if diag.Detail != "" { + fmt.Fprintf(&out, "\n%s\n", diag.Detail) + } + } + return out.String() +} + +// --- Policy check log (legacy Sentinel) --- + +func policyScopeLabel(scope string) string { + switch scope { + case "organization": + return "Organization Policy Check" + case "workspace": + return "Workspace Policy Check" + default: + if scope != "" { + return fmt.Sprintf("Policy Check (%s)", scope) + } + return "Policy Check" + } +} + +// cleanPolicyCheckLog cleans up the Sentinel runner's policy set header line +// when the policy set name is empty (non-VCS policy sets produce +// "" as the name). +var policySetHeaderRe = regexp.MustCompile(`(?m)^=+ Results for policy set: =+\n`) + +func cleanPolicyCheckLog(log string) string { + return policySetHeaderRe.ReplaceAllString(log, "") +} + +func (d *summaryDisplayer) formatPolicyCheckLogPretty() string { + cs := d.io.ColorScheme() + var out strings.Builder + + header := policyScopeLabel(d.summary.PolicyCheckScope) + out.WriteString(cs.String(header + ":").Bold().String()) + out.WriteString("\n\n") + out.WriteString(cleanPolicyCheckLog(d.summary.PolicyCheckLog)) + + // Add status footer. + switch d.summary.PolicyCheckStatus { + case "hard_failed": + out.WriteString("\n") + out.WriteString(cs.String(header + " hard failed.").Color(cs.Red()).String()) + out.WriteString("\n") + case "soft_failed": + out.WriteString("\n") + out.WriteString(cs.String(header + " soft failed.").Color(cs.Orange()).String()) + out.WriteString("\n") + case "errored": + out.WriteString("\n") + out.WriteString(cs.String(header + " errored.").Color(cs.Red()).String()) + out.WriteString("\n") + } + + return out.String() +} + +func (d *summaryDisplayer) formatPolicyCheckLogMarkdown() string { + var out strings.Builder + + header := policyScopeLabel(d.summary.PolicyCheckScope) + fmt.Fprintf(&out, "## %s\n\n", header) + fmt.Fprintf(&out, "```\n%s\n```\n", stripANSI(cleanPolicyCheckLog(d.summary.PolicyCheckLog))) + + switch d.summary.PolicyCheckStatus { + case "hard_failed": + fmt.Fprintf(&out, "\n**%s hard failed.**\n", header) + case "soft_failed": + fmt.Fprintf(&out, "\n**%s soft failed.**\n", header) + case "errored": + fmt.Fprintf(&out, "\n**%s errored.**\n", header) + } + + return out.String() +} + +func policyKindLabel(kind string) string { + if kind == "sentinel" { + return "Sentinel" + } + return "OPA" +} + +// Unicode symbols matching Terraform CLI output. +const ( + symbolTick = "\u2713" // ✓ + symbolCross = "\u00d7" // × + symbolInfo = "\u24be" // Ⓘ + symbolArrow = "\u2192" // → + symbolDownArrow = "\u21b3" // ↳ + symbolDash = "\u2e3a" // ⸺ +) + +// policyIcon returns a colored icon for a policy outcome, matching TF CLI. +func policyIcon(cs *iostreams.ColorScheme, status, enforcementLevel string) iostreams.String { + switch status { + case "passed": + return cs.String(symbolTick).Color(cs.Green()).Bold() + case "failed": + if enforcementLevel == "advisory" { + return cs.String(symbolInfo).Color(cs.Orange()).Bold() + } + return cs.String(symbolCross).Color(cs.Red()).Bold() + default: + return cs.String("-") + } +} + +// policyStatusLabel returns the display label for a policy status, matching TF CLI. +func policyStatusLabel(status, enforcementLevel string) string { + switch status { + case "passed": + return "Passed" + case "failed": + if enforcementLevel == "advisory" { + return "Advisory" + } + return "Failed" + default: + return status + } +} + +// taskStatusLabel returns a colored status string for a run task, matching TF CLI. +func taskStatusLabel(cs *iostreams.ColorScheme, status, enforcementLevel string) iostreams.String { + switch status { + case "passed": + return cs.String("Passed").Color(cs.Green()) + case "failed": + label := "Failed" + if enforcementLevel != "" { + label += " (" + strings.ToUpper(enforcementLevel[:1]) + enforcementLevel[1:] + ")" + } + return cs.String(label).Color(cs.Red()) + default: + return cs.String(status) + } +} + +// --- Policy evaluations --- + +func (d *summaryDisplayer) formatPolicyEvaluations(f format.Format) string { + switch f { + case format.Markdown: + return d.formatPolicyEvaluationsMarkdown() + default: + return d.formatPolicyEvaluationsPretty() + } +} + +func (d *summaryDisplayer) formatPolicyEvaluationsPretty() string { + cs := d.io.ColorScheme() + var out strings.Builder + + out.WriteString(cs.String("Policy Evaluations:").Bold().String()) + out.WriteString("\n") + + for i, eval := range d.summary.PolicyEvaluations { + out.WriteString("\n") + kind := policyKindLabel(eval.PolicyKind) + out.WriteString(cs.String(kind + " Policy Evaluation").Bold().String()) + out.WriteString("\n") + + if eval.Error != "" { + fmt.Fprintf(&out, "%s %s %s\n", + cs.String(symbolArrow+symbolArrow).Bold(), + cs.String("Overall Result:").Bold(), + cs.String("ERRORED").Color(cs.Red()).Bold()) + fmt.Fprintf(&out, " %s\n", cs.String(eval.Error).Faint()) + if eval.PolicySetName != "" { + fmt.Fprintf(&out, "\n%s Policy set 1: %s\n", + cs.String(symbolArrow).Bold(), + cs.String(eval.PolicySetName).Bold()) + } + continue + } + + // Compute overall result. + overallResult := "PASSED" + overallColor := cs.Green() + hasAdvisoryFail := false + hasMandatoryFail := false + for _, oc := range eval.Outcomes { + if oc.Status == "failed" { + if oc.EnforcementLevel == "advisory" { + hasAdvisoryFail = true + } else { + hasMandatoryFail = true + } + } + } + if hasMandatoryFail { + overallResult = "FAILED" + overallColor = cs.Red() + } else if hasAdvisoryFail { + overallResult = "PASSED (with advisory)" + overallColor = cs.Green() + } + + fmt.Fprintf(&out, "%s %s %s\n", + cs.String(symbolArrow+symbolArrow).Bold(), + cs.String("Overall Result:").Bold(), + cs.String(overallResult).Color(overallColor).Bold()) + if hasMandatoryFail { + fmt.Fprintf(&out, " %s\n", cs.String("This result means that one or more OPA policies failed").Faint()) + } else if hasAdvisoryFail { + fmt.Fprintf(&out, " %s\n", cs.String("This result means that all OPA policies passed and the protected behavior is allowed").Faint()) + } + fmt.Fprintf(&out, "%d policies evaluated\n", len(eval.Outcomes)) + + if eval.PolicySetName != "" { + fmt.Fprintf(&out, "\n%s Policy set %d: %s (%d)\n", + cs.String(symbolArrow).Bold(), + i+1, + cs.String(eval.PolicySetName).Bold(), + len(eval.Outcomes)) + } + + for _, oc := range eval.Outcomes { + icon := policyIcon(cs, oc.Status, oc.EnforcementLevel) + label := policyStatusLabel(oc.Status, oc.EnforcementLevel) + fmt.Fprintf(&out, " %s Policy name: %s\n", cs.String(symbolDownArrow).Bold(), cs.String(oc.PolicyName).Bold()) + fmt.Fprintf(&out, " | %s %s\n", icon, label) + if oc.Description != "" { + fmt.Fprintf(&out, " | %s\n", cs.String(oc.Description).Faint()) + } + for _, line := range oc.Output { + fmt.Fprintf(&out, " | %s\n", line) + } + } + } + + return out.String() +} + +func (d *summaryDisplayer) formatPolicyEvaluationsMarkdown() string { + var out strings.Builder + + out.WriteString("## Policy Evaluations:\n\n") + + for i, eval := range d.summary.PolicyEvaluations { + kind := policyKindLabel(eval.PolicyKind) + fmt.Fprintf(&out, "### %s Policy Evaluation\n\n", kind) + + if eval.Error != "" { + fmt.Fprintf(&out, "**Overall Result: ERRORED**\n\n") + fmt.Fprintf(&out, "%s\n\n", eval.Error) + if eval.PolicySetName != "" { + fmt.Fprintf(&out, "**Policy set:** %s\n\n", eval.PolicySetName) + } + continue + } + + // Compute overall result. + overallResult := "PASSED" + hasAdvisoryFail := false + hasMandatoryFail := false + for _, oc := range eval.Outcomes { + if oc.Status == "failed" { + if oc.EnforcementLevel == "advisory" { + hasAdvisoryFail = true + } else { + hasMandatoryFail = true + } + } + } + if hasMandatoryFail { + overallResult = "FAILED" + } else if hasAdvisoryFail { + overallResult = "PASSED (with advisory)" + } + + fmt.Fprintf(&out, "**Overall Result: %s**\n\n", overallResult) + fmt.Fprintf(&out, "%d policies evaluated\n\n", len(eval.Outcomes)) + + if eval.PolicySetName != "" { + fmt.Fprintf(&out, "**Policy set %d:** %s (%d)\n\n", i+1, eval.PolicySetName, len(eval.Outcomes)) + } + + for _, oc := range eval.Outcomes { + label := policyStatusLabel(oc.Status, oc.EnforcementLevel) + fmt.Fprintf(&out, "- **%s** — %s\n", oc.PolicyName, label) + if oc.Description != "" { + fmt.Fprintf(&out, " %s\n", oc.Description) + } + for _, line := range oc.Output { + fmt.Fprintf(&out, " - %s\n", line) + } + } + out.WriteString("\n") + } + + return out.String() +} + +// --- Task results --- + +func (d *summaryDisplayer) formatTaskResults(f format.Format) string { + switch f { + case format.Markdown: + return d.formatTaskResultsMarkdown() + default: + return d.formatTaskResultsPretty() + } +} + +func (d *summaryDisplayer) formatTaskResultsPretty() string { + cs := d.io.ColorScheme() + var out strings.Builder + + // Count passed and failed. + passed, failed := 0, 0 + var mandatoryFailed []string + for _, tr := range d.summary.TaskResults { + if tr.Status == "passed" { + passed++ + } else { + failed++ + if tr.EnforcementLevel != "advisory" { + mandatoryFailed = append(mandatoryFailed, tr.TaskName) + } + } + } + + // Summary line. + fmt.Fprint(&out, cs.String("All tasks completed:").Color(cs.White()).Bold().String()) + if passed > 0 { + fmt.Fprintf(&out, cs.String(" %d passed").Color(cs.Green()).Bold().String(), passed) + fmt.Fprint(&out, cs.String(",").Color(cs.White()).Bold().String()) + } + if failed > 0 { + fmt.Fprintf(&out, cs.String(" %d failed").Color(cs.Red()).Blink().String(), failed) + } + + out.WriteString("\n") + + // Per-task output. + for _, tr := range d.summary.TaskResults { + out.WriteString("\n") + status := taskStatusLabel(cs, tr.Status, tr.EnforcementLevel) + fmt.Fprintf(&out, " %s %s %s\n", cs.String(tr.TaskName).Bold(), symbolDash, status) + if tr.Message != "" { + fmt.Fprintf(&out, " %s\n", cs.String(tr.Message).Faint()) + } + if tr.URL != "" { + fmt.Fprintf(&out, " %s\n", cs.String("Details: "+tr.URL).Faint()) + } + } + + // Error footer for mandatory failures. + if len(mandatoryFailed) > 0 { + out.WriteString("\n") + if len(mandatoryFailed) == 1 { + fmt.Fprintf(&out, "%s %s\n", + cs.String("Error:").Color(cs.Red()), + cs.String(fmt.Sprintf("the run failed because the run task, %s, is required to succeed", mandatoryFailed[0])).Bold()) + } else { + fmt.Fprintf(&out, "%s %s\n", + cs.String("Error:").Color(cs.Red()), + cs.String(fmt.Sprintf("the run failed because %d mandatory tasks are required to succeed", len(mandatoryFailed))).Bold()) + } + } + + // Overall result. + out.WriteString("\n") + overallLabel := "Passed" + overallColor := cs.Green() + if len(mandatoryFailed) > 0 { + overallLabel = "Failed" + overallColor = cs.Red() + } else if failed > 0 { + overallLabel = "Passed with advisory failures" + } + fmt.Fprintf(&out, "%s %s\n", + cs.String("Overall Result:").Bold(), + cs.String(overallLabel).Color(overallColor).Bold()) + + return out.String() +} + +func (d *summaryDisplayer) formatTaskResultsMarkdown() string { + var out strings.Builder + + // Count passed and failed. + passed, failed := 0, 0 + var mandatoryFailed []string + for _, tr := range d.summary.TaskResults { + if tr.Status == "passed" { + passed++ + } else { + failed++ + if tr.EnforcementLevel != "advisory" { + mandatoryFailed = append(mandatoryFailed, tr.TaskName) + } + } + } + + fmt.Fprintf(&out, "## Run Tasks\n\n") + fmt.Fprintf(&out, "All tasks completed: %d passed, %d failed\n\n", passed, failed) + + for _, tr := range d.summary.TaskResults { + label := "Passed" + if tr.Status == "failed" { + label = fmt.Sprintf("Failed (%s)", tr.EnforcementLevel) + } + fmt.Fprintf(&out, "- **%s** — %s\n", tr.TaskName, label) + if tr.Message != "" { + fmt.Fprintf(&out, " %s\n", tr.Message) + } + if tr.URL != "" { + fmt.Fprintf(&out, " Details: %s\n", tr.URL) + } + } + + if len(mandatoryFailed) > 0 { + out.WriteString("\n") + if len(mandatoryFailed) == 1 { + fmt.Fprintf(&out, "**Error:** the run failed because the run task, %s, is required to succeed\n", mandatoryFailed[0]) + } else { + fmt.Fprintf(&out, "**Error:** the run failed because %d mandatory tasks are required to succeed\n", len(mandatoryFailed)) + } + } + + overallLabel := "Passed" + if len(mandatoryFailed) > 0 { + overallLabel = "Failed" + } else if failed > 0 { + overallLabel = "Passed with advisory failures" + } + fmt.Fprintf(&out, "\n**Overall Result: %s**\n", overallLabel) + + return out.String() +} + +// formatSnippet renders a code snippet with ANSI underline highlighting, +// matching Terraform's diagnostic output style. +func formatSnippet(cs *iostreams.ColorScheme, snippet *client.DiagnosticSnippet) string { + var out strings.Builder + + code := snippet.Code + start := clamp(snippet.HighlightStartOffset, 0, len(code)) + end := clamp(snippet.HighlightEndOffset, start, len(code)) + + // Apply underline to the highlighted range. + var rendered string + if end > start { + before := code[:start] + highlight := code[start:end] + after := code[end:] + rendered = before + cs.String(highlight).Underline().String() + after + } else { + rendered = code + } + + lines := strings.Split(rendered, "\n") + for i, line := range lines { + fmt.Fprintf(&out, " %4d: %s\n", snippet.StartLine+i, line) + } + + return out.String() +} + +func clamp(val, lo, hi int) int { + if val < lo { + return lo + } + if val > hi { + return hi + } + return val +} + +func stripANSI(s string) string { + return ansiEscapeRe.ReplaceAllString(s, "") +} diff --git a/internal/pkg/client/run_summary.go b/internal/pkg/client/run_summary.go index a22989b..d489f63 100644 --- a/internal/pkg/client/run_summary.go +++ b/internal/pkg/client/run_summary.go @@ -11,6 +11,7 @@ import ( "net/http" "sort" "strings" + "time" "github.com/hashicorp/go-tfe/v2/api/models" "github.com/microsoft/kiota-abstractions-go/serialization" @@ -29,6 +30,11 @@ type RunSummary struct { PolicyCheckStatus string `json:"policy_check_status,omitempty"` // "hard_failed", "soft_failed", "errored" PolicyEvaluations []PolicyEvalResult `json:"policy_evaluations,omitempty"` TaskResults []TaskResult `json:"task_results,omitempty"` + // RunURL is the HCP Terraform UI URL for the run. Set by the caller when known. + RunURL string `json:"run_url,omitempty"` + // Elapsed is the time from run creation to terminal status, derived from + // the run's status timestamps. Zero if timestamps are unavailable. + Elapsed uint64 `json:"elapsed_seconds,omitempty"` } // PolicyEvalResult holds the outcome of a policy evaluation (OPA/Sentinel via task stages). @@ -109,12 +115,58 @@ func NewRunSummary(ctx context.Context, c *Client, runID string) (*RunSummary, e return nil, fmt.Errorf("fetching run %s: %w", runID, err) } - status := run.GetData().GetAttributes().GetStatus() + attrs := run.GetData().GetAttributes() + status := attrs.GetStatus() if status == nil { return nil, fmt.Errorf("run %s has no status", runID) } - return buildRunSummary(ctx, c, runID, *status) + result, err := buildRunSummary(ctx, c, runID, *status) + if err != nil { + return nil, err + } + + result.Elapsed = uint64(elapsedFromTimestamps(attrs.GetCreatedAt(), attrs.GetStatusTimestamps(), *status).Seconds()) + return result, nil +} + +// elapsedFromTimestamps derives the run duration from the terminal status timestamp +// minus the run's created-at time. Returns zero if either value is unavailable. +func elapsedFromTimestamps(createdAt *time.Time, ts models.Runs_attributes_statusTimestampsable, status models.Runs_attributes_status) time.Duration { + if createdAt == nil || ts == nil { + return 0 + } + + var endTime *time.Time + switch status { + case models.APPLIED_RUNS_ATTRIBUTES_STATUS: + endTime = ts.GetAppliedAt() + case models.PLANNED_AND_FINISHED_RUNS_ATTRIBUTES_STATUS: + endTime = ts.GetPlannedAndFinishedAt() + case models.PLANNED_AND_SAVED_RUNS_ATTRIBUTES_STATUS: + endTime = ts.GetPlannedAndSavedAt() + case models.PLANNED_RUNS_ATTRIBUTES_STATUS: + endTime = ts.GetPlannedAt() + case models.ERRORED_RUNS_ATTRIBUTES_STATUS: + endTime = ts.GetErroredAt() + case models.CANCELED_RUNS_ATTRIBUTES_STATUS: + endTime = ts.GetCanceledAt() + case models.DISCARDED_RUNS_ATTRIBUTES_STATUS: + endTime = ts.GetDiscardedAt() + case models.POLICY_SOFT_FAILED_RUNS_ATTRIBUTES_STATUS: + endTime = ts.GetPolicySoftFailedAt() + case models.POLICY_OVERRIDE_RUNS_ATTRIBUTES_STATUS: + endTime = ts.GetPolicyCheckedAt() + } + + if endTime == nil || endTime.IsZero() { + return 0 + } + d := endTime.Sub(*createdAt) + if d < 0 { + return 0 + } + return d.Round(time.Second) } func buildRunSummary(ctx context.Context, c *Client, runID string, status models.Runs_attributes_status) (*RunSummary, error) { diff --git a/internal/pkg/client/run_wait.go b/internal/pkg/client/run_wait.go index 7c0b843..548aea5 100644 --- a/internal/pkg/client/run_wait.go +++ b/internal/pkg/client/run_wait.go @@ -11,19 +11,19 @@ import ( "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" ) -// runOutcome classifies a run's status for the purpose of `run start --wait`. -type runOutcome int +// 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 + // 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 + RunAwaitingConfirm + // RunFailed means the run reached a failed or aborted terminal state. + RunFailed ) // defaultPollInterval is how often run is polled when no interval is set. @@ -35,23 +35,23 @@ const defaultPollInterval = 3 * time.Second // 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 { +func classifyRunStatus(status string, confirmable bool) RunOutcome { switch status { case "applied", "planned_and_finished", "planned_and_saved": - return runSucceeded + return RunSucceeded case "errored", "canceled", "discarded", "policy_soft_failed", "policy_override": - return runFailed + return RunFailed } if confirmable { - return runAwaitingConfirm + return RunAwaitingConfirm } - return runInProgress + return RunInProgress } // PollRunUntilTerminated polls the run indefinitely until it reaches a settled state // (finished, failed, or awaiting manual confirmation), notifying on each status transition. // It returns the final status string and its classified outcome. -func PollRunUntilTerminated(ctx context.Context, c *Client, runID string, io iostreams.IOStreams, interval time.Duration, statusUpdate func(string)) (string, runOutcome, error) { +func PollRunUntilTerminated(ctx context.Context, c *Client, runID string, _ iostreams.IOStreams, interval time.Duration, statusUpdate func(string)) (string, RunOutcome, error) { if interval <= 0 { interval = defaultPollInterval } @@ -60,11 +60,11 @@ func PollRunUntilTerminated(ctx context.Context, c *Client, runID string, io ios 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) + 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) + return "", RunInProgress, fmt.Errorf("run %s has no status", runID) } status := attrs.GetStatus().String() @@ -80,13 +80,13 @@ func PollRunUntilTerminated(ctx context.Context, c *Client, runID string, io ios last = status } - if outcome := classifyRunStatus(status, confirmable); outcome != runInProgress { + if outcome := classifyRunStatus(status, confirmable); outcome != RunInProgress { return status, outcome, nil } select { case <-ctx.Done(): - return status, runInProgress, ctx.Err() + return status, RunInProgress, ctx.Err() case <-time.After(interval): } } diff --git a/internal/pkg/client/run_wait_test.go b/internal/pkg/client/run_wait_test.go index 9b13a10..dc45e93 100644 --- a/internal/pkg/client/run_wait_test.go +++ b/internal/pkg/client/run_wait_test.go @@ -41,23 +41,23 @@ func TestClassifyRunStatus(t *testing.T) { cases := []struct { status string confirmable bool - want runOutcome + 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}, + {"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}, + {"planned", true, RunAwaitingConfirm}, // Confirmable must not override a terminal failure state. - {"errored", true, runFailed}, + {"errored", true, RunFailed}, } for _, tc := range cases { assert.Equalf(t, tc.want, classifyRunStatus(tc.status, tc.confirmable), @@ -92,7 +92,7 @@ func TestPollRunUntilTerminated_Errored(t *testing.T) { status, outcome, err := PollRunUntilTerminated(context.Background(), c, "run-x", io, time.Millisecond, noopStatus) require.NoError(t, err) assert.Equal(t, "errored", status) - assert.Equal(t, runFailed, outcome) + assert.Equal(t, RunFailed, outcome) } func TestPollRunUntilTerminated_Status(t *testing.T) { @@ -114,7 +114,7 @@ func TestPollRunUntilTerminated_Status(t *testing.T) { }) require.NoError(t, err) assert.Equal(t, "errored", status) - assert.Equal(t, runFailed, outcome) + assert.Equal(t, RunFailed, outcome) assert.True(t, sawPlanning, "expected to see planning status") assert.True(t, sawErrored, "expected to see errored status") } @@ -138,7 +138,7 @@ func TestPollRunUntilTerminated_AwaitingConfirm(t *testing.T) { status, outcome, err := PollRunUntilTerminated(context.Background(), c, "run-x", io, time.Millisecond, noopStatus) require.NoError(t, err) assert.Equal(t, "planned", status) - assert.Equal(t, runAwaitingConfirm, outcome) + assert.Equal(t, RunAwaitingConfirm, outcome) } func TestPollRunUntilTerminated_Timeout(t *testing.T) { @@ -155,5 +155,5 @@ func TestPollRunUntilTerminated_Timeout(t *testing.T) { _, outcome, err := PollRunUntilTerminated(ctx, c, "run-x", io, time.Millisecond, noopStatus) require.Error(t, err) assert.Equal(t, "timed out!", context.Cause(ctx).Error()) - assert.Equal(t, runInProgress, outcome) + assert.Equal(t, RunInProgress, outcome) } From 91ff85445e480a87a82154eb824bbed12fb00566 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Tue, 21 Jul 2026 16:14:24 -0600 Subject: [PATCH 05/10] append confirmation message to summary --- internal/commands/run/run_start.go | 2 -- internal/pkg/client/run_summary.go | 27 +++++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/internal/commands/run/run_start.go b/internal/commands/run/run_start.go index 2e7ca64..fe8f756 100644 --- a/internal/commands/run/run_start.go +++ b/internal/commands/run/run_start.go @@ -237,7 +237,6 @@ func waitForRunAndReport(ctx context.Context, opts StartOpts, runID, runURL stri 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) if opts.Timeout > 0 { @@ -261,7 +260,6 @@ func waitForRunAndReport(ctx context.Context, opts StartOpts, runID, runURL stri if err != nil { return err } - summary.Message = "Plan finished; a manual apply is required (auto-apply is off)." summary.RunURL = runURL if outcome == client.RunAwaitingConfirm { diff --git a/internal/pkg/client/run_summary.go b/internal/pkg/client/run_summary.go index d489f63..60a6a05 100644 --- a/internal/pkg/client/run_summary.go +++ b/internal/pkg/client/run_summary.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "slices" "sort" "strings" "time" @@ -105,6 +106,15 @@ type jsonLog struct { Diagnostic *Diagnostic `json:"diagnostic,omitempty"` } +var statusesThatMayRequireConfirmation = []models.Runs_attributes_status{ + models.PLANNED_RUNS_ATTRIBUTES_STATUS, + models.COST_ESTIMATED_RUNS_ATTRIBUTES_STATUS, + models.POLICY_CHECKED_RUNS_ATTRIBUTES_STATUS, + models.POLICY_OVERRIDE_RUNS_ATTRIBUTES_STATUS, + models.POST_PLAN_COMPLETED_RUNS_ATTRIBUTES_STATUS, + models.PRE_APPLY_COMPLETED_RUNS_ATTRIBUTES_STATUS, +} + // NewRunSummary fetches a run and returns a summary of its status. If the run // has errored, it fetches the relevant log and extracts diagnostics. Additionally, // it probes policy checks and run task stages for failures. All failures are surfaced @@ -121,7 +131,14 @@ func NewRunSummary(ctx context.Context, c *Client, runID string) (*RunSummary, e return nil, fmt.Errorf("run %s has no status", runID) } - result, err := buildRunSummary(ctx, c, runID, *status) + confirmable := false + if actions := run.GetData().GetAttributes().GetActions(); actions != nil { + if confirmablePtr := actions.GetIsConfirmable(); confirmablePtr != nil { + confirmable = *confirmablePtr + } + } + + result, err := buildRunSummary(ctx, c, runID, *status, confirmable) if err != nil { return nil, err } @@ -169,7 +186,7 @@ func elapsedFromTimestamps(createdAt *time.Time, ts models.Runs_attributes_statu return d.Round(time.Second) } -func buildRunSummary(ctx context.Context, c *Client, runID string, status models.Runs_attributes_status) (*RunSummary, error) { +func buildRunSummary(ctx context.Context, c *Client, runID string, status models.Runs_attributes_status, confirmable bool) (*RunSummary, error) { result := &RunSummary{ RunID: runID, Status: status.String(), @@ -183,6 +200,8 @@ func buildRunSummary(ctx context.Context, c *Client, runID string, status models models.PLANNING_RUNS_ATTRIBUTES_STATUS, models.PRE_PLAN_RUNNING_RUNS_ATTRIBUTES_STATUS: result.Message = "Plan in progress" + case models.PLANNED_RUNS_ATTRIBUTES_STATUS: + result.Message = "Plan finished" case models.PLANNED_AND_FINISHED_RUNS_ATTRIBUTES_STATUS, models.PLANNED_AND_SAVED_RUNS_ATTRIBUTES_STATUS: @@ -217,6 +236,10 @@ func buildRunSummary(ctx context.Context, c *Client, runID string, status models result.Message = fmt.Sprintf("Run status: %s", status.String()) } + if confirmable && slices.Contains(statusesThatMayRequireConfirmation, status) { + result.Message += "; a manual confirmation is required (auto-apply is off). Confirm the apply by visiting the run URL." + } + return result, nil } From 7001bdcb181edbdb1f2bb028268e2aeef0734967 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Mon, 17 Aug 2026 11:47:17 -0600 Subject: [PATCH 06/10] --wait: don't print status when --quiet --- internal/commands/run/run_start.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/commands/run/run_start.go b/internal/commands/run/run_start.go index fe8f756..75eb33d 100644 --- a/internal/commands/run/run_start.go +++ b/internal/commands/run/run_start.go @@ -246,7 +246,7 @@ func waitForRunAndReport(ctx context.Context, opts StartOpts, runID, runURL stri } _, outcome, err := client.PollRunUntilTerminated(ctx, opts.APIClient, runID, io, opts.PollInterval, func(status string) { - fmt.Fprintln(io.Err(), cs.String(" ⋯ "+status).Faint().String()) + fmt.Fprintln(io.ErrUnessential(), cs.String(" ⋯ "+status).Faint().String()) }) if err != nil { // The wait was interrupted (timeout or cancel), but the run itself keeps From 28dae4b45c0284a54829e5f514e2f2a1f5ebbc31 Mon Sep 17 00:00:00 2001 From: Shweta <35878561+shwetamurali@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:09:27 -0400 Subject: [PATCH 07/10] run: surface the run URL and unify apply-confirmation wording --- internal/commands/run/run_start.go | 14 ++-- internal/commands/run/run_status_test.go | 85 ++++++++++++++++++++++++ internal/pkg/client/run_summary.go | 48 ++++++++++++- 3 files changed, 136 insertions(+), 11 deletions(-) diff --git a/internal/commands/run/run_start.go b/internal/commands/run/run_start.go index 75eb33d..4bae18b 100644 --- a/internal/commands/run/run_start.go +++ b/internal/commands/run/run_start.go @@ -212,8 +212,7 @@ func runStart(ctx context.Context, opts StartOpts, runOpts CreateOpts) error { newRunID := *response.GetData().GetId() - runURL := fmt.Sprintf("https://%s/app/%s/workspaces/%s/runs/%s", - opts.Profile.GetHostname(), *organizationName, *ws.GetAttributes().GetName(), newRunID) + runURL := opts.APIClient.RunAppURL(*organizationName, *ws.GetAttributes().GetName(), newRunID) if !opts.Wait { fmt.Fprintln(io.ErrUnessential(), heredoc.New(io).Mustf(` @@ -237,7 +236,7 @@ func waitForRunAndReport(ctx context.Context, opts StartOpts, runID, runURL stri io := opts.IO cs := io.ColorScheme() - fmt.Fprintf(io.Err(), "%s %s created; waiting for it to finish...\n", cs.SuccessIcon(), runID) + fmt.Fprintf(io.ErrUnessential(), "%s %s created; waiting for it to finish...\n", cs.SuccessIcon(), runID) if opts.Timeout > 0 { var cancel context.CancelFunc @@ -251,20 +250,17 @@ func waitForRunAndReport(ctx context.Context, opts StartOpts, runID, runURL stri 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", + fmt.Fprintf(io.ErrUnessential(), "%s Stopped waiting; the run may still be running in HCP Terraform:\n %s\n", cs.FailureIcon(), runURL) return err } + // The run URL and the confirmation wording are both owned by NewRunSummary so + // this path stays identical to `run status`. summary, err := client.NewRunSummary(ctx, opts.APIClient, runID) if err != nil { return err } - summary.RunURL = runURL - - if outcome == client.RunAwaitingConfirm { - summary.Message = "Plan finished; a manual apply is required (auto-apply is off). Confirm the apply by visiting the run URL." - } if err := opts.Output.Display(&summaryDisplayer{summary: summary, io: io}); err != nil { return err diff --git a/internal/commands/run/run_status_test.go b/internal/commands/run/run_status_test.go index 2f61a74..e386e0e 100644 --- a/internal/commands/run/run_status_test.go +++ b/internal/commands/run/run_status_test.go @@ -337,6 +337,91 @@ func TestRunStatus_ExitCode(t *testing.T) { } } +// TestRunStatus_ConfirmableRunURL verifies that `run status` surfaces the same +// "manual apply is required" wording and run URL that `run start --wait` shows, +// so the two commands stay consistent for a run awaiting a manual apply. +func TestRunStatus_ConfirmableRunURL(t *testing.T) { + t.Parallel() + + c := testAPI(t, routeMap{ + "GET /api/v2/runs/run-1": func(w http.ResponseWriter, _ *http.Request) { + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "run-1", "type": "runs", + "attributes": map[string]any{ + "status": "planned", + "actions": map[string]any{"is-confirmable": true}, + }, + "relationships": map[string]any{ + "workspace": map[string]any{ + "data": map[string]any{"id": "ws-1", "type": "workspaces"}, + }, + }, + }, + }) + }, + "GET /api/v2/workspaces/ws-1": func(w http.ResponseWriter, _ *http.Request) { + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "ws-1", "type": "workspaces", + "attributes": map[string]any{"name": "my-ws"}, + "relationships": map[string]any{ + "organization": map[string]any{ + "data": map[string]any{"id": "my-org", "type": "organizations"}, + }, + }, + }, + }) + }, + }) + + // The summary layer owns both the confirmation wording and the URL. + summary, err := client.NewRunSummary(context.Background(), c, "run-1") + require.NoError(t, err) + assert.Contains(t, summary.Message, "a manual apply is required") + assert.Contains(t, summary.RunURL, "workspaces/my-ws/runs/run-1") + + io := iostreams.Test() + opts := &StatusOpts{IO: io, Output: format.New(io), Client: c, ID: "run-1"} + require.NoError(t, runStatus(context.Background(), opts)) + + out := io.Output.String() + assert.Contains(t, out, "a manual apply is required") + assert.Contains(t, out, "Confirm the apply by") + assert.Contains(t, out, "View run:") + assert.Contains(t, out, "workspaces/my-ws/runs/run-1") +} + +// TestStringPayload_Footer verifies the run URL is surfaced in pretty output but +// the markdown "View run" link is intentionally omitted, while duration is kept. +func TestStringPayload_Footer(t *testing.T) { + t.Parallel() + io := iostreams.Test() + + d := &summaryDisplayer{summary: &client.RunSummary{ + Status: "applied", + Message: "Run succeeded", + RunURL: "https://app.terraform.io/app/my-org/workspaces/my-ws/runs/run-1", + Elapsed: 42, + }, io: io} + + pretty := d.StringPayload(format.Pretty) + assert.Contains(t, pretty, "View run:") + assert.Contains(t, pretty, "runs/run-1") + + md := d.StringPayload(format.Markdown) + assert.NotContains(t, md, "View run") + assert.NotContains(t, md, "runs/run-1") + assert.Contains(t, md, "**Duration:** 42") + + // JSON output marshals Payload() directly, so both fields must serialize + // under their documented keys. + raw, err := json.Marshal(d.Payload()) + require.NoError(t, err) + assert.Contains(t, string(raw), `"run_url":"https://app.terraform.io/app/my-org/workspaces/my-ws/runs/run-1"`) + assert.Contains(t, string(raw), `"elapsed_seconds":42`) +} + func TestStringPayload_MultipleFailuresDivider(t *testing.T) { t.Parallel() diff --git a/internal/pkg/client/run_summary.go b/internal/pkg/client/run_summary.go index 60a6a05..e174323 100644 --- a/internal/pkg/client/run_summary.go +++ b/internal/pkg/client/run_summary.go @@ -31,7 +31,8 @@ type RunSummary struct { PolicyCheckStatus string `json:"policy_check_status,omitempty"` // "hard_failed", "soft_failed", "errored" PolicyEvaluations []PolicyEvalResult `json:"policy_evaluations,omitempty"` TaskResults []TaskResult `json:"task_results,omitempty"` - // RunURL is the HCP Terraform UI URL for the run. Set by the caller when known. + // RunURL is the HCP Terraform UI URL for the run. Resolved by NewRunSummary + // from the run's workspace relationship; empty if it cannot be determined. RunURL string `json:"run_url,omitempty"` // Elapsed is the time from run creation to terminal status, derived from // the run's status timestamps. Zero if timestamps are unavailable. @@ -144,9 +145,52 @@ func NewRunSummary(ctx context.Context, c *Client, runID string) (*RunSummary, e } result.Elapsed = uint64(elapsedFromTimestamps(attrs.GetCreatedAt(), attrs.GetStatusTimestamps(), *status).Seconds()) + result.RunURL = c.resolveRunURL(ctx, run.GetData().GetRelationships(), runID) return result, nil } +// RunAppURL returns the HCP Terraform UI URL for a run. It is the single source +// of the URL format shared by `run status` and `run start --wait`. +func (c *Client) RunAppURL(org, workspaceName, runID string) string { + return fmt.Sprintf("https://%s/app/%s/workspaces/%s/runs/%s", c.BaseURL.Host, org, workspaceName, runID) +} + +// resolveRunURL best-effort builds the run's UI URL by resolving its workspace +// name and organization. It returns "" if any required piece is unavailable: the +// URL is supplementary and must never fail summary construction. +func (c *Client) resolveRunURL(ctx context.Context, rel models.Runs_relationshipsable, runID string) string { + if rel == nil { + return "" + } + wsRel := rel.GetWorkspace() + if wsRel == nil || wsRel.GetData() == nil || wsRel.GetData().GetId() == nil { + return "" + } + wsID := *wsRel.GetData().GetId() + + resp, err := c.TFE.API.Workspaces().ByWorkspace_id(wsID).Get(ctx, nil) + if err != nil { + return "" + } + ws, ok := resp.GetData().(*models.Workspaces) + if !ok || ws == nil { + return "" + } + attrs := ws.GetAttributes() + if attrs == nil || attrs.GetName() == nil { + return "" + } + rels := ws.GetRelationships() + if rels == nil { + return "" + } + orgRel := rels.GetOrganization() + if orgRel == nil || orgRel.GetData() == nil || orgRel.GetData().GetId() == nil { + return "" + } + return c.RunAppURL(*orgRel.GetData().GetId(), *attrs.GetName(), runID) +} + // elapsedFromTimestamps derives the run duration from the terminal status timestamp // minus the run's created-at time. Returns zero if either value is unavailable. func elapsedFromTimestamps(createdAt *time.Time, ts models.Runs_attributes_statusTimestampsable, status models.Runs_attributes_status) time.Duration { @@ -237,7 +281,7 @@ func buildRunSummary(ctx context.Context, c *Client, runID string, status models } if confirmable && slices.Contains(statusesThatMayRequireConfirmation, status) { - result.Message += "; a manual confirmation is required (auto-apply is off). Confirm the apply by visiting the run URL." + result.Message += "; a manual apply is required (auto-apply is off). Confirm the apply by visiting the run URL." } return result, nil From d2a408c652b195310b099e131b76021b63577013 Mon Sep 17 00:00:00 2001 From: Shweta <35878561+shwetamurali@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:09:45 -0400 Subject: [PATCH 08/10] make quiet quiet --- internal/commands/run/run_start_test.go | 81 +++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/internal/commands/run/run_start_test.go b/internal/commands/run/run_start_test.go index 2d74781..932348b 100644 --- a/internal/commands/run/run_start_test.go +++ b/internal/commands/run/run_start_test.go @@ -388,6 +388,11 @@ func TestRunStart_Wait_Success(t *testing.T) { "data": map[string]any{ "id": "run-waited", "type": "runs", "attributes": map[string]any{"status": status}, + "relationships": map[string]any{ + "workspace": map[string]any{ + "data": map[string]any{"id": "ws-abc123", "type": "workspaces"}, + }, + }, }, }) default: @@ -415,6 +420,72 @@ func TestRunStart_Wait_Success(t *testing.T) { assert.Contains(t, io.Output.String(), "workspaces/foobar/runs/run-waited") } +func TestRunStart_Wait_Quiet(t *testing.T) { + t.Parallel() + io := iostreams.Test() + io.SetQuiet(true) + + 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-quiet", "type": "runs", + "attributes": map[string]any{"status": "pending"}, + }, + }) + case "GET /api/v2/runs/run-quiet": + status := "planning" + if atomic.AddInt32(&runGets, 1) > 1 { + status = "planned_and_finished" + } + jsonapi(w, map[string]any{ + "data": map[string]any{ + "id": "run-quiet", "type": "runs", + "attributes": map[string]any{"status": status}, + "relationships": map[string]any{ + "workspace": map[string]any{ + "data": map[string]any{"id": "ws-abc123", "type": "workspaces"}, + }, + }, + }, + }) + 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) + // --quiet suppresses all wait progress on stderr... + assert.Empty(t, io.Error.String()) + assert.NotContains(t, io.Error.String(), "waiting for it to finish") + // ...but the final summary result still prints to stdout. + assert.Contains(t, io.Output.String(), "Plan complete, no apply needed") +} + func TestRunStart_Wait_Timeout(t *testing.T) { t.Parallel() io := iostreams.Test() @@ -507,6 +578,11 @@ func TestRunStart_Wait_AwaitingConfirm(t *testing.T) { "status": "planned", "actions": map[string]any{"is-confirmable": true}, }, + "relationships": map[string]any{ + "workspace": map[string]any{ + "data": map[string]any{"id": "ws-abc123", "type": "workspaces"}, + }, + }, }, }) default: @@ -565,6 +641,11 @@ func TestRunStart_Wait_Failure(t *testing.T) { "data": map[string]any{ "id": "run-cancel", "type": "runs", "attributes": map[string]any{"status": "canceled"}, + "relationships": map[string]any{ + "workspace": map[string]any{ + "data": map[string]any{"id": "ws-abc123", "type": "workspaces"}, + }, + }, }, }) default: From b37307c80fda6764718d67e9357e6562bb90d8b5 Mon Sep 17 00:00:00 2001 From: Shweta <35878561+shwetamurali@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:10:13 -0400 Subject: [PATCH 09/10] remove the markdown "View run" link --- internal/commands/run/summary_displayer.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/commands/run/summary_displayer.go b/internal/commands/run/summary_displayer.go index 33630db..27cb594 100644 --- a/internal/commands/run/summary_displayer.go +++ b/internal/commands/run/summary_displayer.go @@ -89,9 +89,6 @@ func (d *summaryDisplayer) formatFooter(f format.Format) string { if s.Elapsed > 0 { fmt.Fprintf(&out, "**Duration:** %d\n", s.Elapsed) } - if s.RunURL != "" { - fmt.Fprintf(&out, "\n[View run](%s)\n", s.RunURL) - } return strings.TrimRight(out.String(), "\n") default: From 7bf8bb37c15ce5504ae45158faa3c30d39ae7c5f Mon Sep 17 00:00:00 2001 From: Shweta <35878561+shwetamurali@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:10:25 -0400 Subject: [PATCH 10/10] update changelog --- .changes/unreleased/ENHANCEMENTS-20260717-023923.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/unreleased/ENHANCEMENTS-20260717-023923.yaml b/.changes/unreleased/ENHANCEMENTS-20260717-023923.yaml index 8e25aa8..f7f9cd6 100644 --- a/.changes/unreleased/ENHANCEMENTS-20260717-023923.yaml +++ b/.changes/unreleased/ENHANCEMENTS-20260717-023923.yaml @@ -1,3 +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.' +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 run URL is printed. `run status` now surfaces the same run URL as well.' time: 2026-07-17T02:39:23-04:00