1package admin
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "net/http"
9 "strings"
10
11 "github.com/bnema/gordon/internal/adapters/dto"
12 "github.com/bnema/gordon/internal/adapters/in/appmanifest"
13 "github.com/bnema/gordon/internal/boundaries/in"
14 "github.com/bnema/gordon/internal/domain"
15)
16
17// handleApps dispatches /admin/apps/* reads and lifecycle mutations.
18func (h *Handler) handleApps(w http.ResponseWriter, r *http.Request, path string) {
19 if path == "/apps/apply" {
20 h.handleAppApply(w, r)
21 return
22 }
23 if app, key, ok := splitOpLookup(path); ok {
24 h.handleAppOpLookup(w, r, app, key)
25 return
26 }
27 rest := strings.TrimPrefix(path, "/apps")
28 if rest == "" || rest == "/" {
29 if r.Method != http.MethodGet {
30 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
31 return
32 }
33 h.handleAppList(w, r)
34 return
35 }
36 parts := strings.Split(strings.Trim(rest, "/"), "/")
37 if parts[0] == "" {
38 h.sendError(w, http.StatusBadRequest, "app required in path")
39 return
40 }
41 h.dispatchAppSubroute(w, r, parts)
42}
43
44// splitOpLookup parses /apps/{app}/operations/by-key/{key}.
45func splitOpLookup(path string) (string, string, bool) {
46 rest, ok := strings.CutPrefix(path, "/apps/")
47 if !ok || !strings.Contains(rest, "/operations/by-key/") {
48 return "", "", false
49 }
50 app, key, _ := strings.Cut(rest, "/")
51 key, ok = strings.CutPrefix(key, "operations/by-key/")
52 if !ok || app == "" || key == "" || strings.Contains(key, "/") {
53 return "", "", false
54 }
55 return app, key, true
56}
57
58// dispatchAppSubroute routes one app's subpaths by method + shape.
59func (h *Handler) dispatchAppSubroute(w http.ResponseWriter, r *http.Request, parts []string) {
60 app := parts[0]
61 if len(parts) == 1 {
62 if r.Method != http.MethodGet {
63 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
64 return
65 }
66 h.handleAppShow(w, r, app)
67 return
68 }
69 if len(parts) == 2 {
70 if parts[1] == "secrets" && r.Method == http.MethodGet {
71 h.handleAppSecretsList(w, r, app)
72 } else {
73 h.dispatchAppVerb(w, r, app, parts[1])
74 }
75 return
76 }
77 if len(parts) == 3 && parts[1] == "secrets" {
78 h.dispatchAppSecrets(w, r, app, parts[2])
79 return
80 }
81 h.sendError(w, http.StatusNotFound, "route not found")
82}
83
84// dispatchAppVerb routes single-segment verbs (diff/deploy/stop/...).
85func (h *Handler) dispatchAppVerb(w http.ResponseWriter, r *http.Request, app, verb string) {
86 switch {
87 case verb == "diff" && r.Method == http.MethodGet:
88 h.handleAppDiff(w, r, app)
89 case verb == "deploy" && r.Method == http.MethodPost:
90 h.handleAppDeploy(w, r, app)
91 case verb == "restart" && r.Method == http.MethodPost:
92 h.handleAppRestart(w, r, app)
93 case (verb == "stop" || verb == "start" || verb == "remove") && r.Method == http.MethodPost:
94 h.handleAppLifecycle(w, r, app, verb)
95 default:
96 h.sendError(w, http.StatusNotFound, "route not found")
97 }
98}
99
100// dispatchAppSecrets routes the secrets set/delete pair.
101func (h *Handler) dispatchAppSecrets(w http.ResponseWriter, r *http.Request, app, action string) {
102 if r.Method != http.MethodPost {
103 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
104 return
105 }
106 switch action {
107 case "set":
108 h.handleAppSecretsSet(w, r, app)
109 case "delete":
110 h.handleAppSecretsDelete(w, r, app)
111 default:
112 h.sendError(w, http.StatusNotFound, "route not found")
113 }
114}
115
116// appService returns the port or 503 when the daemon engine is unwired.
117func (h *Handler) appService(w http.ResponseWriter) (in.AppService, bool) {
118 if h.appSvc == nil {
119 h.sendError(w, http.StatusServiceUnavailable, "app engine not available")
120 return nil, false
121 }
122 return h.appSvc, true
123}
124
125// handleAppApply validates a manifest and persists desired state.
126func (h *Handler) handleAppApply(w http.ResponseWriter, r *http.Request) {
127 ctx := r.Context()
128 if r.Method != http.MethodPost {
129 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
130 return
131 }
132 if !HasAccess(ctx, domain.AdminResourceApps, domain.AdminActionWrite) {
133 h.sendError(w, http.StatusForbidden, "insufficient permissions for apps:write")
134 return
135 }
136 svc, ok := h.appService(w)
137 if !ok {
138 return
139 }
140 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
141 var req dto.AppApplyRequest
142 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
143 h.sendAppError(w, http.StatusBadRequest, "invalid-request", "invalid JSON", "", "")
144 return
145 }
146 if strings.TrimSpace(req.ManifestTOML) == "" {
147 h.sendAppError(w, http.StatusBadRequest, "invalid-manifest", "manifest_toml is required", "", "")
148 return
149 }
150 spec, _, err := appmanifest.Parse([]byte(req.ManifestTOML), "")
151 if err != nil {
152 h.sendAppError(w, http.StatusBadRequest, "invalid-manifest", err.Error(), "", "")
153 return
154 }
155 applyResult, dryResult, err := svc.Apply(ctx, spec, []byte(req.ManifestTOML), req.DryRun)
156 if err != nil {
157 h.sendAppOpError(w, err)
158 return
159 }
160 if dryResult != nil {
161 h.sendJSON(w, http.StatusOK, dto.AppApplyResponse{
162 App: spec.Name,
163 Diff: toAppDiffSection(dryResult.Diff),
164 })
165 return
166 }
167 h.sendJSON(w, http.StatusOK, dto.AppApplyResponse{
168 App: applyResult.App,
169 FormerRevision: applyResult.FormerRevision,
170 ResultingRevision: applyResult.ResultingRevision,
171 Pending: applyResult.Pending,
172 Noop: applyResult.Noop,
173 Diff: toAppDiffSection(applyResult.Diff),
174 Intent: applyResult.IntentID,
175 })
176}
177
178// handleAppList returns one summary per known app.
179func (h *Handler) handleAppList(w http.ResponseWriter, r *http.Request) {
180 ctx := r.Context()
181 if !HasAccess(ctx, domain.AdminResourceApps, domain.AdminActionRead) {
182 h.sendError(w, http.StatusForbidden, "insufficient permissions for apps:read")
183 return
184 }
185 svc, ok := h.appService(w)
186 if !ok {
187 return
188 }
189 summaries, err := svc.List(ctx)
190 if err != nil {
191 h.sendAppOpError(w, err)
192 return
193 }
194 items := make([]dto.AppSummaryDTO, 0, len(summaries))
195 for _, s := range summaries {
196 items = append(items, dto.AppSummaryDTO{
197 App: s.App, Desired: s.Desired, DesiredStatus: s.DesiredStatus,
198 Active: s.Active, Converged: s.Converged, Pending: s.Pending,
199 Stopped: s.Stopped, LastOutcome: s.LastOutcome,
200 })
201 }
202 if items == nil {
203 items = []dto.AppSummaryDTO{}
204 }
205 h.sendJSON(w, http.StatusOK, map[string]any{"apps": items})
206}
207
208// handleAppShow inspects desired + active + intent.
209func (h *Handler) handleAppShow(w http.ResponseWriter, r *http.Request, app string) {
210 ctx := r.Context()
211 if !HasAccess(ctx, domain.AdminResourceApps, domain.AdminActionRead) {
212 h.sendError(w, http.StatusForbidden, "insufficient permissions for apps:read")
213 return
214 }
215 svc, ok := h.appService(w)
216 if !ok {
217 return
218 }
219 detail, err := svc.Show(ctx, app)
220 if err != nil {
221 h.sendAppOpError(w, err)
222 return
223 }
224 services := map[string]dto.AppActiveServiceDTO{}
225 for name, view := range detail.Services {
226 services[name] = dto.AppActiveServiceDTO{
227 EffectiveRevision: view.EffectiveRevision,
228 Digest: view.Digest,
229 Container: view.Container,
230 RestartUnsafe: view.RestartUnsafe,
231 }
232 }
233 resp := dto.AppShowResponse{
234 App: detail.App,
235 Desired: dto.AppDesiredDTO{Revision: detail.DesiredRevision, Status: detail.DesiredStatus, Pending: detail.Pending},
236 Active: dto.AppActiveDTO{
237 Converged: detail.Converged,
238 ConvergedRevision: detail.ConvergedRevision,
239 Services: services,
240 },
241 Intent: dto.AppIntentDTO{Stopped: detail.Stopped},
242 Retained: dto.AppRetainedDTO{
243 Volumes: detail.Retained.Volumes,
244 Secrets: detail.Retained.Secrets,
245 Images: detail.Retained.Images,
246 },
247 }
248 if detail.LastOp != "" {
249 resp.LastOp = &dto.AppLastOpDTO{
250 Op: detail.LastOp, Kind: detail.LastOpKind,
251 Outcome: detail.LastOutcome, StartedAt: detail.LastOpStartedAt,
252 }
253 }
254 h.sendJSON(w, http.StatusOK, resp)
255}
256
257// handleAppDiff returns the normalized desired-vs-active diff.
258func (h *Handler) handleAppDiff(w http.ResponseWriter, r *http.Request, app string) {
259 ctx := r.Context()
260 if !HasAccess(ctx, domain.AdminResourceApps, domain.AdminActionRead) {
261 h.sendError(w, http.StatusForbidden, "insufficient permissions for apps:read")
262 return
263 }
264 svc, ok := h.appService(w)
265 if !ok {
266 return
267 }
268 diff, err := svc.Diff(ctx, app)
269 if err != nil {
270 h.sendAppOpError(w, err)
271 return
272 }
273 h.sendJSON(w, http.StatusOK, dto.AppDiffResponse{App: app, Diff: toAppDiffSection(diff)})
274}
275
276// handleAppDeploy activates a captured revision.
277func (h *Handler) handleAppDeploy(w http.ResponseWriter, r *http.Request, app string) {
278 ctx := r.Context()
279 if !HasAccess(ctx, domain.AdminResourceApps, domain.AdminActionWrite) {
280 h.sendError(w, http.StatusForbidden, "insufficient permissions for apps:write")
281 return
282 }
283 svc, ok := h.appService(w)
284 if !ok {
285 return
286 }
287 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
288 var req dto.AppDeployRequest
289 if r.Body != nil {
290 raw, err := io.ReadAll(r.Body)
291 if err != nil {
292 h.sendAppError(w, http.StatusBadRequest, "invalid-request", "unreadable body", "", "")
293 return
294 }
295 if len(raw) > 0 {
296 if err := json.Unmarshal(raw, &req); err != nil {
297 h.sendAppError(w, http.StatusBadRequest, "invalid-request", "invalid JSON", "", "")
298 return
299 }
300 }
301 }
302 key, ok := appIdempotencyKey(w, r)
303 if !ok {
304 return
305 }
306 op, err := svc.Deploy(ctx, app, req.Revision, req.Service, req.All, key)
307 if err != nil && (op == nil || isMappedPreflightError(err, op)) {
308 h.sendAppOpError(w, err)
309 return
310 }
311 status := http.StatusOK
312 switch {
313 case err != nil:
314 status = http.StatusConflict
315 case op != nil && !op.Terminal():
316 // Newly owned: the claim is durable and its effects run on the
317 // daemon context, so answer 202 with the running operation and let
318 // the client observe completion through operations/by-key. A
319 // terminal replay instead answers 200 with the stored outcome.
320 status = http.StatusAccepted
321 }
322 h.sendJSON(w, status, h.appMutationResponse(ctx, svc, app, op, false))
323}
324
325// handleAppLifecycle runs stop/start/remove verbs.
326func (h *Handler) handleAppLifecycle(w http.ResponseWriter, r *http.Request, app, verb string) {
327 ctx := r.Context()
328 if !HasAccess(ctx, domain.AdminResourceApps, domain.AdminActionWrite) {
329 h.sendError(w, http.StatusForbidden, "insufficient permissions for apps:write")
330 return
331 }
332 svc, ok := h.appService(w)
333 if !ok {
334 return
335 }
336 key, ok := appIdempotencyKey(w, r)
337 if !ok {
338 return
339 }
340 var domainOp *domain.AppOperation
341 var err error
342 switch verb {
343 case "stop":
344 domainOp, err = svc.Stop(ctx, app, key)
345 case "start":
346 domainOp, err = svc.Start(ctx, app, key)
347 case "remove":
348 domainOp, err = svc.Remove(ctx, app, key)
349 default:
350 h.sendError(w, http.StatusNotFound, "route not found")
351 return
352 }
353 if err != nil && domainOp == nil {
354 h.sendAppOpError(w, err)
355 return
356 }
357 status := http.StatusOK
358 if err != nil {
359 status = http.StatusConflict
360 }
361 h.sendJSON(w, status, h.appMutationResponse(ctx, svc, app, domainOp, false))
362}
363
364// handleAppRestart restarts from pinned digests.
365func (h *Handler) handleAppRestart(w http.ResponseWriter, r *http.Request, app string) {
366 ctx := r.Context()
367 if !HasAccess(ctx, domain.AdminResourceApps, domain.AdminActionWrite) {
368 h.sendError(w, http.StatusForbidden, "insufficient permissions for apps:write")
369 return
370 }
371 svc, ok := h.appService(w)
372 if !ok {
373 return
374 }
375 key, ok := appIdempotencyKey(w, r)
376 if !ok {
377 return
378 }
379 query := r.URL.Query()
380 all := query.Get("all") == "true"
381 op, err := svc.Restart(ctx, app, query.Get("service"), all, key)
382 if err != nil && op == nil {
383 h.sendAppOpError(w, err)
384 return
385 }
386 status := http.StatusOK
387 if err != nil {
388 status = http.StatusConflict
389 }
390 h.sendJSON(w, status, h.appMutationResponse(ctx, svc, app, op, false))
391}
392
393func appIdempotencyKey(w http.ResponseWriter, r *http.Request) (string, bool) {
394 key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
395 if key == "" || len(key) > 128 {
396 http.Error(w, "valid Idempotency-Key header required", http.StatusBadRequest)
397 return "", false
398 }
399 for _, char := range key {
400 if char < '!' || char > '~' {
401 http.Error(w, "valid Idempotency-Key header required", http.StatusBadRequest)
402 return "", false
403 }
404 }
405 return key, true
406}
407
408// handleAppOpLookup serves GET /apps/{app}/operations/{key}.
409func (h *Handler) handleAppOpLookup(w http.ResponseWriter, r *http.Request, app, key string) {
410 ctx := r.Context()
411 if !HasAccess(ctx, domain.AdminResourceApps, domain.AdminActionRead) {
412 h.sendError(w, http.StatusForbidden, "insufficient permissions for apps:read")
413 return
414 }
415 svc, ok := h.appService(w)
416 if !ok {
417 return
418 }
419 op, err := svc.OperationByKey(ctx, app, key)
420 if err != nil {
421 h.sendAppOpError(w, err)
422 return
423 }
424 // Application diagnostics require the logs read scope; an apps-read
425 // actor receives the stable error only. The lookup answers with the
426 // journal alone: no app read is added to a journal query.
427 includeDiagnostics := HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead)
428 h.sendJSON(w, http.StatusOK, toAppDeployResponse(app, op, includeDiagnostics))
429}
430
431func (h *Handler) handleAppSecretsList(w http.ResponseWriter, r *http.Request, app string) {
432 ctx := r.Context()
433 if !HasAccess(ctx, domain.AdminResourceApps, domain.AdminActionRead) {
434 h.sendError(w, http.StatusForbidden, "insufficient permissions for apps:read")
435 return
436 }
437 svc, ok := h.appService(w)
438 if !ok {
439 return
440 }
441 entries, err := svc.ListSecrets(ctx, app, r.URL.Query().Get("service"))
442 if err != nil {
443 h.sendAppOpError(w, err)
444 return
445 }
446 result := make([]dto.AppSecretMetadataDTO, 0, len(entries))
447 for _, entry := range entries {
448 result = append(result, dto.AppSecretMetadataDTO{Service: entry.Service, Key: entry.Key, Name: entry.Name, Source: entry.Source, Presence: entry.Presence})
449 }
450 h.sendJSON(w, http.StatusOK, result)
451}
452
453// handleAppSecretsSet writes secret values for pre-registered names.
454func (h *Handler) handleAppSecretsSet(w http.ResponseWriter, r *http.Request, app string) {
455 ctx := r.Context()
456 if !HasAccess(ctx, domain.AdminResourceApps, domain.AdminActionWrite) {
457 h.sendError(w, http.StatusForbidden, "insufficient permissions for apps:write")
458 return
459 }
460 svc, ok := h.appService(w)
461 if !ok {
462 return
463 }
464 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
465 var req dto.AppSecretSetRequest
466 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
467 h.sendAppError(w, http.StatusBadRequest, "invalid-request", "invalid JSON", "", "")
468 return
469 }
470 if err := svc.SetSecrets(ctx, app, req.Service, req.Secrets); err != nil {
471 h.sendAppSecretWriteError(w, err)
472 return
473 }
474 h.sendJSON(w, http.StatusOK, map[string]string{"status": "updated"})
475}
476
477// handleAppSecretsDelete removes one secret value (refused when referenced).
478func (h *Handler) handleAppSecretsDelete(w http.ResponseWriter, r *http.Request, app string) {
479 ctx := r.Context()
480 if !HasAccess(ctx, domain.AdminResourceApps, domain.AdminActionWrite) {
481 h.sendError(w, http.StatusForbidden, "insufficient permissions for apps:write")
482 return
483 }
484 svc, ok := h.appService(w)
485 if !ok {
486 return
487 }
488 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
489 var req dto.AppSecretDeleteRequest
490 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
491 h.sendAppError(w, http.StatusBadRequest, "invalid-request", "invalid JSON", "", "")
492 return
493 }
494 if err := svc.DeleteSecret(ctx, app, req.Service, req.Key); err != nil {
495 h.sendAppSecretWriteError(w, err)
496 return
497 }
498 h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
499}
500
501// sendAppSecretWriteError maps secret set/delete errors. A missing
502// registration is not fixed by setting the secret, so the deploy-oriented
503// hint of sendAppOpError is omitted; the message carries the next step.
504func (h *Handler) sendAppSecretWriteError(w http.ResponseWriter, err error) {
505 if errors.Is(err, domain.ErrAppSecretMissing) {
506 h.sendAppError(w, http.StatusBadRequest, "secret-missing", err.Error(), "", "")
507 return
508 }
509 h.sendAppOpError(w, err)
510}
511
512// sendAppError writes the v3 error envelope (never carries logs).
513func (h *Handler) sendAppError(w http.ResponseWriter, status int, code, message, cause, hint string) {
514 w.Header().Set("Content-Type", "application/json")
515 w.WriteHeader(status)
516 _ = json.NewEncoder(w).Encode(dto.AppError{Error: code, Message: message, Cause: cause, Hint: hint})
517}
518
519func isMappedPreflightError(err error, op *domain.AppOperation) bool {
520 // A non-terminal journal is a running replay, never a preflight
521 // failure: its stored operation is the response regardless of which
522 // steps have started, so the mapped error envelope must not replace
523 // it. Only an absent or terminal journal can map a preflight error.
524 if op != nil && !op.Terminal() {
525 return false
526 }
527 for _, step := range op.Steps {
528 if strings.HasPrefix(step.ID, "service.") {
529 return false
530 }
531 }
532 return errors.Is(err, domain.ErrInvalidAppSpec) ||
533 errors.Is(err, domain.ErrBindPolicy) ||
534 errors.Is(err, domain.ErrDevicePolicy) ||
535 isPreflightErrorRest(err)
536}
537
538// isPreflightErrorRest covers the remaining preflight-mappable sentinels.
539// Split from isMappedPreflightError to keep its complexity within budget.
540func isPreflightErrorRest(err error) bool {
541 return errors.Is(err, domain.ErrRuntimeUnsupported) ||
542 errors.Is(err, domain.ErrAppNotFound) ||
543 errors.Is(err, domain.ErrAppReservationConflict) ||
544 errors.Is(err, domain.ErrAppImageUnresolvable) ||
545 errors.Is(err, domain.ErrAppSecretMissing) ||
546 errors.Is(err, domain.ErrAppUnmanagedImageVolume) ||
547 errors.Is(err, domain.ErrAppRevisionNotFound) ||
548 errors.Is(err, domain.ErrAppIntentNotFound) ||
549 errors.Is(err, domain.ErrAppOperationNotFound) ||
550 errors.Is(err, domain.ErrAppStateConflict)
551}
552
553// sendAppOpError maps domain errors to status + envelope.
554func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) {
555 switch {
556 case errors.Is(err, domain.ErrInvalidAppSpec):
557 h.sendAppError(w, http.StatusBadRequest, "invalid-manifest", err.Error(), "", "")
558 case errors.Is(err, domain.ErrBindPolicy):
559 h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization")
560 case errors.Is(err, domain.ErrDevicePolicy):
561 h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization")
562 case errors.Is(err, domain.ErrRuntimeGuard):
563 h.sendAppError(w, http.StatusBadRequest, "runtime-guard-violation", err.Error(), "", "check administrative mounts and network settings")
564 case errors.Is(err, domain.ErrRuntimeUnsupported):
565 h.sendAppError(w, http.StatusBadRequest, "runtime-unsupported", err.Error(), "", "use an engine with native CDI support")
566 case errors.Is(err, domain.ErrAppReservationConflict):
567 h.sendAppError(w, http.StatusConflict, "reservation-conflict", err.Error(), "", "")
568 case errors.Is(err, domain.ErrAppImageUnresolvable):
569 h.sendAppError(w, http.StatusBadRequest, "image-unresolvable", err.Error(), "", "")
570 case errors.Is(err, domain.ErrAppSecretMissing):
571 h.sendAppError(w, http.StatusBadRequest, "secret-missing", err.Error(), "", "set the secret, then redeploy")
572 case errors.Is(err, domain.ErrAppUnmanagedImageVolume):
573 h.sendAppError(w, http.StatusBadRequest, "unmanaged-image-volume", err.Error(), "", "")
574 case errors.Is(err, domain.ErrAppServiceScope):
575 h.sendAppError(w, http.StatusBadRequest, "service-scope-required", err.Error(), "", "pass --service NAME or --all")
576 case errors.Is(err, domain.ErrAppNotFound):
577 h.sendAppError(w, http.StatusNotFound, "app-not-found", err.Error(), "", "check the app name")
578 case errors.Is(err, domain.ErrAppRevisionNotFound),
579 errors.Is(err, domain.ErrAppIntentNotFound),
580 errors.Is(err, domain.ErrAppOperationNotFound):
581 h.sendAppError(w, http.StatusNotFound, "not-found", err.Error(), "", "")
582 case errors.Is(err, domain.ErrAppStateConflict):
583 h.sendAppError(w, http.StatusConflict, "state-conflict", err.Error(), "", "")
584 default:
585 h.sendAppError(w, http.StatusInternalServerError, "internal", "app operation failed", "", "")
586 }
587}
588
589// toAppDiffSection maps the domain diff to its DTO shape.
590func toAppDiffSection(diff domain.AppDiff) dto.AppDiffSection {
591 return dto.AppDiffSection{Added: diff.Added, Removed: diff.Removed, Changed: diff.Changed}
592}
593
594// toAppDeployResponse maps a journaled operation to its DTO shape.
595// toAppDeployResponse maps an operation journal record to the wire DTO.
596// includeDiagnostics must be true only for callers holding the logs read
597// scope: application output is never returned on a mutation response.
598func toAppDeployResponse(app string, op *domain.AppOperation, includeDiagnostics bool) dto.AppDeployResponse {
599 resp := dto.AppDeployResponse{App: app}
600 if op == nil {
601 return resp
602 }
603 resp.Op = op.Op
604 resp.Revision = op.InputRevision
605 resp.Outcome = op.Outcome
606 // A non-terminal operation is still running; a terminal one reports
607 // its persisted outcome as the stable status.
608 resp.Status = dto.AppStatusRunning
609 if op.Terminal() {
610 resp.Status = op.Outcome
611 }
612 resp.Services = map[string]dto.AppServiceResultDTO{}
613 for _, step := range op.Steps {
614 service, ok := strings.CutPrefix(step.ID, "service.")
615 if !ok {
616 continue
617 }
618 service, action, _ := strings.Cut(service, ".")
619 entry := resp.Services[service]
620 switch step.State {
621 case domain.AppStepSucceeded:
622 entry.Result = "deployed"
623 if action == "remove" {
624 entry.Result = domain.AppServiceRemoved
625 }
626 if strings.HasPrefix(step.Detail, domain.AppServiceUnchanged+":") {
627 entry.Result = domain.AppServiceUnchanged
628 }
629 case domain.AppStepFailed:
630 entry.Result = "failed"
631 entry.Error = step.Error
632 default:
633 entry.Result = "pending"
634 }
635 entry.Before = step.Before
636 entry.After = step.After
637 // A removed service has no effective revision: the input revision
638 // no longer declares it.
639 if entry.EffectiveRevision == "" && entry.Result != domain.AppServiceRemoved {
640 entry.EffectiveRevision = op.InputRevision
641 }
642 resp.Services[service] = entry
643 }
644 for _, warning := range op.Warnings {
645 resp.CleanupWarnings = append(resp.CleanupWarnings, dto.AppCleanupWarningDTO{
646 Service: warning.Service, Leftover: warning.Leftover, Detail: warning.Detail,
647 })
648 }
649 resp.Steps = make([]dto.AppStepDTO, 0, len(op.Steps))
650 for _, step := range op.Steps {
651 entry := dto.AppStepDTO{
652 ID: step.ID, State: step.State, Detail: step.Detail,
653 Error: step.Error, Before: step.Before, After: step.After,
654 }
655 if includeDiagnostics {
656 entry.Diagnostics = step.Diagnostics
657 }
658 resp.Steps = append(resp.Steps, entry)
659 }
660 return resp
661}
662
663// appMutationResponse maps one operation journal to the wire DTO and
664// attaches current app state. Effective revisions, convergence, and owned
665// resources come from desired + ACTIVE + ownership, never from the
666// journal. An app that no longer exists (removal) omits both sections.
667func (h *Handler) appMutationResponse(ctx context.Context, svc in.AppService, app string, op *domain.AppOperation, includeDiagnostics bool) dto.AppDeployResponse {
668 resp := toAppDeployResponse(app, op, includeDiagnostics)
669 detail, err := svc.Show(ctx, app)
670 if err != nil {
671 return resp
672 }
673 effective := &dto.AppEffectiveDTO{
674 Converged: detail.Converged,
675 ConvergedRevision: detail.ConvergedRevision,
676 Services: map[string]string{},
677 }
678 for name, view := range detail.Services {
679 effective.Services[name] = view.EffectiveRevision
680 }
681 resp.Effective = effective
682 resp.Retained = &dto.AppRetainedDTO{
683 Volumes: detail.Retained.Volumes,
684 Secrets: detail.Retained.Secrets,
685 Images: detail.Retained.Images,
686 }
687 return resp
688}