v3.3.2 9697f9d

apps_watch.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close