1package cli
2
3// Operation watch: a 202 mutation (or an explicit resume) is observed
4// through the existing by-key journal endpoint until it reaches a terminal
5// state. Polling is intentionally local: the CLI never reissues the
6// mutation and never asks the daemon to cancel the operation, so Ctrl-C
7// only stops this process from watching.
8
9import (
10 "context"
11 "fmt"
12 "io"
13 "strings"
14 "time"
15
16 "github.com/bnema/gordon/internal/adapters/dto"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20// operationPollInterval is the pause between by-key polls of a running
21// operation. It is a package variable (no flag) so command tests can
22// shorten it; production uses a modest one-second cadence.
23var operationPollInterval = time.Second
24
25// operationPollMaxTransientErrors bounds consecutive transient polling
26// failures so a broken connection fails loudly instead of spinning or
27// hiding the error.
28const operationPollMaxTransientErrors = 5
29
30// OperationFailedError reports a terminal non-success watch outcome. The
31// terminal journal is rendered before this error is returned so the
32// command exits nonzero without hiding the terminal detail.
33type OperationFailedError struct {
34 App string
35 Op string
36 Outcome string
37}
38
39func (e *OperationFailedError) Error() string {
40 return fmt.Sprintf("operation %s for app %s did not succeed (outcome %s)", e.Op, e.App, e.Outcome)
41}
42
43// operationResumeCommand is the concise command that resumes polling an
44// existing operation without reissuing the mutation.
45func operationResumeCommand(app, key string) string {
46 cmd := fmt.Sprintf("gordon apps operations watch %s --key %s", app, key)
47 if remoteFlag != "" {
48 cmd += " --remote " + remoteFlag
49 }
50 return cmd
51}
52
53// watchOperation polls the by-key journal until the operation is terminal,
54// reports progress while it runs, then renders the terminal journal. In
55// JSON mode stdout carries exactly one final document and progress goes to
56// errOut. A terminal non-success outcome renders the journal and returns
57// OperationFailedError.
58func watchOperation(ctx context.Context, plane ControlPlane, app, key string, initial *dto.AppDeployResponse, out, errOut io.Writer, jsonOut bool) error {
59 if app == "" || key == "" {
60 return fmt.Errorf("APP and --key are required")
61 }
62 progressOut := out
63 if jsonOut {
64 progressOut = errOut
65 }
66 op, err := awaitOperation(ctx, plane, app, key, initial, progressOut)
67 if err != nil {
68 return err
69 }
70 if jsonOut {
71 if err := writeJSON(out, op); err != nil {
72 return err
73 }
74 } else if err := renderAppDeployResponse(out, op); err != nil {
75 return err
76 }
77 if op.Outcome == domain.AppOutcomeSuccess {
78 return nil
79 }
80 return &OperationFailedError{App: app, Op: op.Op, Outcome: op.Outcome}
81}
82
83// awaitOperation polls OperationByKey until the journal is terminal. The
84// optional initial response (the 202 mutation body) is reported before the
85// first poll so its state is not lost. Progress is written only when the
86// operation/step fingerprint changes.
87func awaitOperation(ctx context.Context, plane ControlPlane, app, key string, initial *dto.AppDeployResponse, progressOut io.Writer) (*dto.AppDeployResponse, error) {
88 lastProgress := ""
89 transient := 0
90 for {
91 op := initial
92 initial = nil
93 if op == nil {
94 got, err := plane.OperationByKey(ctx, app, key)
95 if err != nil {
96 if perr := handlePollError(ctx, progressOut, app, key, err, &transient); perr != nil {
97 return nil, perr
98 }
99 continue
100 }
101 transient = 0
102 op = got
103 }
104 if op.Status != dto.AppStatusRunning {
105 return op, nil
106 }
107 if sig := operationProgressSignature(op); sig != lastProgress {
108 lastProgress = sig
109 if err := renderOperationProgress(progressOut, op); err != nil {
110 return nil, err
111 }
112 }
113 if err := waitOperationPoll(ctx); err != nil {
114 return nil, stopOperationWatch(progressOut, app, key, err)
115 }
116 }
117}
118
119// handlePollError classifies one by-key lookup error. It returns nil when
120// the caller should poll again and a terminal error otherwise: 404 is
121// terminal, cancellation stops local polling, and any other failure is
122// retried up to operationPollMaxTransientErrors consecutive times.
123func handlePollError(ctx context.Context, progressOut io.Writer, app, key string, err error, transient *int) error {
124 if ctxErr := ctx.Err(); ctxErr != nil {
125 return stopOperationWatch(progressOut, app, key, ctxErr)
126 }
127 if isRemoteNotFoundError(err) {
128 return fmt.Errorf("operation %s for app %s is no longer recorded; nothing to resume: %w", key, app, err)
129 }
130 *transient++
131 if *transient > operationPollMaxTransientErrors {
132 return fmt.Errorf(
133 "gave up watching operation %s for app %s after %d consecutive polling errors: %w; resume with %s",
134 key, app, *transient, err, operationResumeCommand(app, key))
135 }
136 if werr := cliWriteLine(progressOut, cliRenderWarning(fmt.Sprintf(
137 "operation watch: transient polling error (%d/%d): %v", *transient, operationPollMaxTransientErrors, err))); werr != nil {
138 return werr
139 }
140 if werr := waitOperationPoll(ctx); werr != nil {
141 return stopOperationWatch(progressOut, app, key, werr)
142 }
143 return nil
144}
145
146// waitOperationPoll sleeps for one polling interval, aborting early when
147// the command context is cancelled.
148func waitOperationPoll(ctx context.Context) error {
149 if err := ctx.Err(); err != nil {
150 return err
151 }
152 timer := time.NewTimer(operationPollInterval)
153 defer timer.Stop()
154 select {
155 case <-ctx.Done():
156 return ctx.Err()
157 case <-timer.C:
158 return nil
159 }
160}
161
162// stopOperationWatch reports an interrupted watch without implying any
163// server-side cancellation, then returns the interruption as an error. The
164// resume command is printed so the user can continue watching later.
165func stopOperationWatch(progressOut io.Writer, app, key string, cause error) error {
166 if err := cliWriteLine(progressOut, cliRenderWarning("polling stopped; the operation is not cancelled and keeps running on the daemon")); err != nil {
167 return err
168 }
169 if err := cliWriteLine(progressOut, cliRenderMeta("resume:", operationResumeCommand(app, key))); err != nil {
170 return err
171 }
172 return fmt.Errorf("operation watch interrupted: %w", cause)
173}
174
175// operationProgressSignature fingerprints the visible operation/step state
176// so unchanged polls are not reprinted.
177func operationProgressSignature(op *dto.AppDeployResponse) string {
178 var b strings.Builder
179 b.WriteString(op.Status)
180 b.WriteByte(0)
181 b.WriteString(op.Outcome)
182 for _, step := range op.Steps {
183 b.WriteByte(0)
184 b.WriteString(step.ID)
185 b.WriteByte('=')
186 b.WriteString(step.State)
187 b.WriteByte(':')
188 b.WriteString(step.Detail)
189 b.WriteByte(':')
190 b.WriteString(step.Error)
191 }
192 return b.String()
193}
194
195// renderOperationProgress writes one concise progress line for the current
196// operation/step state.
197func renderOperationProgress(out io.Writer, op *dto.AppDeployResponse) error {
198 detail := op.Status
199 if step, ok := activeOperationStep(op.Steps); ok {
200 detail = fmt.Sprintf("%s %s: %s", op.Status, step.ID, step.State)
201 if step.Error != "" {
202 detail += ": " + sanitizeTerminalText(step.Error)
203 } else if step.Detail != "" {
204 detail += ": " + sanitizeTerminalText(step.Detail)
205 }
206 }
207 return cliWriteLine(out, cliRenderMeta("op "+op.Op+":", detail))
208}
209
210// activeOperationStep returns the step still to run (pending) when one
211// exists, otherwise the last journaled step.
212func activeOperationStep(steps []dto.AppStepDTO) (dto.AppStepDTO, bool) {
213 if len(steps) == 0 {
214 return dto.AppStepDTO{}, false
215 }
216 for _, step := range steps {
217 switch step.State {
218 case domain.AppStepSucceeded, domain.AppStepFailed, domain.AppStepNotRun:
219 continue
220 default:
221 return step, true
222 }
223 }
224 return steps[len(steps)-1], true
225}