v2.5.0 b9b6391

handler.go RawHistoryFolder

1// Package admin implements the HTTP adapter for the admin API.
2package admin
3
4import (
5	"encoding/json"
6	"errors"
7	"fmt"
8	"net/http"
9	"strconv"
10	"strings"
11
12	"github.com/bnema/zerowrap"
13
14	"gordon/internal/adapters/dto"
15	"gordon/internal/boundaries/in"
16	"gordon/internal/boundaries/out"
17	"gordon/internal/domain"
18)
19
20// maxAdminRequestSize is the maximum allowed size for admin API request bodies.
21const maxAdminRequestSize = 1 << 20 // 1MB
22
23// maxLogLines is the maximum allowed number of log lines that can be requested.
24const maxLogLines = 10000
25
26// Handler implements the HTTP handler for the admin API.
27type Handler struct {
28	configSvc    in.ConfigService
29	authSvc      in.AuthService
30	containerSvc in.ContainerService
31	healthSvc    in.HealthService
32	secretSvc    in.SecretService
33	logSvc       in.LogService
34	eventBus     out.EventPublisher
35	log          zerowrap.Logger
36}
37
38// Type aliases for API responses using shared DTO types.
39type routeInfoResponse = dto.RouteInfo
40type attachmentResponse = dto.Attachment
41type routeResponse = dto.Route
42
43// toAttachmentResponse converts a domain.Attachment to a dto.Attachment.
44func toAttachmentResponse(a domain.Attachment) dto.Attachment {
45	return dto.Attachment{
46		Name:        a.Name,
47		Image:       a.Image,
48		ContainerID: a.ContainerID,
49		Status:      a.Status,
50		Network:     a.Network,
51	}
52}
53
54// toRouteInfoResponse converts a domain.RouteInfo to a dto.RouteInfo.
55func toRouteInfoResponse(r domain.RouteInfo) dto.RouteInfo {
56	attachments := make([]dto.Attachment, 0, len(r.Attachments))
57	for _, a := range r.Attachments {
58		attachments = append(attachments, toAttachmentResponse(a))
59	}
60	return dto.RouteInfo{
61		Domain:          r.Domain,
62		Image:           r.Image,
63		ContainerID:     r.ContainerID,
64		ContainerStatus: r.ContainerStatus,
65		Network:         r.Network,
66		Attachments:     attachments,
67	}
68}
69
70// toRouteResponse converts a domain.Route to a dto.Route.
71func toRouteResponse(r domain.Route) dto.Route {
72	return dto.Route{
73		Domain: r.Domain,
74		Image:  r.Image,
75		HTTPS:  r.HTTPS,
76	}
77}
78
79// NewHandler creates a new admin HTTP handler.
80func NewHandler(
81	configSvc in.ConfigService,
82	authSvc in.AuthService,
83	containerSvc in.ContainerService,
84	healthSvc in.HealthService,
85	secretSvc in.SecretService,
86	logSvc in.LogService,
87	eventBus out.EventPublisher,
88	log zerowrap.Logger,
89) *Handler {
90	return &Handler{
91		configSvc:    configSvc,
92		authSvc:      authSvc,
93		containerSvc: containerSvc,
94		healthSvc:    healthSvc,
95		secretSvc:    secretSvc,
96		logSvc:       logSvc,
97		eventBus:     eventBus,
98		log:          log,
99	}
100}
101
102// RegisterRoutes registers the admin routes on the given mux.
103func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
104	mux.HandleFunc("/admin/", h.handleAdminRoutes)
105}
106
107// ServeHTTP implements http.Handler.
108func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
109	h.handleAdminRoutes(w, r)
110}
111
112func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
113	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
114		zerowrap.FieldLayer:   "adapter",
115		zerowrap.FieldAdapter: "http",
116		zerowrap.FieldHandler: "admin",
117		zerowrap.FieldMethod:  r.Method,
118		zerowrap.FieldPath:    r.URL.Path,
119	})
120	r = r.WithContext(ctx)
121
122	path := strings.TrimPrefix(r.URL.Path, "/admin")
123
124	// Route to appropriate handler
125	switch {
126	case path == "/routes" || strings.HasPrefix(path, "/routes/"):
127		h.handleRoutes(w, r, path)
128	case path == "/networks":
129		h.handleNetworks(w, r)
130	case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):
131		h.handleSecrets(w, r, path)
132	case path == "/deploy" || strings.HasPrefix(path, "/deploy/"):
133		h.handleDeploy(w, r, path)
134	case path == "/logs" || strings.HasPrefix(path, "/logs/"):
135		h.handleLogs(w, r, path)
136	case path == "/status":
137		h.handleStatus(w, r)
138	case path == "/health":
139		h.handleHealth(w, r)
140	case path == "/reload":
141		h.handleReload(w, r)
142	case path == "/config":
143		h.handleConfig(w, r)
144	default:
145		h.sendError(w, http.StatusNotFound, "route not found")
146	}
147}
148
149// sendJSON sends a JSON response.
150func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
151	w.Header().Set("Content-Type", "application/json")
152	w.WriteHeader(status)
153	if data != nil {
154		_ = json.NewEncoder(w).Encode(data)
155	}
156}
157
158// sendError sends an error response.
159func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
160	h.sendJSON(w, status, dto.ErrorResponse{Error: message})
161}
162
163// handleRoutes handles /admin/routes endpoints.
164func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path string) {
165	// Parse domain from path if present
166	routeDomain := strings.TrimPrefix(path, "/routes/")
167	if routeDomain == "/routes" {
168		routeDomain = ""
169	}
170
171	switch r.Method {
172	case http.MethodGet:
173		h.handleRoutesGet(w, r, routeDomain)
174	case http.MethodPost:
175		h.handleRoutesPost(w, r)
176	case http.MethodPut:
177		h.handleRoutesPut(w, r, routeDomain)
178	case http.MethodDelete:
179		h.handleRoutesDelete(w, r, routeDomain)
180	default:
181		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
182	}
183}
184
185func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeDomain string) {
186	ctx := r.Context()
187
188	// Check read permission
189	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
190		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
191		return
192	}
193
194	if routeDomain == "" {
195		if r.URL.Query().Get("detailed") == "true" {
196			routes := h.containerSvc.ListRoutesWithDetails(ctx)
197			response := make([]routeInfoResponse, 0, len(routes))
198			for _, route := range routes {
199				response = append(response, toRouteInfoResponse(route))
200			}
201			h.sendJSON(w, http.StatusOK, dto.RoutesDetailResponse{Routes: response})
202			return
203		}
204
205		routes := h.configSvc.GetRoutes(ctx)
206		response := make([]routeResponse, 0, len(routes))
207		for _, route := range routes {
208			response = append(response, toRouteResponse(route))
209		}
210		h.sendJSON(w, http.StatusOK, dto.RoutesResponse{Routes: response})
211		return
212	}
213
214	if strings.HasSuffix(routeDomain, "/attachments") {
215		parentDomain := strings.TrimSuffix(routeDomain, "/attachments")
216		if parentDomain == "" {
217			h.sendError(w, http.StatusBadRequest, "domain required in path")
218			return
219		}
220		attachments := h.containerSvc.ListAttachments(ctx, parentDomain)
221		response := make([]attachmentResponse, 0, len(attachments))
222		for _, attachment := range attachments {
223			response = append(response, toAttachmentResponse(attachment))
224		}
225		h.sendJSON(w, http.StatusOK, dto.AttachmentsResponse{Attachments: response})
226		return
227	}
228
229	route, err := h.configSvc.GetRoute(ctx, routeDomain)
230	if err != nil {
231		h.sendError(w, http.StatusNotFound, "route not found")
232		return
233	}
234	h.sendJSON(w, http.StatusOK, toRouteResponse(*route))
235}
236
237func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
238	ctx := r.Context()
239	log := zerowrap.FromCtx(ctx)
240
241	// Check write permission
242	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
243		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
244		return
245	}
246
247	// Limit request body size
248	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
249
250	var route domain.Route
251	if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
252		log.Warn().Err(err).Msg("invalid route JSON")
253		h.sendError(w, http.StatusBadRequest, "invalid JSON")
254		return
255	}
256
257	if err := h.configSvc.AddRoute(ctx, route); err != nil {
258		log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
259		switch {
260		case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
261			h.sendError(w, http.StatusBadRequest, err.Error())
262		default:
263			h.sendError(w, http.StatusInternalServerError, "failed to add route")
264		}
265		return
266	}
267
268	log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added")
269	h.sendJSON(w, http.StatusCreated, toRouteResponse(route))
270}
271
272func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) {
273	ctx := r.Context()
274	log := zerowrap.FromCtx(ctx)
275
276	// Check write permission
277	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
278		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
279		return
280	}
281
282	if routeDomain == "" {
283		h.sendError(w, http.StatusBadRequest, "domain required in path")
284		return
285	}
286
287	// Limit request body size
288	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
289
290	var route domain.Route
291	if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
292		log.Warn().Err(err).Msg("invalid route JSON")
293		h.sendError(w, http.StatusBadRequest, "invalid JSON")
294		return
295	}
296
297	route.Domain = routeDomain
298
299	if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
300		log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
301		switch {
302		case errors.Is(err, domain.ErrRouteNotFound):
303			h.sendError(w, http.StatusNotFound, "route not found")
304		case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
305			h.sendError(w, http.StatusBadRequest, err.Error())
306		default:
307			h.sendError(w, http.StatusInternalServerError, "failed to update route")
308		}
309		return
310	}
311
312	log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated")
313	h.sendJSON(w, http.StatusOK, toRouteResponse(route))
314}
315
316func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) {
317	ctx := r.Context()
318	log := zerowrap.FromCtx(ctx)
319
320	// Check write permission
321	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
322		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
323		return
324	}
325
326	if routeDomain == "" {
327		h.sendError(w, http.StatusBadRequest, "domain required in path")
328		return
329	}
330
331	if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
332		log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
333		h.sendError(w, http.StatusInternalServerError, "failed to remove route")
334		return
335	}
336
337	log.Info().Str("domain", routeDomain).Msg("route removed")
338	h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"})
339}
340
341func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
342	if r.Method != http.MethodGet {
343		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
344		return
345	}
346
347	ctx := r.Context()
348
349	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
350		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
351		return
352	}
353
354	networks, err := h.containerSvc.ListNetworks(ctx)
355	if err != nil {
356		h.sendError(w, http.StatusInternalServerError, "failed to list networks")
357		return
358	}
359
360	response := make([]dto.Network, 0, len(networks))
361	for _, network := range networks {
362		if network == nil {
363			continue
364		}
365		var labelsCopy map[string]string
366		if network.Labels != nil {
367			labelsCopy = make(map[string]string, len(network.Labels))
368			for key, value := range network.Labels {
369				labelsCopy[key] = value
370			}
371		}
372		response = append(response, dto.Network{
373			ID:         network.ID,
374			Name:       network.Name,
375			Driver:     network.Driver,
376			Containers: append([]string{}, network.Containers...),
377			Labels:     labelsCopy,
378		})
379	}
380
381	h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response})
382}
383
384// handleSecrets handles /admin/secrets endpoints.
385func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
386	ctx := r.Context()
387	log := zerowrap.FromCtx(ctx)
388
389	// Parse path: /secrets/{domain} or /secrets/{domain}/{key}
390	parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")
391	if len(parts) == 0 || parts[0] == "" {
392		h.sendError(w, http.StatusBadRequest, "domain required")
393		return
394	}
395
396	secretDomain := parts[0]
397	secretKey := ""
398	if len(parts) > 1 {
399		secretKey = parts[1]
400	}
401
402	switch r.Method {
403	case http.MethodGet:
404		// Check read permission
405		if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
406			h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
407			return
408		}
409		// List secrets for domain (names only, not values)
410		keys, err := h.secretSvc.ListKeys(ctx, secretDomain)
411		if err != nil {
412			log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
413			h.sendError(w, http.StatusBadRequest, "invalid domain")
414			return
415		}
416		h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{Domain: secretDomain, Keys: keys})
417
418	case http.MethodPost:
419		// Check write permission
420		if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
421			h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
422			return
423		}
424
425		// Limit request body size
426		r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
427
428		// Set secret(s)
429		var data map[string]string
430		if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
431			log.Warn().Err(err).Msg("invalid secrets JSON")
432			h.sendError(w, http.StatusBadRequest, "invalid JSON")
433			return
434		}
435
436		if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
437			log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
438			h.sendError(w, http.StatusBadRequest, "invalid domain")
439			return
440		}
441
442		log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
443		h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
444
445	case http.MethodDelete:
446		// Check write permission
447		if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
448			h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
449			return
450		}
451		if secretKey == "" {
452			h.sendError(w, http.StatusBadRequest, "key required in path")
453			return
454		}
455
456		if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
457			log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
458			h.sendError(w, http.StatusBadRequest, "invalid domain")
459			return
460		}
461
462		log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
463		h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
464
465	default:
466		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
467	}
468}
469
470// handleHealth handles /admin/health endpoint.
471// Returns detailed health status for all routes including HTTP probe results.
472func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
473	if r.Method != http.MethodGet {
474		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
475		return
476	}
477
478	ctx := r.Context()
479
480	// Check read permission
481	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
482		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
483		return
484	}
485
486	health := h.healthSvc.CheckAllRoutes(ctx)
487
488	response := make(map[string]dto.HealthStatus, len(health))
489	for domain, healthStatus := range health {
490		if healthStatus == nil {
491			continue
492		}
493		response[domain] = dto.HealthStatus{
494			ContainerStatus: healthStatus.ContainerStatus,
495			HTTPStatus:      healthStatus.HTTPStatus,
496			ResponseTimeMs:  healthStatus.ResponseTimeMs,
497			Healthy:         healthStatus.Healthy,
498			Error:           healthStatus.Error,
499		}
500	}
501
502	h.sendJSON(w, http.StatusOK, dto.HealthResponse{Health: response})
503}
504
505// handleStatus handles /admin/status endpoint.
506func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
507	if r.Method != http.MethodGet {
508		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
509		return
510	}
511
512	ctx := r.Context()
513
514	// Check read permission
515	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
516		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
517		return
518	}
519
520	routes := h.configSvc.GetRoutes(ctx)
521
522	// Get container statuses
523	statuses := make(map[string]string)
524	for _, route := range routes {
525		status := "unknown"
526		container, ok := h.containerSvc.Get(ctx, route.Domain)
527		if ok && container != nil {
528			status = container.Status
529		}
530		statuses[route.Domain] = status
531	}
532
533	status := dto.StatusResponse{
534		Routes:            len(routes),
535		RegistryDomain:    h.configSvc.GetRegistryDomain(),
536		RegistryPort:      h.configSvc.GetRegistryPort(),
537		ServerPort:        h.configSvc.GetServerPort(),
538		AutoRoute:         h.configSvc.IsAutoRouteEnabled(),
539		NetworkIsolation:  h.configSvc.IsNetworkIsolationEnabled(),
540		ContainerStatuses: statuses,
541	}
542
543	h.sendJSON(w, http.StatusOK, status)
544}
545
546// handleReload handles /admin/reload endpoint.
547// This reloads configuration from file into memory and triggers container sync.
548func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
549	if r.Method != http.MethodPost {
550		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
551		return
552	}
553
554	ctx := r.Context()
555	log := zerowrap.FromCtx(ctx)
556
557	// Check write permission (reload modifies state)
558	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
559		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
560		return
561	}
562
563	// Reload configuration from file into memory
564	if err := h.configSvc.Load(ctx); err != nil {
565		log.Error().Err(err).Msg("failed to reload config")
566		h.sendError(w, http.StatusInternalServerError, "failed to reload config")
567		return
568	}
569
570	// Publish manual reload event to sync containers
571	// ManualReloadHandler starts missing containers without restarting running ones
572	if h.eventBus != nil {
573		if err := h.eventBus.Publish(domain.EventManualReload, nil); err != nil {
574			log.Warn().Err(err).Msg("failed to publish manual reload event")
575		}
576	}
577
578	log.Info().Msg("config reloaded via admin API")
579	h.sendJSON(w, http.StatusOK, dto.ReloadResponse{Status: "reloaded"})
580}
581
582// handleConfig handles /admin/config endpoint.
583func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
584	if r.Method != http.MethodGet {
585		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
586		return
587	}
588
589	ctx := r.Context()
590
591	// Check read permission
592	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
593		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
594		return
595	}
596
597	routes := h.configSvc.GetRoutes(ctx)
598	routeResponses := make([]dto.Route, 0, len(routes))
599	for _, route := range routes {
600		routeResponses = append(routeResponses, toRouteResponse(route))
601	}
602
603	externalRoutes := h.configSvc.GetExternalRoutes()
604	externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))
605	for domain, target := range externalRoutes {
606		externalResponses = append(externalResponses, dto.ExternalRoute{
607			Domain: domain,
608			Target: target,
609		})
610	}
611
612	config := dto.ConfigResponse{
613		Server: dto.ServerConfig{
614			Port:           h.configSvc.GetServerPort(),
615			RegistryPort:   h.configSvc.GetRegistryPort(),
616			RegistryDomain: h.configSvc.GetRegistryDomain(),
617			DataDir:        h.configSvc.GetDataDir(),
618		},
619		AutoRoute: dto.AutoRouteConfig{
620			Enabled: h.configSvc.IsAutoRouteEnabled(),
621		},
622		NetworkIsolation: dto.NetworkIsolationConfig{
623			Enabled: h.configSvc.IsNetworkIsolationEnabled(),
624			Prefix:  h.configSvc.GetNetworkPrefix(),
625		},
626		Routes:         routeResponses,
627		ExternalRoutes: externalResponses,
628	}
629
630	h.sendJSON(w, http.StatusOK, config)
631}
632
633// handleDeploy handles /admin/deploy/:domain endpoint.
634// POST triggers a deployment for the specified domain.
635func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {
636	if r.Method != http.MethodPost {
637		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
638		return
639	}
640
641	ctx := r.Context()
642	log := zerowrap.FromCtx(ctx)
643
644	// Check write permission
645	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
646		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
647		return
648	}
649
650	// Parse domain from path
651	deployDomain := strings.TrimPrefix(path, "/deploy/")
652	if deployDomain == "" || deployDomain == "/deploy" {
653		h.sendError(w, http.StatusBadRequest, "domain required in path")
654		return
655	}
656
657	// Get the route for this domain
658	route, err := h.configSvc.GetRoute(ctx, deployDomain)
659	if err != nil {
660		h.sendError(w, http.StatusNotFound, "route not found")
661		return
662	}
663
664	// Deploy the container
665	container, err := h.containerSvc.Deploy(ctx, *route)
666	if err != nil {
667		log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
668		h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
669		return
670	}
671
672	log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
673	h.sendJSON(w, http.StatusOK, dto.DeployResponse{
674		Status:      "deployed",
675		ContainerID: container.ID,
676		Domain:      deployDomain,
677	})
678}
679
680// handleLogs handles /admin/logs endpoints.
681// GET /admin/logs - Gordon process logs
682// GET /admin/logs/:domain - Container logs for a specific domain
683func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
684	if r.Method != http.MethodGet {
685		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
686		return
687	}
688
689	ctx := r.Context()
690	log := zerowrap.FromCtx(ctx)
691
692	// Check read permission
693	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
694		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
695		return
696	}
697
698	// Check if LogService is available
699	if h.logSvc == nil {
700		h.sendError(w, http.StatusServiceUnavailable, "log service not available")
701		return
702	}
703
704	// Parse query parameters
705	lines := 50 // default
706	if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
707		if n, err := strconv.Atoi(linesStr); err == nil && n > 0 {
708			lines = n
709		}
710	}
711	if lines > maxLogLines {
712		lines = maxLogLines
713	}
714	follow := r.URL.Query().Get("follow") == "true"
715
716	// Parse domain from path
717	logDomain := strings.TrimPrefix(path, "/logs/")
718	if logDomain == "/logs" {
719		logDomain = ""
720	}
721
722	if logDomain == "" {
723		// Gordon process logs
724		h.handleProcessLogs(w, r, lines, follow)
725	} else {
726		// Container logs
727		h.handleContainerLogs(w, r, logDomain, lines, follow)
728	}
729
730	// Prevent unused variable warning when follow is implemented
731	_ = log
732}
733
734// handleProcessLogs handles Gordon process logs.
735func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) {
736	ctx := r.Context()
737	log := zerowrap.FromCtx(ctx)
738
739	if follow {
740		// SSE streaming
741		h.streamProcessLogs(w, r, lines)
742		return
743	}
744
745	// Return last N lines as JSON
746	logLines, err := h.logSvc.GetProcessLogs(ctx, lines)
747	if err != nil {
748		log.Warn().Err(err).Msg("failed to get process logs")
749		h.sendError(w, http.StatusInternalServerError, "failed to get logs")
750		return
751	}
752
753	h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines})
754}
755
756// handleContainerLogs handles container logs for a specific domain.
757func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) {
758	ctx := r.Context()
759	log := zerowrap.FromCtx(ctx)
760
761	if follow {
762		// SSE streaming
763		h.streamContainerLogs(w, r, logDomain, lines)
764		return
765	}
766
767	// Return last N lines as JSON
768	logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines)
769	if err != nil {
770		log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs")
771		h.sendError(w, http.StatusInternalServerError, fmt.Sprintf("failed to get logs: %v", err))
772		return
773	}
774
775	h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
776		Domain: logDomain,
777		Lines:  logLines,
778	})
779}
780
781// streamProcessLogs streams Gordon process logs via SSE.
782func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) {
783	ctx := r.Context()
784	log := zerowrap.FromCtx(ctx)
785
786	// Check for flusher support before setting up SSE
787	flusher, ok := w.(http.Flusher)
788	if !ok {
789		h.sendError(w, http.StatusInternalServerError, "streaming not supported")
790		return
791	}
792
793	// Set up SSE
794	w.Header().Set("Content-Type", "text/event-stream")
795	w.Header().Set("Cache-Control", "no-cache")
796	w.Header().Set("Connection", "keep-alive")
797	w.WriteHeader(http.StatusOK)
798
799	ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
800	if err != nil {
801		log.Warn().Err(err).Msg("failed to follow process logs")
802		_, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n")
803		flusher.Flush()
804		return
805	}
806
807	for {
808		select {
809		case line, ok := <-ch:
810			if !ok {
811				return
812			}
813			_, _ = fmt.Fprintf(w, "data: %s\n\n", line)
814			flusher.Flush()
815		case <-ctx.Done():
816			return
817		}
818	}
819}
820
821// streamContainerLogs streams container logs via SSE.
822func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) {
823	ctx := r.Context()
824	log := zerowrap.FromCtx(ctx)
825
826	// Check for flusher support before setting up SSE
827	flusher, ok := w.(http.Flusher)
828	if !ok {
829		h.sendError(w, http.StatusInternalServerError, "streaming not supported")
830		return
831	}
832
833	// Set up SSE
834	w.Header().Set("Content-Type", "text/event-stream")
835	w.Header().Set("Cache-Control", "no-cache")
836	w.Header().Set("Connection", "keep-alive")
837	w.WriteHeader(http.StatusOK)
838
839	ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
840	if err != nil {
841		log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs")
842		_, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n")
843		flusher.Flush()
844		return
845	}
846
847	for {
848		select {
849		case line, ok := <-ch:
850			if !ok {
851				return
852			}
853			_, _ = fmt.Fprintf(w, "data: %s\n\n", line)
854			flusher.Flush()
855		case <-ctx.Done():
856			return
857		}
858	}
859}

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