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}