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}