main 0857610

handler_apps.go RawHistoryFolder

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		errors.Is(err, domain.ErrCapabilityPolicy) ||
536		isPreflightErrorRest(err)
537}
538
539// isPreflightErrorRest covers the remaining preflight-mappable sentinels.
540// Split from isMappedPreflightError to keep its complexity within budget.
541func isPreflightErrorRest(err error) bool {
542	return errors.Is(err, domain.ErrRuntimeUnsupported) ||
543		errors.Is(err, domain.ErrAppNotFound) ||
544		errors.Is(err, domain.ErrAppReservationConflict) ||
545		errors.Is(err, domain.ErrAppImageUnresolvable) ||
546		errors.Is(err, domain.ErrAppSecretMissing) ||
547		errors.Is(err, domain.ErrAppUnmanagedImageVolume) ||
548		errors.Is(err, domain.ErrAppRevisionNotFound) ||
549		errors.Is(err, domain.ErrAppIntentNotFound) ||
550		errors.Is(err, domain.ErrAppOperationNotFound) ||
551		errors.Is(err, domain.ErrAppStateConflict)
552}
553
554// sendAppOpError maps domain errors to status + envelope.
555func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) {
556	switch {
557	case errors.Is(err, domain.ErrInvalidAppSpec):
558		h.sendAppError(w, http.StatusBadRequest, "invalid-manifest", err.Error(), "", "")
559	case errors.Is(err, domain.ErrBindPolicy):
560		h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization")
561	case errors.Is(err, domain.ErrDevicePolicy):
562		h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization")
563	case errors.Is(err, domain.ErrCapabilityPolicy):
564		h.sendAppError(w, http.StatusBadRequest, "capability-policy-violation", err.Error(), "", "check the administrative capability authorization")
565	case errors.Is(err, domain.ErrRuntimeGuard):
566		h.sendAppError(w, http.StatusBadRequest, "runtime-guard-violation", err.Error(), "", "check administrative mounts and network settings")
567	case errors.Is(err, domain.ErrRuntimeUnsupported):
568		h.sendAppError(w, http.StatusBadRequest, "runtime-unsupported", err.Error(), "", "use an engine with native CDI support")
569	case errors.Is(err, domain.ErrAppReservationConflict):
570		h.sendAppError(w, http.StatusConflict, "reservation-conflict", err.Error(), "", "")
571	case errors.Is(err, domain.ErrAppImageUnresolvable):
572		h.sendAppError(w, http.StatusBadRequest, "image-unresolvable", err.Error(), "", "")
573	case errors.Is(err, domain.ErrAppSecretMissing):
574		h.sendAppError(w, http.StatusBadRequest, "secret-missing", err.Error(), "", "set the secret, then redeploy")
575	case errors.Is(err, domain.ErrAppUnmanagedImageVolume):
576		h.sendAppError(w, http.StatusBadRequest, "unmanaged-image-volume", err.Error(), "", "")
577	case errors.Is(err, domain.ErrAppServiceScope):
578		h.sendAppError(w, http.StatusBadRequest, "service-scope-required", err.Error(), "", "pass --service NAME or --all")
579	case errors.Is(err, domain.ErrAppNotFound):
580		h.sendAppError(w, http.StatusNotFound, "app-not-found", err.Error(), "", "check the app name")
581	case errors.Is(err, domain.ErrAppRevisionNotFound),
582		errors.Is(err, domain.ErrAppIntentNotFound),
583		errors.Is(err, domain.ErrAppOperationNotFound):
584		h.sendAppError(w, http.StatusNotFound, "not-found", err.Error(), "", "")
585	case errors.Is(err, domain.ErrAppStateConflict):
586		h.sendAppError(w, http.StatusConflict, "state-conflict", err.Error(), "", "")
587	default:
588		h.sendAppError(w, http.StatusInternalServerError, "internal", "app operation failed", "", "")
589	}
590}
591
592// toAppDiffSection maps the domain diff to its DTO shape.
593func toAppDiffSection(diff domain.AppDiff) dto.AppDiffSection {
594	return dto.AppDiffSection{Added: diff.Added, Removed: diff.Removed, Changed: diff.Changed}
595}
596
597// toAppDeployResponse maps a journaled operation to its DTO shape.
598// toAppDeployResponse maps an operation journal record to the wire DTO.
599// includeDiagnostics must be true only for callers holding the logs read
600// scope: application output is never returned on a mutation response.
601func toAppDeployResponse(app string, op *domain.AppOperation, includeDiagnostics bool) dto.AppDeployResponse {
602	resp := dto.AppDeployResponse{App: app}
603	if op == nil {
604		return resp
605	}
606	resp.Op = op.Op
607	resp.Revision = op.InputRevision
608	resp.Outcome = op.Outcome
609	// A non-terminal operation is still running; a terminal one reports
610	// its persisted outcome as the stable status.
611	resp.Status = dto.AppStatusRunning
612	if op.Terminal() {
613		resp.Status = op.Outcome
614	}
615	resp.Services = map[string]dto.AppServiceResultDTO{}
616	for _, step := range op.Steps {
617		service, ok := strings.CutPrefix(step.ID, "service.")
618		if !ok {
619			continue
620		}
621		service, action, _ := strings.Cut(service, ".")
622		entry := resp.Services[service]
623		switch step.State {
624		case domain.AppStepSucceeded:
625			entry.Result = "deployed"
626			if action == "remove" {
627				entry.Result = domain.AppServiceRemoved
628			}
629			if strings.HasPrefix(step.Detail, domain.AppServiceUnchanged+":") {
630				entry.Result = domain.AppServiceUnchanged
631			}
632		case domain.AppStepFailed:
633			entry.Result = "failed"
634			entry.Error = step.Error
635		default:
636			entry.Result = "pending"
637		}
638		entry.Before = step.Before
639		entry.After = step.After
640		// A removed service has no effective revision: the input revision
641		// no longer declares it.
642		if entry.EffectiveRevision == "" && entry.Result != domain.AppServiceRemoved {
643			entry.EffectiveRevision = op.InputRevision
644		}
645		resp.Services[service] = entry
646	}
647	for _, warning := range op.Warnings {
648		resp.CleanupWarnings = append(resp.CleanupWarnings, dto.AppCleanupWarningDTO{
649			Service: warning.Service, Leftover: warning.Leftover, Detail: warning.Detail,
650		})
651	}
652	resp.Steps = make([]dto.AppStepDTO, 0, len(op.Steps))
653	for _, step := range op.Steps {
654		entry := dto.AppStepDTO{
655			ID: step.ID, State: step.State, Detail: step.Detail,
656			Error: step.Error, Before: step.Before, After: step.After,
657		}
658		if includeDiagnostics {
659			entry.Diagnostics = step.Diagnostics
660		}
661		resp.Steps = append(resp.Steps, entry)
662	}
663	return resp
664}
665
666// appMutationResponse maps one operation journal to the wire DTO and
667// attaches current app state. Effective revisions, convergence, and owned
668// resources come from desired + ACTIVE + ownership, never from the
669// journal. An app that no longer exists (removal) omits both sections.
670func (h *Handler) appMutationResponse(ctx context.Context, svc in.AppService, app string, op *domain.AppOperation, includeDiagnostics bool) dto.AppDeployResponse {
671	resp := toAppDeployResponse(app, op, includeDiagnostics)
672	detail, err := svc.Show(ctx, app)
673	if err != nil {
674		return resp
675	}
676	effective := &dto.AppEffectiveDTO{
677		Converged:         detail.Converged,
678		ConvergedRevision: detail.ConvergedRevision,
679		Services:          map[string]string{},
680	}
681	for name, view := range detail.Services {
682		effective.Services[name] = view.EffectiveRevision
683	}
684	resp.Effective = effective
685	resp.Retained = &dto.AppRetainedDTO{
686		Volumes: detail.Retained.Volumes,
687		Secrets: detail.Retained.Secrets,
688		Images:  detail.Retained.Images,
689	}
690	return resp
691}

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