v3.3.2 9697f9d

apps.go RawHistoryFolder

1package remote
2
3import (
4	"bytes"
5	"context"
6	"encoding/hex"
7	"encoding/json"
8	"fmt"
9	"io"
10	"net/http"
11	"net/url"
12
13	"github.com/google/uuid"
14
15	"github.com/bnema/gordon/internal/adapters/dto"
16)
17
18// Apps API: daemon-owned app mutations through the existing V2
19// administration transport. Every mutation carries an Idempotency-Key
20// header (client-generated ULID); the daemon persists the claim before
21// effects and replays recorded results. On ambiguous transport outcome
22// the caller gets OutcomeUnknownError and must re-query by key — never
23// blindly retry.
24
25// newIdempotencyKey allocates a time-ordered key (uuid v7).
26func newIdempotencyKey() string {
27	id, err := uuid.NewV7()
28	if err != nil {
29		id = uuid.New()
30	}
31	var buf [32]byte
32	hex.Encode(buf[:], id[:])
33	return string(buf[:])
34}
35
36// mutationPost sends a POST mutation with an idempotency key.
37func (c *Client) mutationPost(ctx context.Context, path string, key string, body any) (*http.Response, error) {
38	var jsonBody []byte
39	if body != nil {
40		var err error
41		jsonBody, err = json.Marshal(body)
42		if err != nil {
43			return nil, fmt.Errorf("failed to marshal request body: %w", err)
44		}
45	}
46	return c.doMutation(ctx, http.MethodPost, path, key, jsonBody)
47}
48
49// doMutation executes one mutation request with the idempotency header.
50// Transport ambiguity surfaces as OutcomeUnknownError: the caller must
51// re-query GET …/operations/by-key/{key} before any retry, and retries
52// reuse the SAME key.
53func (c *Client) doMutation(ctx context.Context, method, path, key string, jsonBody []byte) (*http.Response, error) {
54	reqURL := c.baseURL + "/admin" + path
55
56	var bodyReader io.Reader
57	if jsonBody != nil {
58		bodyReader = bytes.NewReader(jsonBody)
59	}
60	req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
61	if err != nil {
62		return nil, fmt.Errorf("failed to create request: %w", err)
63	}
64	req.Header.Set("Content-Type", "application/json")
65	req.Header.Set("Accept", "application/json")
66	req.Header.Set("Idempotency-Key", key)
67
68	bearer, err := c.bearerToken(ctx)
69	if err != nil {
70		return nil, err
71	}
72	if bearer != "" {
73		req.Header.Set("Authorization", "Bearer "+bearer)
74	}
75
76	resp, err := c.httpClient.Do(req)
77	if err != nil {
78		return nil, &OutcomeUnknownError{Method: method, Path: path, Err: err}
79	}
80	if isRetryableStatus(resp.StatusCode) {
81		body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
82		_ = resp.Body.Close()
83		return nil, &OutcomeUnknownError{
84			Method: method,
85			Path:   path,
86			Err:    parseErrorResponse(resp, body),
87		}
88	}
89	return resp, nil
90}
91
92// ApplyApp validates and persists desired state (or dry-runs).
93func (c *Client) ApplyApp(ctx context.Context, req dto.AppApplyRequest) (*dto.AppApplyResponse, error) {
94	resp, err := c.mutationPost(ctx, "/apps/apply", newIdempotencyKey(), req)
95	if err != nil {
96		return nil, err
97	}
98	var result dto.AppApplyResponse
99	if err := parseResponse(resp, &result); err != nil {
100		return nil, fmt.Errorf("apply app: %w", err)
101	}
102	return &result, nil
103}
104
105// ListApps returns desired+active summaries.
106func (c *Client) ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error) {
107	resp, err := c.request(ctx, http.MethodGet, "/apps", nil)
108	if err != nil {
109		return nil, err
110	}
111	var envelope struct {
112		Apps []dto.AppSummaryDTO `json:"apps"`
113	}
114	if err := parseResponse(resp, &envelope); err != nil {
115		return nil, fmt.Errorf("list apps: %w", err)
116	}
117	return envelope.Apps, nil
118}
119
120// ShowApp inspects desired + active + intent + op ref.
121func (c *Client) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) {
122	resp, err := c.request(ctx, http.MethodGet, "/apps/"+url.PathEscape(app), nil)
123	if err != nil {
124		return nil, err
125	}
126	var result dto.AppShowResponse
127	if err := parseResponse(resp, &result); err != nil {
128		return nil, fmt.Errorf("show app %s: %w", app, err)
129	}
130	return &result, nil
131}
132
133// DiffApp returns the normalized desired-vs-active diff.
134func (c *Client) DiffApp(ctx context.Context, app string) (*dto.AppDiffResponse, error) {
135	resp, err := c.request(ctx, http.MethodGet, "/apps/"+url.PathEscape(app)+"/diff", nil)
136	if err != nil {
137		return nil, err
138	}
139	var result dto.AppDiffResponse
140	if err := parseResponse(resp, &result); err != nil {
141		return nil, fmt.Errorf("diff app %s: %w", app, err)
142	}
143	return &result, nil
144}
145
146// DeployApp activates a captured revision. It returns the key used so
147// ambiguous outcomes can be recovered via OperationByKey.
148func (c *Client) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) {
149	key := newIdempotencyKey()
150	resp, err := c.mutationPost(ctx, "/apps/"+url.PathEscape(app)+"/deploy", key, req)
151	if err != nil {
152		return nil, key, err
153	}
154	result, err := parseDeployResponse(resp, "deploy", app)
155	return result, key, err
156}
157
158// StopApp persists durable stopped intent and stops exact containers.
159func (c *Client) StopApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
160	return c.lifecyclePost(ctx, app, "stop")
161}
162
163// StartApp clears stopped intent and ensures running from active.
164func (c *Client) StartApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
165	return c.lifecyclePost(ctx, app, "start")
166}
167
168// RestartApp restarts from pinned digests (no re-resolution).
169// all confirms an app-wide restart of a multi-service app.
170func (c *Client) RestartApp(ctx context.Context, app, service string, all bool) (*dto.AppDeployResponse, string, error) {
171	key := newIdempotencyKey()
172	path := "/apps/" + url.PathEscape(app) + "/restart"
173	query := url.Values{}
174	if service != "" {
175		query.Set("service", service)
176	}
177	if all {
178		query.Set("all", "true")
179	}
180	if len(query) > 0 {
181		path += "?" + query.Encode()
182	}
183	resp, err := c.doMutation(ctx, http.MethodPost, path, key, nil)
184	if err != nil {
185		return nil, key, err
186	}
187	result, err := parseDeployResponse(resp, "restart", app)
188	return result, key, err
189}
190
191// RemoveApp withdraws workloads; volumes and secrets are retained.
192func (c *Client) RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error) {
193	return c.lifecyclePost(ctx, app, "remove")
194}
195
196// lifecyclePost posts a body-less lifecycle mutation.
197func (c *Client) lifecyclePost(ctx context.Context, app, action string) (*dto.AppDeployResponse, string, error) {
198	key := newIdempotencyKey()
199	resp, err := c.doMutation(ctx, http.MethodPost, "/apps/"+url.PathEscape(app)+"/"+action, key, nil)
200	if err != nil {
201		return nil, key, err
202	}
203	result, err := parseDeployResponse(resp, action, app)
204	return result, key, err
205}
206
207// maxDeployConflictBodySize bounds 409 journal reads: journals carry
208// per-service results and steps and can exceed the 1 KB error cap.
209const maxDeployConflictBodySize int64 = 1 << 20
210
211// AppOpConflictError reports a 409 deploy/lifecycle outcome while carrying
212// the decoded operation journal. Use errors.As to render the journal and
213// retain failure semantics; the idempotency key travels alongside the
214// returned error from DeployApp/RestartApp/lifecycle calls.
215type AppOpConflictError struct {
216	StatusCode int
217	Status     string
218	Response   dto.AppDeployResponse
219}
220
221func (e *AppOpConflictError) Error() string {
222	return fmt.Sprintf("%s: %s %s outcome %s", e.Status, e.Response.Op, e.Response.App, e.Response.Outcome)
223}
224
225// parseDeployResponse decodes a deploy/lifecycle mutation response. A 202
226// Accepted is a success shape: it carries the same running AppDeployResponse
227// journal as a 200 and is polled via OperationByKey, so it decodes through
228// the generic 2xx path. A 409 carries the journaled AppDeployResponse body
229// (per-service results, steps, effective/observed state) instead of an error
230// envelope; it is decoded and surfaced as AppOpConflictError so the CLI can
231// render the journal while retaining failure semantics.
232func parseDeployResponse(resp *http.Response, action, app string) (*dto.AppDeployResponse, error) {
233	if resp.StatusCode == http.StatusConflict {
234		return decodeDeployConflict(resp, action, app)
235	}
236	var result dto.AppDeployResponse
237	if err := parseResponse(resp, &result); err != nil {
238		return nil, fmt.Errorf("%s app %s: %w", action, app, err)
239	}
240	return &result, nil
241}
242
243// decodeDeployConflict decodes a 409 journal body into AppDeployResponse.
244// Bodies that are not journals (preflight AppError envelopes) fall back to
245// HTTPError so generic conflicts keep their existing shape.
246func decodeDeployConflict(resp *http.Response, action, app string) (*dto.AppDeployResponse, error) {
247	defer resp.Body.Close()
248	body, _ := io.ReadAll(io.LimitReader(resp.Body, maxDeployConflictBodySize))
249	var result dto.AppDeployResponse
250	if err := json.Unmarshal(body, &result); err != nil {
251		return nil, fmt.Errorf("%s app %s: %w", action, app, parseErrorResponse(resp, body))
252	}
253	if result.Op == "" && result.Outcome == "" && len(result.Services) == 0 && len(result.Steps) == 0 {
254		return nil, fmt.Errorf("%s app %s: %w", action, app, parseErrorResponse(resp, body))
255	}
256	conflict := &AppOpConflictError{StatusCode: resp.StatusCode, Status: resp.Status, Response: result}
257	return &result, fmt.Errorf("%s app %s: %w", action, app, conflict)
258}
259
260// OperationByKey recovers an ambiguous mutation outcome by idempotency key.
261func (c *Client) OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error) {
262	resp, err := c.request(ctx, http.MethodGet, "/apps/"+url.PathEscape(app)+"/operations/by-key/"+url.PathEscape(key), nil)
263	if err != nil {
264		return nil, err
265	}
266	var result dto.AppDeployResponse
267	if err := parseResponse(resp, &result); err != nil {
268		return nil, fmt.Errorf("operation by key: %w", err)
269	}
270	return &result, nil
271}
272
273// ListAppSecrets returns metadata-only secret registrations.
274func (c *Client) ListAppSecrets(ctx context.Context, app, service string) ([]dto.AppSecretMetadataDTO, error) {
275	path := "/apps/" + url.PathEscape(app) + "/secrets"
276	if service != "" {
277		path += "?service=" + url.QueryEscape(service)
278	}
279	resp, err := c.request(ctx, http.MethodGet, path, nil)
280	if err != nil {
281		return nil, fmt.Errorf("list app secrets %s: %w", app, err)
282	}
283	var result []dto.AppSecretMetadataDTO
284	if err := parseResponse(resp, &result); err != nil {
285		return nil, fmt.Errorf("list app secrets %s: %w", app, err)
286	}
287	return result, nil
288}
289
290// SetAppSecrets writes secret values for pre-registered names.
291func (c *Client) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error {
292	resp, err := c.mutationPost(ctx, "/apps/"+url.PathEscape(app)+"/secrets/set", newIdempotencyKey(), req)
293	if err != nil {
294		return err
295	}
296	return parseResponse(resp, nil)
297}
298
299// DeleteAppSecret removes one secret value (refused when referenced).
300func (c *Client) DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error {
301	resp, err := c.mutationPost(ctx, "/apps/"+url.PathEscape(app)+"/secrets/delete", newIdempotencyKey(), req)
302	if err != nil {
303		return err
304	}
305	return parseResponse(resp, nil)
306}

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 7

Tags 106

↑↓ moveenter openesc close