v2.7.0 fac7705

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	if handler, ok := h.matchRoute(path); ok {
126		handler(w, r, path)
127		return
128	}
129	h.sendError(w, http.StatusNotFound, "route not found")
130}
131
132// routeHandler is the signature for path-based route handlers.
133type routeHandler func(w http.ResponseWriter, r *http.Request, path string)
134
135// matchRoute returns the handler for a given path, or false if not found.
136func (h *Handler) matchRoute(path string) (routeHandler, bool) {
137	// Exact match routes
138	exactRoutes := map[string]routeHandler{
139		"/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
140		"/status":   func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
141		"/health":   func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
142		"/reload":   func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
143		"/config":   func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
144	}
145	if handler, ok := exactRoutes[path]; ok {
146		return handler, true
147	}
148
149	// Prefix match routes
150	prefixRoutes := []struct {
151		prefix  string
152		handler routeHandler
153	}{
154		{"/attachments", h.handleAttachmentsConfig},
155		{"/routes", h.handleRoutes},
156		{"/secrets", h.handleSecrets},
157		{"/deploy", h.handleDeploy},
158		{"/logs", h.handleLogs},
159	}
160	for _, route := range prefixRoutes {
161		if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") {
162			return route.handler, true
163		}
164	}
165
166	return nil, false
167}
168
169// sendJSON sends a JSON response.
170func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
171	w.Header().Set("Content-Type", "application/json")
172	w.WriteHeader(status)
173	if data != nil {
174		_ = json.NewEncoder(w).Encode(data)
175	}
176}
177
178// sendError sends an error response.
179func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
180	h.sendJSON(w, status, dto.ErrorResponse{Error: message})
181}
182
183// handleRoutes handles /admin/routes endpoints.
184func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path string) {
185	// Parse domain from path if present
186	routeDomain := strings.TrimPrefix(path, "/routes/")
187	if routeDomain == "/routes" {
188		routeDomain = ""
189	}
190
191	switch r.Method {
192	case http.MethodGet:
193		h.handleRoutesGet(w, r, routeDomain)
194	case http.MethodPost:
195		h.handleRoutesPost(w, r)
196	case http.MethodPut:
197		h.handleRoutesPut(w, r, routeDomain)
198	case http.MethodDelete:
199		h.handleRoutesDelete(w, r, routeDomain)
200	default:
201		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
202	}
203}
204
205func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeDomain string) {
206	ctx := r.Context()
207
208	// Check read permission
209	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
210		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
211		return
212	}
213
214	if routeDomain == "" {
215		if r.URL.Query().Get("detailed") == "true" {
216			routes := h.containerSvc.ListRoutesWithDetails(ctx)
217			response := make([]routeInfoResponse, 0, len(routes))
218			for _, route := range routes {
219				response = append(response, toRouteInfoResponse(route))
220			}
221			h.sendJSON(w, http.StatusOK, dto.RoutesDetailResponse{Routes: response})
222			return
223		}
224
225		routes := h.configSvc.GetRoutes(ctx)
226		response := make([]routeResponse, 0, len(routes))
227		for _, route := range routes {
228			response = append(response, toRouteResponse(route))
229		}
230		h.sendJSON(w, http.StatusOK, dto.RoutesResponse{Routes: response})
231		return
232	}
233
234	if strings.HasSuffix(routeDomain, "/attachments") {
235		parentDomain := strings.TrimSuffix(routeDomain, "/attachments")
236		if parentDomain == "" {
237			h.sendError(w, http.StatusBadRequest, "domain required in path")
238			return
239		}
240		attachments := h.containerSvc.ListAttachments(ctx, parentDomain)
241		response := make([]attachmentResponse, 0, len(attachments))
242		for _, attachment := range attachments {
243			response = append(response, toAttachmentResponse(attachment))
244		}
245		h.sendJSON(w, http.StatusOK, dto.AttachmentsResponse{Attachments: response})
246		return
247	}
248
249	route, err := h.configSvc.GetRoute(ctx, routeDomain)
250	if err != nil {
251		h.sendError(w, http.StatusNotFound, "route not found")
252		return
253	}
254	h.sendJSON(w, http.StatusOK, toRouteResponse(*route))
255}
256
257func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
258	ctx := r.Context()
259	log := zerowrap.FromCtx(ctx)
260
261	// Check write permission
262	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
263		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
264		return
265	}
266
267	// Limit request body size
268	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
269
270	var route domain.Route
271	if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
272		log.Warn().Err(err).Msg("invalid route JSON")
273		h.sendError(w, http.StatusBadRequest, "invalid JSON")
274		return
275	}
276
277	if err := h.configSvc.AddRoute(ctx, route); err != nil {
278		log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
279		switch {
280		case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
281			h.sendError(w, http.StatusBadRequest, err.Error())
282		default:
283			h.sendError(w, http.StatusInternalServerError, "failed to add route")
284		}
285		return
286	}
287
288	log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added")
289	h.sendJSON(w, http.StatusCreated, toRouteResponse(route))
290}
291
292func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) {
293	ctx := r.Context()
294	log := zerowrap.FromCtx(ctx)
295
296	// Check write permission
297	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
298		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
299		return
300	}
301
302	if routeDomain == "" {
303		h.sendError(w, http.StatusBadRequest, "domain required in path")
304		return
305	}
306
307	// Limit request body size
308	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
309
310	var route domain.Route
311	if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
312		log.Warn().Err(err).Msg("invalid route JSON")
313		h.sendError(w, http.StatusBadRequest, "invalid JSON")
314		return
315	}
316
317	route.Domain = routeDomain
318
319	if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
320		log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
321		switch {
322		case errors.Is(err, domain.ErrRouteNotFound):
323			h.sendError(w, http.StatusNotFound, "route not found")
324		case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
325			h.sendError(w, http.StatusBadRequest, err.Error())
326		default:
327			h.sendError(w, http.StatusInternalServerError, "failed to update route")
328		}
329		return
330	}
331
332	log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated")
333	h.sendJSON(w, http.StatusOK, toRouteResponse(route))
334}
335
336func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) {
337	ctx := r.Context()
338	log := zerowrap.FromCtx(ctx)
339
340	// Check write permission
341	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
342		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
343		return
344	}
345
346	if routeDomain == "" {
347		h.sendError(w, http.StatusBadRequest, "domain required in path")
348		return
349	}
350
351	if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
352		log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
353		if errors.Is(err, domain.ErrRouteNotFound) {
354			h.sendError(w, http.StatusNotFound, "route not found")
355		} else {
356			h.sendError(w, http.StatusInternalServerError, "failed to remove route")
357		}
358		return
359	}
360
361	log.Info().Str("domain", routeDomain).Msg("route removed")
362	h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"})
363}
364
365func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
366	if r.Method != http.MethodGet {
367		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
368		return
369	}
370
371	ctx := r.Context()
372
373	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
374		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
375		return
376	}
377
378	networks, err := h.containerSvc.ListNetworks(ctx)
379	if err != nil {
380		h.sendError(w, http.StatusInternalServerError, "failed to list networks")
381		return
382	}
383
384	response := make([]dto.Network, 0, len(networks))
385	for _, network := range networks {
386		if network == nil {
387			continue
388		}
389		var labelsCopy map[string]string
390		if network.Labels != nil {
391			labelsCopy = make(map[string]string, len(network.Labels))
392			for key, value := range network.Labels {
393				labelsCopy[key] = value
394			}
395		}
396		response = append(response, dto.Network{
397			ID:         network.ID,
398			Name:       network.Name,
399			Driver:     network.Driver,
400			Containers: append([]string{}, network.Containers...),
401			Labels:     labelsCopy,
402		})
403	}
404
405	h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response})
406}
407
408// handleSecrets handles /admin/secrets endpoints.
409func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
410	ctx := r.Context()
411	log := zerowrap.FromCtx(ctx)
412
413	// Parse path: /secrets/{domain} or /secrets/{domain}/{key}
414	parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")
415	if len(parts) == 0 || parts[0] == "" {
416		h.sendError(w, http.StatusBadRequest, "domain required")
417		return
418	}
419
420	secretDomain := parts[0]
421	secretKey := ""
422	if len(parts) > 1 {
423		secretKey = parts[1]
424	}
425
426	switch r.Method {
427	case http.MethodGet:
428		h.handleSecretsGet(w, r, secretDomain)
429
430	case http.MethodPost:
431		// Check write permission
432		if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
433			h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
434			return
435		}
436
437		// Limit request body size
438		r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
439
440		// Set secret(s)
441		var data map[string]string
442		if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
443			log.Warn().Err(err).Msg("invalid secrets JSON")
444			h.sendError(w, http.StatusBadRequest, "invalid JSON")
445			return
446		}
447
448		if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
449			log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
450			h.sendError(w, http.StatusBadRequest, "invalid domain")
451			return
452		}
453
454		log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
455		h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
456
457	case http.MethodDelete:
458		// Check write permission
459		if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
460			h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
461			return
462		}
463		if secretKey == "" {
464			h.sendError(w, http.StatusBadRequest, "key required in path")
465			return
466		}
467
468		if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
469			log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
470			h.sendError(w, http.StatusBadRequest, "invalid domain")
471			return
472		}
473
474		log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
475		h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
476
477	default:
478		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
479	}
480}
481
482// handleSecretsGet handles GET /admin/secrets/{domain} - list secrets.
483func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secretDomain string) {
484	ctx := r.Context()
485	log := zerowrap.FromCtx(ctx)
486
487	// Check read permission
488	if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
489		h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
490		return
491	}
492
493	// List secrets for domain (names only, not values) including attachments
494	keys, attachments, err := h.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
495	if err != nil {
496		log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
497		h.sendError(w, http.StatusBadRequest, "invalid domain")
498		return
499	}
500
501	// Convert attachments to DTO format
502	var attachmentDTOs []dto.AttachmentSecretsResponse
503	for _, att := range attachments {
504		attachmentDTOs = append(attachmentDTOs, dto.AttachmentSecretsResponse{
505			Service: att.Service,
506			Keys:    att.Keys,
507		})
508	}
509
510	h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{
511		Domain:      secretDomain,
512		Keys:        keys,
513		Attachments: attachmentDTOs,
514	})
515}
516
517// handleHealth handles /admin/health endpoint.
518// Returns detailed health status for all routes including HTTP probe results.
519func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
520	if r.Method != http.MethodGet {
521		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
522		return
523	}
524
525	ctx := r.Context()
526
527	// Check read permission
528	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
529		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
530		return
531	}
532
533	health := h.healthSvc.CheckAllRoutes(ctx)
534
535	response := make(map[string]dto.HealthStatus, len(health))
536	for domain, healthStatus := range health {
537		if healthStatus == nil {
538			continue
539		}
540		response[domain] = dto.HealthStatus{
541			ContainerStatus: healthStatus.ContainerStatus,
542			HTTPStatus:      healthStatus.HTTPStatus,
543			ResponseTimeMs:  healthStatus.ResponseTimeMs,
544			Healthy:         healthStatus.Healthy,
545			Error:           healthStatus.Error,
546		}
547	}
548
549	h.sendJSON(w, http.StatusOK, dto.HealthResponse{Health: response})
550}
551
552// handleStatus handles /admin/status endpoint.
553func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
554	if r.Method != http.MethodGet {
555		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
556		return
557	}
558
559	ctx := r.Context()
560
561	// Check read permission
562	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
563		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
564		return
565	}
566
567	routes := h.configSvc.GetRoutes(ctx)
568
569	// Get container statuses
570	statuses := make(map[string]string)
571	for _, route := range routes {
572		status := "unknown"
573		container, ok := h.containerSvc.Get(ctx, route.Domain)
574		if ok && container != nil {
575			status = container.Status
576		}
577		statuses[route.Domain] = status
578	}
579
580	status := dto.StatusResponse{
581		Routes:            len(routes),
582		RegistryDomain:    h.configSvc.GetRegistryDomain(),
583		RegistryPort:      h.configSvc.GetRegistryPort(),
584		ServerPort:        h.configSvc.GetServerPort(),
585		AutoRoute:         h.configSvc.IsAutoRouteEnabled(),
586		NetworkIsolation:  h.configSvc.IsNetworkIsolationEnabled(),
587		ContainerStatuses: statuses,
588	}
589
590	h.sendJSON(w, http.StatusOK, status)
591}
592
593// handleReload handles /admin/reload endpoint.
594// This reloads configuration from file into memory and triggers container sync.
595func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
596	if r.Method != http.MethodPost {
597		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
598		return
599	}
600
601	ctx := r.Context()
602	log := zerowrap.FromCtx(ctx)
603
604	// Check write permission (reload modifies state)
605	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
606		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
607		return
608	}
609
610	// Reload configuration from file into memory
611	// Using Reload() instead of Load() to ensure we re-read the file from disk
612	if err := h.configSvc.Reload(ctx); err != nil {
613		log.Error().Err(err).Msg("failed to reload config")
614		h.sendError(w, http.StatusInternalServerError, "failed to reload config")
615		return
616	}
617
618	// Publish manual reload event to sync containers
619	// ManualReloadHandler starts missing containers without restarting running ones
620	if h.eventBus != nil {
621		if err := h.eventBus.Publish(domain.EventManualReload, nil); err != nil {
622			log.Warn().Err(err).Msg("failed to publish manual reload event")
623		}
624	}
625
626	log.Info().Msg("config reloaded via admin API")
627	h.sendJSON(w, http.StatusOK, dto.ReloadResponse{Status: "reloaded"})
628}
629
630// handleConfig handles /admin/config endpoint.
631func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
632	if r.Method != http.MethodGet {
633		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
634		return
635	}
636
637	ctx := r.Context()
638
639	// Check read permission
640	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
641		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
642		return
643	}
644
645	routes := h.configSvc.GetRoutes(ctx)
646	routeResponses := make([]dto.Route, 0, len(routes))
647	for _, route := range routes {
648		routeResponses = append(routeResponses, toRouteResponse(route))
649	}
650
651	externalRoutes := h.configSvc.GetExternalRoutes()
652	externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))
653	for domain, target := range externalRoutes {
654		externalResponses = append(externalResponses, dto.ExternalRoute{
655			Domain: domain,
656			Target: target,
657		})
658	}
659
660	config := dto.ConfigResponse{
661		Server: dto.ServerConfig{
662			Port:           h.configSvc.GetServerPort(),
663			RegistryPort:   h.configSvc.GetRegistryPort(),
664			RegistryDomain: h.configSvc.GetRegistryDomain(),
665			DataDir:        h.configSvc.GetDataDir(),
666		},
667		AutoRoute: dto.AutoRouteConfig{
668			Enabled: h.configSvc.IsAutoRouteEnabled(),
669		},
670		NetworkIsolation: dto.NetworkIsolationConfig{
671			Enabled: h.configSvc.IsNetworkIsolationEnabled(),
672			Prefix:  h.configSvc.GetNetworkPrefix(),
673		},
674		Routes:         routeResponses,
675		ExternalRoutes: externalResponses,
676	}
677
678	h.sendJSON(w, http.StatusOK, config)
679}
680
681// handleDeploy handles /admin/deploy/:domain endpoint.
682// POST triggers a deployment for the specified domain.
683func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {
684	if r.Method != http.MethodPost {
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 write permission
693	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
694		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
695		return
696	}
697
698	// Parse domain from path
699	deployDomain := strings.TrimPrefix(path, "/deploy/")
700	if deployDomain == "" || deployDomain == "/deploy" {
701		h.sendError(w, http.StatusBadRequest, "domain required in path")
702		return
703	}
704
705	// Get the route for this domain
706	route, err := h.configSvc.GetRoute(ctx, deployDomain)
707	if err != nil {
708		h.sendError(w, http.StatusNotFound, "route not found")
709		return
710	}
711
712	// Deploy the container
713	container, err := h.containerSvc.Deploy(ctx, *route)
714	if err != nil {
715		log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
716		h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
717		return
718	}
719
720	log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
721	h.sendJSON(w, http.StatusOK, dto.DeployResponse{
722		Status:      "deployed",
723		ContainerID: container.ID,
724		Domain:      deployDomain,
725	})
726}
727
728// handleLogs handles /admin/logs endpoints.
729// GET /admin/logs - Gordon process logs
730// GET /admin/logs/:domain - Container logs for a specific domain
731func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
732	if r.Method != http.MethodGet {
733		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
734		return
735	}
736
737	ctx := r.Context()
738	log := zerowrap.FromCtx(ctx)
739
740	// Check read permission
741	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
742		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
743		return
744	}
745
746	// Check if LogService is available
747	if h.logSvc == nil {
748		h.sendError(w, http.StatusServiceUnavailable, "log service not available")
749		return
750	}
751
752	// Parse query parameters
753	lines := 50 // default
754	if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
755		if n, err := strconv.Atoi(linesStr); err == nil && n > 0 {
756			lines = n
757		}
758	}
759	if lines > maxLogLines {
760		lines = maxLogLines
761	}
762	follow := r.URL.Query().Get("follow") == "true"
763
764	// Parse domain from path
765	logDomain := strings.TrimPrefix(path, "/logs/")
766	if logDomain == "/logs" {
767		logDomain = ""
768	}
769
770	if logDomain == "" {
771		// Gordon process logs
772		h.handleProcessLogs(w, r, lines, follow)
773	} else {
774		// Container logs
775		h.handleContainerLogs(w, r, logDomain, lines, follow)
776	}
777
778	// Prevent unused variable warning when follow is implemented
779	_ = log
780}
781
782// handleProcessLogs handles Gordon process logs.
783func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) {
784	ctx := r.Context()
785	log := zerowrap.FromCtx(ctx)
786
787	if follow {
788		// SSE streaming
789		h.streamProcessLogs(w, r, lines)
790		return
791	}
792
793	// Return last N lines as JSON
794	logLines, err := h.logSvc.GetProcessLogs(ctx, lines)
795	if err != nil {
796		log.Warn().Err(err).Msg("failed to get process logs")
797		h.sendError(w, http.StatusInternalServerError, "failed to get logs")
798		return
799	}
800
801	h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines})
802}
803
804// handleContainerLogs handles container logs for a specific domain.
805func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) {
806	ctx := r.Context()
807	log := zerowrap.FromCtx(ctx)
808
809	if follow {
810		// SSE streaming
811		h.streamContainerLogs(w, r, logDomain, lines)
812		return
813	}
814
815	// Return last N lines as JSON
816	logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines)
817	if err != nil {
818		log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs")
819		h.sendError(w, http.StatusInternalServerError, fmt.Sprintf("failed to get logs: %v", err))
820		return
821	}
822
823	h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
824		Domain: logDomain,
825		Lines:  logLines,
826	})
827}
828
829// streamProcessLogs streams Gordon process logs via SSE.
830func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) {
831	ctx := r.Context()
832	log := zerowrap.FromCtx(ctx)
833
834	// Check for flusher support before setting up SSE
835	flusher, ok := w.(http.Flusher)
836	if !ok {
837		h.sendError(w, http.StatusInternalServerError, "streaming not supported")
838		return
839	}
840
841	// Set up SSE
842	w.Header().Set("Content-Type", "text/event-stream")
843	w.Header().Set("Cache-Control", "no-cache")
844	w.Header().Set("Connection", "keep-alive")
845	w.WriteHeader(http.StatusOK)
846
847	ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
848	if err != nil {
849		log.Warn().Err(err).Msg("failed to follow process logs")
850		_, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n")
851		flusher.Flush()
852		return
853	}
854
855	for {
856		select {
857		case line, ok := <-ch:
858			if !ok {
859				return
860			}
861			_, _ = fmt.Fprintf(w, "data: %s\n\n", line)
862			flusher.Flush()
863		case <-ctx.Done():
864			return
865		}
866	}
867}
868
869// streamContainerLogs streams container logs via SSE.
870func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) {
871	ctx := r.Context()
872	log := zerowrap.FromCtx(ctx)
873
874	// Check for flusher support before setting up SSE
875	flusher, ok := w.(http.Flusher)
876	if !ok {
877		h.sendError(w, http.StatusInternalServerError, "streaming not supported")
878		return
879	}
880
881	// Set up SSE
882	w.Header().Set("Content-Type", "text/event-stream")
883	w.Header().Set("Cache-Control", "no-cache")
884	w.Header().Set("Connection", "keep-alive")
885	w.WriteHeader(http.StatusOK)
886
887	ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
888	if err != nil {
889		log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs")
890		_, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n")
891		flusher.Flush()
892		return
893	}
894
895	for {
896		select {
897		case line, ok := <-ch:
898			if !ok {
899				return
900			}
901			_, _ = fmt.Fprintf(w, "data: %s\n\n", line)
902			flusher.Flush()
903		case <-ctx.Done():
904			return
905		}
906	}
907}
908
909// handleAttachmentsConfig handles /admin/attachments endpoints for config-level attachments.
910func (h *Handler) handleAttachmentsConfig(w http.ResponseWriter, r *http.Request, path string) {
911	// Parse target (domain or group) from path
912	target := strings.TrimPrefix(path, "/attachments/")
913	if target == "/attachments" {
914		target = ""
915	}
916
917	switch r.Method {
918	case http.MethodGet:
919		h.handleAttachmentsConfigGet(w, r, target)
920	case http.MethodPost:
921		h.handleAttachmentsConfigPost(w, r, target)
922	case http.MethodDelete:
923		h.handleAttachmentsConfigDelete(w, r, target)
924	default:
925		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
926	}
927}
928
929func (h *Handler) handleAttachmentsConfigGet(w http.ResponseWriter, r *http.Request, target string) {
930	ctx := r.Context()
931
932	// Check read permission
933	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
934		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
935		return
936	}
937
938	if target == "" {
939		// List all attachments
940		attachments := h.configSvc.GetAllAttachments(ctx)
941		h.sendJSON(w, http.StatusOK, dto.AttachmentsConfigResponse{Attachments: attachments})
942		return
943	}
944
945	// List attachments for specific target
946	images, err := h.configSvc.GetAttachmentsFor(ctx, target)
947	if err != nil {
948		if errors.Is(err, domain.ErrAttachmentNotFound) {
949			h.sendError(w, http.StatusNotFound, "no attachments found for target")
950			return
951		}
952		h.sendError(w, http.StatusInternalServerError, "failed to get attachments")
953		return
954	}
955
956	h.sendJSON(w, http.StatusOK, dto.AttachmentConfigResponse{Target: target, Images: images})
957}
958
959func (h *Handler) handleAttachmentsConfigPost(w http.ResponseWriter, r *http.Request, target string) {
960	ctx := r.Context()
961	log := zerowrap.FromCtx(ctx)
962
963	// Check write permission
964	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
965		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
966		return
967	}
968
969	if target == "" {
970		h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
971		return
972	}
973
974	// Limit request body size
975	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
976
977	var req dto.AttachmentAddRequest
978	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
979		log.Warn().Err(err).Msg("invalid attachment JSON")
980		h.sendError(w, http.StatusBadRequest, "invalid JSON")
981		return
982	}
983
984	if err := h.configSvc.AddAttachment(ctx, target, req.Image); err != nil {
985		log.Error().Err(err).Str("target", target).Str("image", req.Image).Msg("failed to add attachment")
986		switch {
987		case errors.Is(err, domain.ErrAttachmentExists):
988			h.sendError(w, http.StatusConflict, "attachment already exists")
989		case errors.Is(err, domain.ErrAttachmentImageEmpty):
990			h.sendError(w, http.StatusBadRequest, err.Error())
991		case errors.Is(err, domain.ErrAttachmentTargetEmpty):
992			h.sendError(w, http.StatusBadRequest, err.Error())
993		default:
994			h.sendError(w, http.StatusInternalServerError, "failed to add attachment")
995		}
996		return
997	}
998
999	log.Info().Str("target", target).Str("image", req.Image).Msg("attachment added")
1000	h.sendJSON(w, http.StatusCreated, dto.AttachmentStatusResponse{Status: "added"})
1001}
1002
1003func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.Request, target string) {
1004	ctx := r.Context()
1005	log := zerowrap.FromCtx(ctx)
1006
1007	// Check write permission
1008	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1009		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1010		return
1011	}
1012
1013	if target == "" {
1014		h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
1015		return
1016	}
1017
1018	// Parse image from path: /attachments/{target}/{image}
1019	// target at this point contains "{domain}/{image}" or just "{domain}"
1020	parts := strings.SplitN(target, "/", 2)
1021	if len(parts) != 2 {
1022		h.sendError(w, http.StatusBadRequest, "image required in path: /attachments/{target}/{image}")
1023		return
1024	}
1025
1026	domainOrGroup := parts[0]
1027	image := parts[1]
1028
1029	if err := h.configSvc.RemoveAttachment(ctx, domainOrGroup, image); err != nil {
1030		log.Error().Err(err).Str("target", domainOrGroup).Str("image", image).Msg("failed to remove attachment")
1031		switch {
1032		case errors.Is(err, domain.ErrAttachmentNotFound):
1033			h.sendError(w, http.StatusNotFound, "attachment not found")
1034		case errors.Is(err, domain.ErrAttachmentImageEmpty):
1035			h.sendError(w, http.StatusBadRequest, err.Error())
1036		case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1037			h.sendError(w, http.StatusBadRequest, err.Error())
1038		default:
1039			h.sendError(w, http.StatusInternalServerError, "failed to remove attachment")
1040		}
1041		return
1042	}
1043
1044	log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed")
1045	h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
1046}

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