detached ff90e61

Merge pull request #29 from bnema/feat/routes-list-network-attachments

feat: expose route network and attachments in API
commit
ff90e61911a42cbeed808c5240a70759cb2a81bc
author
bnema · · 2026-01-17 13:39 UTC
committer
GitHub · · 2026-01-17 13:39 UTC
merge of
d96ffdd + 1a3e1ca · diff against the first parent
signature
unsigned
14 files · +915 -32
A internal/adapters/dto/route.go +20 -0
@@ -0,0 +1,20 @@
1+// Package dto provides shared data transfer objects for API responses.
2+package dto
3+
4+// RouteInfo represents route details in API responses.
5+type RouteInfo struct {
6+ Domain string `json:"domain"`
7+ Image string `json:"image"`
8+ ContainerID string `json:"container_id"`
9+ ContainerStatus string `json:"container_status"`
10+ Network string `json:"network"`
11+ Attachments []Attachment `json:"attachments"`
12+}
13+
14+// Attachment represents an attached service in API responses.
15+type Attachment struct {
16+ Name string `json:"name"`
17+ Image string `json:"image"`
18+ ContainerID string `json:"container_id"`
19+ Status string `json:"status"`
20+}
@@ -0,0 +1,20 @@
1+// Package dto provides shared data transfer objects for API responses.
2+package dto
3+
4+// RouteInfo represents route details in API responses.
5+type RouteInfo struct {
6+ Domain string `json:"domain"`
7+ Image string `json:"image"`
8+ ContainerID string `json:"container_id"`
9+ ContainerStatus string `json:"container_status"`
10+ Network string `json:"network"`
11+ Attachments []Attachment `json:"attachments"`
12+}
13+
14+// Attachment represents an attached service in API responses.
15+type Attachment struct {
16+ Name string `json:"name"`
17+ Image string `json:"image"`
18+ ContainerID string `json:"container_id"`
19+ Status string `json:"status"`
20+}
M internal/adapters/in/cli/remote/client.go +60 -0
@@ -12,6 +12,7 @@ import (
1212 "strings"
1313 "time"
1414
15+ "gordon/internal/adapters/dto"
1516 "gordon/internal/domain"
1617 )
1718
@@ -119,6 +120,10 @@ func parseResponse(resp *http.Response, target any) error {
119120
120121 // Routes API
121122
123+// Type aliases for API types using shared DTO package.
124+type RouteInfo = dto.RouteInfo
125+type Attachment = dto.Attachment
126+
122127 // ListRoutes returns all configured routes.
123128 func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
124129 resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
@@ -136,6 +141,61 @@ func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
136141 return result.Routes, nil
137142 }
138143
144+// ListRoutesWithDetails returns routes with network and attachment info.
145+func (c *Client) ListRoutesWithDetails(ctx context.Context) ([]RouteInfo, error) {
146+ resp, err := c.request(ctx, http.MethodGet, "/routes?detailed=true", nil)
147+ if err != nil {
148+ return nil, err
149+ }
150+
151+ var result struct {
152+ Routes []RouteInfo `json:"routes"`
153+ }
154+ if err := parseResponse(resp, &result); err != nil {
155+ return nil, err
156+ }
157+
158+ return result.Routes, nil
159+}
160+
161+// ListNetworks returns Gordon-managed networks.
162+func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
163+ resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
164+ if err != nil {
165+ return nil, err
166+ }
167+
168+ var result struct {
169+ Networks []*domain.NetworkInfo `json:"networks"`
170+ }
171+ if err := parseResponse(resp, &result); err != nil {
172+ return nil, err
173+ }
174+
175+ return result.Networks, nil
176+}
177+
178+// ListAttachments returns attachments for a domain.
179+func (c *Client) ListAttachments(ctx context.Context, routeDomain string) ([]Attachment, error) {
180+ if routeDomain == "" {
181+ return nil, fmt.Errorf("domain is required")
182+ }
183+ path := "/routes/" + url.PathEscape(routeDomain) + "/attachments"
184+ resp, err := c.request(ctx, http.MethodGet, path, nil)
185+ if err != nil {
186+ return nil, err
187+ }
188+
189+ var result struct {
190+ Attachments []Attachment `json:"attachments"`
191+ }
192+ if err := parseResponse(resp, &result); err != nil {
193+ return nil, err
194+ }
195+
196+ return result.Attachments, nil
197+}
198+
139199 // GetRoute returns a specific route by domain.
140200 func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
141201 resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
@@ -12,6 +12,7 @@ import (
12 "strings"12 "strings"
13 "time"13 "time"
14 14
15+ "gordon/internal/adapters/dto"
15 "gordon/internal/domain"16 "gordon/internal/domain"
16 )17 )
17 18
@@ -119,6 +120,10 @@ func parseResponse(resp *http.Response, target any) error {
119 120
120 // Routes API121 // Routes API
121 122
123+// Type aliases for API types using shared DTO package.
124+type RouteInfo = dto.RouteInfo
125+type Attachment = dto.Attachment
126+
122 // ListRoutes returns all configured routes.127 // ListRoutes returns all configured routes.
123 func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {128 func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
124 resp, err := c.request(ctx, http.MethodGet, "/routes", nil)129 resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
@@ -136,6 +141,61 @@ func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
136 return result.Routes, nil141 return result.Routes, nil
137 }142 }
138 143
144+// ListRoutesWithDetails returns routes with network and attachment info.
145+func (c *Client) ListRoutesWithDetails(ctx context.Context) ([]RouteInfo, error) {
146+ resp, err := c.request(ctx, http.MethodGet, "/routes?detailed=true", nil)
147+ if err != nil {
148+ return nil, err
149+ }
150+
151+ var result struct {
152+ Routes []RouteInfo `json:"routes"`
153+ }
154+ if err := parseResponse(resp, &result); err != nil {
155+ return nil, err
156+ }
157+
158+ return result.Routes, nil
159+}
160+
161+// ListNetworks returns Gordon-managed networks.
162+func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
163+ resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
164+ if err != nil {
165+ return nil, err
166+ }
167+
168+ var result struct {
169+ Networks []*domain.NetworkInfo `json:"networks"`
170+ }
171+ if err := parseResponse(resp, &result); err != nil {
172+ return nil, err
173+ }
174+
175+ return result.Networks, nil
176+}
177+
178+// ListAttachments returns attachments for a domain.
179+func (c *Client) ListAttachments(ctx context.Context, routeDomain string) ([]Attachment, error) {
180+ if routeDomain == "" {
181+ return nil, fmt.Errorf("domain is required")
182+ }
183+ path := "/routes/" + url.PathEscape(routeDomain) + "/attachments"
184+ resp, err := c.request(ctx, http.MethodGet, path, nil)
185+ if err != nil {
186+ return nil, err
187+ }
188+
189+ var result struct {
190+ Attachments []Attachment `json:"attachments"`
191+ }
192+ if err := parseResponse(resp, &result); err != nil {
193+ return nil, err
194+ }
195+
196+ return result.Attachments, nil
197+}
198+
139 // GetRoute returns a specific route by domain.199 // GetRoute returns a specific route by domain.
140 func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {200 func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
141 resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)201 resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
M internal/adapters/in/cli/routes.go +50 -15
@@ -29,25 +29,40 @@ func truncateImage(image string, maxLen int) string {
2929 if len(digest) > 12 {
3030 digest = digest[:12]
3131 }
32- short := fmt.Sprintf("%s@sha256:%s…", name, digest)
32+ short := fmt.Sprintf("%s@sha256:%s", name, digest)
3333 if len(short) > maxLen {
34- // Truncate name if still too long
35- available := maxLen - len("@sha256:") - 12 - 1 // 1 for ellipsis
36- if available > 3 {
37- name = name[:available-1] + "…"
34+ // Truncate from the end to maintain valid reference format
35+ if maxLen <= 3 {
36+ return short[:maxLen]
3837 }
39- short = fmt.Sprintf("%s@sha256:%s…", name, digest)
38+ return short[:maxLen-3] + "..."
4039 }
4140 return short
4241 }
4342
4443 // Regular tag: truncate if needed
4544 if len(image) > maxLen {
46- return image[:maxLen-1] + "…"
45+ if maxLen <= 3 {
46+ return image[:maxLen]
47+ }
48+ return image[:maxLen-3] + "..."
4749 }
4850 return image
4951 }
5052
53+func truncateNetwork(network string, maxLen int) string {
54+ if network == "" || network == "-" {
55+ return "-"
56+ }
57+ if len(network) > maxLen {
58+ if maxLen <= 3 {
59+ return network[:maxLen]
60+ }
61+ return network[:maxLen-3] + "..."
62+ }
63+ return network
64+}
65+
5166 // newRoutesCmd creates the routes command group.
5267 func newRoutesCmd() *cobra.Command {
5368 cmd := &cobra.Command{
@@ -107,7 +122,7 @@ func newRoutesListCmd() *cobra.Command {
107122
108123 // runRoutesListRemote lists routes from a remote Gordon instance.
109124 func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
110- routes, err := client.ListRoutes(ctx)
125+ routes, err := client.ListRoutesWithDetails(ctx)
111126 if err != nil {
112127 return fmt.Errorf("failed to list routes: %w", err)
113128 }
@@ -123,16 +138,20 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
123138 health = make(map[string]*remote.RouteHealth)
124139 }
125140
126- // Build table rows
127141 const imageColWidth = 35
128- rows := make([][]string, len(routes))
129- for i, route := range routes {
142+ const networkColWidth = 22
143+ rows := make([][]string, 0, len(routes))
144+ for _, route := range routes {
130145 routeHealth := health[route.Domain]
131146
132147 // Container status column
133- containerStatus := "unknown"
134- if routeHealth != nil {
135- containerStatus = routeHealth.ContainerStatus
148+ containerStatus := route.ContainerStatus
149+ if containerStatus == "" {
150+ if routeHealth != nil {
151+ containerStatus = routeHealth.ContainerStatus
152+ } else {
153+ containerStatus = "unknown"
154+ }
136155 }
137156 containerBadge := components.ContainerStatusBadge(containerStatus)
138157
@@ -140,7 +159,22 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
140159 httpStatus := formatHTTPStatus(routeHealth)
141160
142161 displayImage := truncateImage(route.Image, imageColWidth)
143- rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}
162+ displayNetwork := truncateNetwork(route.Network, networkColWidth)
163+ rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})
164+
165+ for i, attachment := range route.Attachments {
166+ prefix := "|-"
167+ if i == len(route.Attachments)-1 {
168+ prefix = "`-"
169+ }
170+ attachmentName := prefix + " " + attachment.Name
171+ if len(attachmentName) > 25 {
172+ attachmentName = attachmentName[:22] + "..."
173+ }
174+ attachmentStatus := components.ContainerStatusBadge(attachment.Status)
175+ attachmentImage := truncateImage(attachment.Image, imageColWidth)
176+ rows = append(rows, []string{attachmentName, attachmentImage, "-", attachmentStatus, "-"})
177+ }
144178 }
145179
146180 // Render table
@@ -148,6 +182,7 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
148182 components.WithColumns([]components.TableColumn{
149183 {Title: "Domain", Width: 25},
150184 {Title: "Image", Width: imageColWidth},
185+ {Title: "Network", Width: networkColWidth},
151186 {Title: "Container", Width: 12},
152187 {Title: "HTTP", Width: 14},
153188 }),
@@ -29,25 +29,40 @@ func truncateImage(image string, maxLen int) string {
29 if len(digest) > 12 {29 if len(digest) > 12 {
30 digest = digest[:12]30 digest = digest[:12]
31 }31 }
32- short := fmt.Sprintf("%s@sha256:%s…", name, digest)32+ short := fmt.Sprintf("%s@sha256:%s", name, digest)
33 if len(short) > maxLen {33 if len(short) > maxLen {
34- // Truncate name if still too long34+ // Truncate from the end to maintain valid reference format
35- available := maxLen - len("@sha256:") - 12 - 1 // 1 for ellipsis35+ if maxLen <= 3 {
36- if available > 3 {36+ return short[:maxLen]
37- name = name[:available-1] + "…"
38 }37 }
39- short = fmt.Sprintf("%s@sha256:%s…", name, digest)38+ return short[:maxLen-3] + "..."
40 }39 }
41 return short40 return short
42 }41 }
43 42
44 // Regular tag: truncate if needed43 // Regular tag: truncate if needed
45 if len(image) > maxLen {44 if len(image) > maxLen {
46- return image[:maxLen-1] + "…"45+ if maxLen <= 3 {
46+ return image[:maxLen]
47+ }
48+ return image[:maxLen-3] + "..."
47 }49 }
48 return image50 return image
49 }51 }
50 52
53+func truncateNetwork(network string, maxLen int) string {
54+ if network == "" || network == "-" {
55+ return "-"
56+ }
57+ if len(network) > maxLen {
58+ if maxLen <= 3 {
59+ return network[:maxLen]
60+ }
61+ return network[:maxLen-3] + "..."
62+ }
63+ return network
64+}
65+
51 // newRoutesCmd creates the routes command group.66 // newRoutesCmd creates the routes command group.
52 func newRoutesCmd() *cobra.Command {67 func newRoutesCmd() *cobra.Command {
53 cmd := &cobra.Command{68 cmd := &cobra.Command{
@@ -107,7 +122,7 @@ func newRoutesListCmd() *cobra.Command {
107 122
108 // runRoutesListRemote lists routes from a remote Gordon instance.123 // runRoutesListRemote lists routes from a remote Gordon instance.
109 func runRoutesListRemote(ctx context.Context, client *remote.Client) error {124 func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
110- routes, err := client.ListRoutes(ctx)125+ routes, err := client.ListRoutesWithDetails(ctx)
111 if err != nil {126 if err != nil {
112 return fmt.Errorf("failed to list routes: %w", err)127 return fmt.Errorf("failed to list routes: %w", err)
113 }128 }
@@ -123,16 +138,20 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
123 health = make(map[string]*remote.RouteHealth)138 health = make(map[string]*remote.RouteHealth)
124 }139 }
125 140
126- // Build table rows
127 const imageColWidth = 35141 const imageColWidth = 35
128- rows := make([][]string, len(routes))142+ const networkColWidth = 22
129- for i, route := range routes {143+ rows := make([][]string, 0, len(routes))
144+ for _, route := range routes {
130 routeHealth := health[route.Domain]145 routeHealth := health[route.Domain]
131 146
132 // Container status column147 // Container status column
133- containerStatus := "unknown"148+ containerStatus := route.ContainerStatus
134- if routeHealth != nil {149+ if containerStatus == "" {
135- containerStatus = routeHealth.ContainerStatus150+ if routeHealth != nil {
151+ containerStatus = routeHealth.ContainerStatus
152+ } else {
153+ containerStatus = "unknown"
154+ }
136 }155 }
137 containerBadge := components.ContainerStatusBadge(containerStatus)156 containerBadge := components.ContainerStatusBadge(containerStatus)
138 157
@@ -140,7 +159,22 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
140 httpStatus := formatHTTPStatus(routeHealth)159 httpStatus := formatHTTPStatus(routeHealth)
141 160
142 displayImage := truncateImage(route.Image, imageColWidth)161 displayImage := truncateImage(route.Image, imageColWidth)
143- rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}162+ displayNetwork := truncateNetwork(route.Network, networkColWidth)
163+ rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})
164+
165+ for i, attachment := range route.Attachments {
166+ prefix := "|-"
167+ if i == len(route.Attachments)-1 {
168+ prefix = "`-"
169+ }
170+ attachmentName := prefix + " " + attachment.Name
171+ if len(attachmentName) > 25 {
172+ attachmentName = attachmentName[:22] + "..."
173+ }
174+ attachmentStatus := components.ContainerStatusBadge(attachment.Status)
175+ attachmentImage := truncateImage(attachment.Image, imageColWidth)
176+ rows = append(rows, []string{attachmentName, attachmentImage, "-", attachmentStatus, "-"})
177+ }
144 }178 }
145 179
146 // Render table180 // Render table
@@ -148,6 +182,7 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
148 components.WithColumns([]components.TableColumn{182 components.WithColumns([]components.TableColumn{
149 {Title: "Domain", Width: 25},183 {Title: "Domain", Width: 25},
150 {Title: "Image", Width: imageColWidth},184 {Title: "Image", Width: imageColWidth},
185+ {Title: "Network", Width: networkColWidth},
151 {Title: "Container", Width: 12},186 {Title: "Container", Width: 12},
152 {Title: "HTTP", Width: 14},187 {Title: "HTTP", Width: 14},
153 }),188 }),
M internal/adapters/in/http/admin/handler.go +88 -8
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/bnema/zerowrap"
1313
14+ "gordon/internal/adapters/dto"
1415 "gordon/internal/boundaries/in"
1516 "gordon/internal/boundaries/out"
1617 "gordon/internal/domain"
@@ -34,6 +35,36 @@ type Handler struct {
3435 log zerowrap.Logger
3536 }
3637
38+// Type aliases for API responses using shared DTO types.
39+type routeInfoResponse = dto.RouteInfo
40+type attachmentResponse = dto.Attachment
41+
42+// toAttachmentResponse converts a domain.Attachment to a dto.Attachment.
43+func toAttachmentResponse(a domain.Attachment) dto.Attachment {
44+ return dto.Attachment{
45+ Name: a.Name,
46+ Image: a.Image,
47+ ContainerID: a.ContainerID,
48+ Status: a.Status,
49+ }
50+}
51+
52+// toRouteInfoResponse converts a domain.RouteInfo to a dto.RouteInfo.
53+func toRouteInfoResponse(r domain.RouteInfo) dto.RouteInfo {
54+ attachments := make([]dto.Attachment, 0, len(r.Attachments))
55+ for _, a := range r.Attachments {
56+ attachments = append(attachments, toAttachmentResponse(a))
57+ }
58+ return dto.RouteInfo{
59+ Domain: r.Domain,
60+ Image: r.Image,
61+ ContainerID: r.ContainerID,
62+ ContainerStatus: r.ContainerStatus,
63+ Network: r.Network,
64+ Attachments: attachments,
65+ }
66+}
67+
3768 // NewHandler creates a new admin HTTP handler.
3869 func NewHandler(
3970 configSvc in.ConfigService,
@@ -83,6 +114,8 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
83114 switch {
84115 case path == "/routes" || strings.HasPrefix(path, "/routes/"):
85116 h.handleRoutes(w, r, path)
117+ case path == "/networks":
118+ h.handleNetworks(w, r)
86119 case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):
87120 h.handleSecrets(w, r, path)
88121 case path == "/deploy" || strings.HasPrefix(path, "/deploy/"):
@@ -134,7 +167,7 @@ func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path stri
134167 case http.MethodDelete:
135168 h.handleRoutesDelete(w, r, routeDomain)
136169 default:
137- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
170+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
138171 }
139172 }
140173
@@ -148,11 +181,36 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
148181 }
149182
150183 if routeDomain == "" {
184+ if r.URL.Query().Get("detailed") == "true" {
185+ routes := h.containerSvc.ListRoutesWithDetails(ctx)
186+ response := make([]routeInfoResponse, 0, len(routes))
187+ for _, route := range routes {
188+ response = append(response, toRouteInfoResponse(route))
189+ }
190+ h.sendJSON(w, http.StatusOK, map[string]any{"routes": response})
191+ return
192+ }
193+
151194 routes := h.configSvc.GetRoutes(ctx)
152195 h.sendJSON(w, http.StatusOK, map[string]any{"routes": routes})
153196 return
154197 }
155198
199+ if strings.HasSuffix(routeDomain, "/attachments") {
200+ parentDomain := strings.TrimSuffix(routeDomain, "/attachments")
201+ if parentDomain == "" {
202+ h.sendError(w, http.StatusBadRequest, "domain required in path")
203+ return
204+ }
205+ attachments := h.containerSvc.ListAttachments(ctx, parentDomain)
206+ response := make([]attachmentResponse, 0, len(attachments))
207+ for _, attachment := range attachments {
208+ response = append(response, toAttachmentResponse(attachment))
209+ }
210+ h.sendJSON(w, http.StatusOK, map[string]any{"attachments": response})
211+ return
212+ }
213+
156214 route, err := h.configSvc.GetRoute(ctx, routeDomain)
157215 if err != nil {
158216 h.sendError(w, http.StatusNotFound, "route not found")
@@ -265,6 +323,28 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
265323 h.sendJSON(w, http.StatusOK, map[string]string{"status": "removed"})
266324 }
267325
326+func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
327+ if r.Method != http.MethodGet {
328+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
329+ return
330+ }
331+
332+ ctx := r.Context()
333+
334+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
335+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
336+ return
337+ }
338+
339+ networks, err := h.containerSvc.ListNetworks(ctx)
340+ if err != nil {
341+ h.sendError(w, http.StatusInternalServerError, "failed to list networks")
342+ return
343+ }
344+
345+ h.sendJSON(w, http.StatusOK, map[string]any{"networks": networks})
346+}
347+
268348 // handleSecrets handles /admin/secrets endpoints.
269349 func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
270350 ctx := r.Context()
@@ -347,7 +427,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
347427 h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
348428
349429 default:
350- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
430+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
351431 }
352432 }
353433
@@ -355,7 +435,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
355435 // Returns detailed health status for all routes including HTTP probe results.
356436 func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
357437 if r.Method != http.MethodGet {
358- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
438+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
359439 return
360440 }
361441
@@ -387,7 +467,7 @@ func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
387467 // handleStatus handles /admin/status endpoint.
388468 func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
389469 if r.Method != http.MethodGet {
390- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
470+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
391471 return
392472 }
393473
@@ -429,7 +509,7 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
429509 // This reloads configuration from file into memory and triggers container sync.
430510 func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
431511 if r.Method != http.MethodPost {
432- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
512+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
433513 return
434514 }
435515
@@ -464,7 +544,7 @@ func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
464544 // handleConfig handles /admin/config endpoint.
465545 func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
466546 if r.Method != http.MethodGet {
467- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
547+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
468548 return
469549 }
470550
@@ -501,7 +581,7 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
501581 // POST triggers a deployment for the specified domain.
502582 func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {
503583 if r.Method != http.MethodPost {
504- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
584+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
505585 return
506586 }
507587
@@ -549,7 +629,7 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
549629 // GET /admin/logs/:domain - Container logs for a specific domain
550630 func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
551631 if r.Method != http.MethodGet {
552- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
632+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
553633 return
554634 }
555635
@@ -11,6 +11,7 @@ import (
11 11
12 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
13 13
14+ "gordon/internal/adapters/dto"
14 "gordon/internal/boundaries/in"15 "gordon/internal/boundaries/in"
15 "gordon/internal/boundaries/out"16 "gordon/internal/boundaries/out"
16 "gordon/internal/domain"17 "gordon/internal/domain"
@@ -34,6 +35,36 @@ type Handler struct {
34 log zerowrap.Logger35 log zerowrap.Logger
35 }36 }
36 37
38+// Type aliases for API responses using shared DTO types.
39+type routeInfoResponse = dto.RouteInfo
40+type attachmentResponse = dto.Attachment
41+
42+// toAttachmentResponse converts a domain.Attachment to a dto.Attachment.
43+func toAttachmentResponse(a domain.Attachment) dto.Attachment {
44+ return dto.Attachment{
45+ Name: a.Name,
46+ Image: a.Image,
47+ ContainerID: a.ContainerID,
48+ Status: a.Status,
49+ }
50+}
51+
52+// toRouteInfoResponse converts a domain.RouteInfo to a dto.RouteInfo.
53+func toRouteInfoResponse(r domain.RouteInfo) dto.RouteInfo {
54+ attachments := make([]dto.Attachment, 0, len(r.Attachments))
55+ for _, a := range r.Attachments {
56+ attachments = append(attachments, toAttachmentResponse(a))
57+ }
58+ return dto.RouteInfo{
59+ Domain: r.Domain,
60+ Image: r.Image,
61+ ContainerID: r.ContainerID,
62+ ContainerStatus: r.ContainerStatus,
63+ Network: r.Network,
64+ Attachments: attachments,
65+ }
66+}
67+
37 // NewHandler creates a new admin HTTP handler.68 // NewHandler creates a new admin HTTP handler.
38 func NewHandler(69 func NewHandler(
39 configSvc in.ConfigService,70 configSvc in.ConfigService,
@@ -83,6 +114,8 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
83 switch {114 switch {
84 case path == "/routes" || strings.HasPrefix(path, "/routes/"):115 case path == "/routes" || strings.HasPrefix(path, "/routes/"):
85 h.handleRoutes(w, r, path)116 h.handleRoutes(w, r, path)
117+ case path == "/networks":
118+ h.handleNetworks(w, r)
86 case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):119 case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):
87 h.handleSecrets(w, r, path)120 h.handleSecrets(w, r, path)
88 case path == "/deploy" || strings.HasPrefix(path, "/deploy/"):121 case path == "/deploy" || strings.HasPrefix(path, "/deploy/"):
@@ -134,7 +167,7 @@ func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path stri
134 case http.MethodDelete:167 case http.MethodDelete:
135 h.handleRoutesDelete(w, r, routeDomain)168 h.handleRoutesDelete(w, r, routeDomain)
136 default:169 default:
137- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)170+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
138 }171 }
139 }172 }
140 173
@@ -148,11 +181,36 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
148 }181 }
149 182
150 if routeDomain == "" {183 if routeDomain == "" {
184+ if r.URL.Query().Get("detailed") == "true" {
185+ routes := h.containerSvc.ListRoutesWithDetails(ctx)
186+ response := make([]routeInfoResponse, 0, len(routes))
187+ for _, route := range routes {
188+ response = append(response, toRouteInfoResponse(route))
189+ }
190+ h.sendJSON(w, http.StatusOK, map[string]any{"routes": response})
191+ return
192+ }
193+
151 routes := h.configSvc.GetRoutes(ctx)194 routes := h.configSvc.GetRoutes(ctx)
152 h.sendJSON(w, http.StatusOK, map[string]any{"routes": routes})195 h.sendJSON(w, http.StatusOK, map[string]any{"routes": routes})
153 return196 return
154 }197 }
155 198
199+ if strings.HasSuffix(routeDomain, "/attachments") {
200+ parentDomain := strings.TrimSuffix(routeDomain, "/attachments")
201+ if parentDomain == "" {
202+ h.sendError(w, http.StatusBadRequest, "domain required in path")
203+ return
204+ }
205+ attachments := h.containerSvc.ListAttachments(ctx, parentDomain)
206+ response := make([]attachmentResponse, 0, len(attachments))
207+ for _, attachment := range attachments {
208+ response = append(response, toAttachmentResponse(attachment))
209+ }
210+ h.sendJSON(w, http.StatusOK, map[string]any{"attachments": response})
211+ return
212+ }
213+
156 route, err := h.configSvc.GetRoute(ctx, routeDomain)214 route, err := h.configSvc.GetRoute(ctx, routeDomain)
157 if err != nil {215 if err != nil {
158 h.sendError(w, http.StatusNotFound, "route not found")216 h.sendError(w, http.StatusNotFound, "route not found")
@@ -265,6 +323,28 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
265 h.sendJSON(w, http.StatusOK, map[string]string{"status": "removed"})323 h.sendJSON(w, http.StatusOK, map[string]string{"status": "removed"})
266 }324 }
267 325
326+func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
327+ if r.Method != http.MethodGet {
328+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
329+ return
330+ }
331+
332+ ctx := r.Context()
333+
334+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
335+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
336+ return
337+ }
338+
339+ networks, err := h.containerSvc.ListNetworks(ctx)
340+ if err != nil {
341+ h.sendError(w, http.StatusInternalServerError, "failed to list networks")
342+ return
343+ }
344+
345+ h.sendJSON(w, http.StatusOK, map[string]any{"networks": networks})
346+}
347+
268 // handleSecrets handles /admin/secrets endpoints.348 // handleSecrets handles /admin/secrets endpoints.
269 func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {349 func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
270 ctx := r.Context()350 ctx := r.Context()
@@ -347,7 +427,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
347 h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"})427 h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
348 428
349 default:429 default:
350- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)430+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
351 }431 }
352 }432 }
353 433
@@ -355,7 +435,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
355 // Returns detailed health status for all routes including HTTP probe results.435 // Returns detailed health status for all routes including HTTP probe results.
356 func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {436 func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
357 if r.Method != http.MethodGet {437 if r.Method != http.MethodGet {
358- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)438+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
359 return439 return
360 }440 }
361 441
@@ -387,7 +467,7 @@ func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
387 // handleStatus handles /admin/status endpoint.467 // handleStatus handles /admin/status endpoint.
388 func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {468 func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
389 if r.Method != http.MethodGet {469 if r.Method != http.MethodGet {
390- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)470+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
391 return471 return
392 }472 }
393 473
@@ -429,7 +509,7 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
429 // This reloads configuration from file into memory and triggers container sync.509 // This reloads configuration from file into memory and triggers container sync.
430 func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {510 func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
431 if r.Method != http.MethodPost {511 if r.Method != http.MethodPost {
432- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)512+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
433 return513 return
434 }514 }
435 515
@@ -464,7 +544,7 @@ func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
464 // handleConfig handles /admin/config endpoint.544 // handleConfig handles /admin/config endpoint.
465 func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {545 func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
466 if r.Method != http.MethodGet {546 if r.Method != http.MethodGet {
467- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)547+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
468 return548 return
469 }549 }
470 550
@@ -501,7 +581,7 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
501 // POST triggers a deployment for the specified domain.581 // POST triggers a deployment for the specified domain.
502 func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {582 func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {
503 if r.Method != http.MethodPost {583 if r.Method != http.MethodPost {
504- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)584+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
505 return585 return
506 }586 }
507 587
@@ -549,7 +629,7 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
549 // GET /admin/logs/:domain - Container logs for a specific domain629 // GET /admin/logs/:domain - Container logs for a specific domain
550 func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {630 func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
551 if r.Method != http.MethodGet {631 if r.Method != http.MethodGet {
552- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)632+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
553 return633 return
554 }634 }
555 635
A internal/adapters/out/docker/network_test.go +118 -0
@@ -0,0 +1,118 @@
1+package docker
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "strings"
8+ "testing"
9+
10+ "github.com/docker/docker/client"
11+ "github.com/stretchr/testify/assert"
12+)
13+
14+func TestRuntime_GetContainerNetwork(t *testing.T) {
15+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+ assert.Equal(t, http.MethodGet, r.Method)
17+ assert.Equal(t, "/v1.41/containers/abc123/json", r.URL.Path)
18+ w.Header().Set("Content-Type", "application/json")
19+ _, _ = w.Write([]byte(`{
20+ "NetworkSettings": {
21+ "Networks": {
22+ "bridge": {"IPAddress": "172.17.0.2"},
23+ "gordon-app": {"IPAddress": "172.18.0.2"}
24+ }
25+ }
26+ }`))
27+ }))
28+ defer server.Close()
29+
30+ host := strings.TrimPrefix(server.URL, "http://")
31+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
32+ if err != nil {
33+ t.Fatalf("failed to create docker client: %v", err)
34+ }
35+
36+ runtime := NewRuntimeWithClient(cli)
37+ network, err := runtime.GetContainerNetwork(context.Background(), "abc123")
38+
39+ assert.NoError(t, err)
40+ assert.Equal(t, "gordon-app", network)
41+}
42+
43+func TestRuntime_GetContainerNetwork_Fallback(t *testing.T) {
44+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45+ assert.Equal(t, http.MethodGet, r.Method)
46+ assert.Equal(t, "/v1.41/containers/abc123/json", r.URL.Path)
47+ w.Header().Set("Content-Type", "application/json")
48+ _, _ = w.Write([]byte(`{
49+ "NetworkSettings": {
50+ "Networks": {
51+ "bridge": {"IPAddress": "172.17.0.2"},
52+ "custom": {"IPAddress": "172.18.0.2"}
53+ }
54+ }
55+ }`))
56+ }))
57+ defer server.Close()
58+
59+ host := strings.TrimPrefix(server.URL, "http://")
60+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
61+ if err != nil {
62+ t.Fatalf("failed to create docker client: %v", err)
63+ }
64+
65+ runtime := NewRuntimeWithClient(cli)
66+ network, err := runtime.GetContainerNetwork(context.Background(), "abc123")
67+
68+ assert.NoError(t, err)
69+ assert.Equal(t, "bridge", network)
70+}
71+
72+func TestRuntime_GetContainerNetwork_EmptyNetworks(t *testing.T) {
73+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74+ assert.Equal(t, http.MethodGet, r.Method)
75+ assert.Equal(t, "/v1.41/containers/abc123/json", r.URL.Path)
76+ w.Header().Set("Content-Type", "application/json")
77+ _, _ = w.Write([]byte(`{
78+ "NetworkSettings": {
79+ "Networks": {}
80+ }
81+ }`))
82+ }))
83+ defer server.Close()
84+
85+ host := strings.TrimPrefix(server.URL, "http://")
86+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
87+ if err != nil {
88+ t.Fatalf("failed to create docker client: %v", err)
89+ }
90+
91+ runtime := NewRuntimeWithClient(cli)
92+ network, err := runtime.GetContainerNetwork(context.Background(), "abc123")
93+
94+ assert.NoError(t, err)
95+ assert.Equal(t, "bridge", network)
96+}
97+
98+func TestRuntime_GetContainerNetwork_NilNetworkSettings(t *testing.T) {
99+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100+ assert.Equal(t, http.MethodGet, r.Method)
101+ assert.Equal(t, "/v1.41/containers/abc123/json", r.URL.Path)
102+ w.Header().Set("Content-Type", "application/json")
103+ _, _ = w.Write([]byte(`{}`))
104+ }))
105+ defer server.Close()
106+
107+ host := strings.TrimPrefix(server.URL, "http://")
108+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
109+ if err != nil {
110+ t.Fatalf("failed to create docker client: %v", err)
111+ }
112+
113+ runtime := NewRuntimeWithClient(cli)
114+ network, err := runtime.GetContainerNetwork(context.Background(), "abc123")
115+
116+ assert.NoError(t, err)
117+ assert.Equal(t, "bridge", network)
118+}
@@ -0,0 +1,118 @@
1+package docker
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "strings"
8+ "testing"
9+
10+ "github.com/docker/docker/client"
11+ "github.com/stretchr/testify/assert"
12+)
13+
14+func TestRuntime_GetContainerNetwork(t *testing.T) {
15+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+ assert.Equal(t, http.MethodGet, r.Method)
17+ assert.Equal(t, "/v1.41/containers/abc123/json", r.URL.Path)
18+ w.Header().Set("Content-Type", "application/json")
19+ _, _ = w.Write([]byte(`{
20+ "NetworkSettings": {
21+ "Networks": {
22+ "bridge": {"IPAddress": "172.17.0.2"},
23+ "gordon-app": {"IPAddress": "172.18.0.2"}
24+ }
25+ }
26+ }`))
27+ }))
28+ defer server.Close()
29+
30+ host := strings.TrimPrefix(server.URL, "http://")
31+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
32+ if err != nil {
33+ t.Fatalf("failed to create docker client: %v", err)
34+ }
35+
36+ runtime := NewRuntimeWithClient(cli)
37+ network, err := runtime.GetContainerNetwork(context.Background(), "abc123")
38+
39+ assert.NoError(t, err)
40+ assert.Equal(t, "gordon-app", network)
41+}
42+
43+func TestRuntime_GetContainerNetwork_Fallback(t *testing.T) {
44+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45+ assert.Equal(t, http.MethodGet, r.Method)
46+ assert.Equal(t, "/v1.41/containers/abc123/json", r.URL.Path)
47+ w.Header().Set("Content-Type", "application/json")
48+ _, _ = w.Write([]byte(`{
49+ "NetworkSettings": {
50+ "Networks": {
51+ "bridge": {"IPAddress": "172.17.0.2"},
52+ "custom": {"IPAddress": "172.18.0.2"}
53+ }
54+ }
55+ }`))
56+ }))
57+ defer server.Close()
58+
59+ host := strings.TrimPrefix(server.URL, "http://")
60+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
61+ if err != nil {
62+ t.Fatalf("failed to create docker client: %v", err)
63+ }
64+
65+ runtime := NewRuntimeWithClient(cli)
66+ network, err := runtime.GetContainerNetwork(context.Background(), "abc123")
67+
68+ assert.NoError(t, err)
69+ assert.Equal(t, "bridge", network)
70+}
71+
72+func TestRuntime_GetContainerNetwork_EmptyNetworks(t *testing.T) {
73+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74+ assert.Equal(t, http.MethodGet, r.Method)
75+ assert.Equal(t, "/v1.41/containers/abc123/json", r.URL.Path)
76+ w.Header().Set("Content-Type", "application/json")
77+ _, _ = w.Write([]byte(`{
78+ "NetworkSettings": {
79+ "Networks": {}
80+ }
81+ }`))
82+ }))
83+ defer server.Close()
84+
85+ host := strings.TrimPrefix(server.URL, "http://")
86+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
87+ if err != nil {
88+ t.Fatalf("failed to create docker client: %v", err)
89+ }
90+
91+ runtime := NewRuntimeWithClient(cli)
92+ network, err := runtime.GetContainerNetwork(context.Background(), "abc123")
93+
94+ assert.NoError(t, err)
95+ assert.Equal(t, "bridge", network)
96+}
97+
98+func TestRuntime_GetContainerNetwork_NilNetworkSettings(t *testing.T) {
99+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100+ assert.Equal(t, http.MethodGet, r.Method)
101+ assert.Equal(t, "/v1.41/containers/abc123/json", r.URL.Path)
102+ w.Header().Set("Content-Type", "application/json")
103+ _, _ = w.Write([]byte(`{}`))
104+ }))
105+ defer server.Close()
106+
107+ host := strings.TrimPrefix(server.URL, "http://")
108+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
109+ if err != nil {
110+ t.Fatalf("failed to create docker client: %v", err)
111+ }
112+
113+ runtime := NewRuntimeWithClient(cli)
114+ network, err := runtime.GetContainerNetwork(context.Background(), "abc123")
115+
116+ assert.NoError(t, err)
117+ assert.Equal(t, "bridge", network)
118+}
M internal/adapters/out/docker/runtime.go +39 -0
@@ -746,6 +746,45 @@ func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID strin
746746 return containerIP, selectedPort, nil
747747 }
748748
749+// GetContainerNetwork returns the network name a container is connected to.
750+func (r *Runtime) GetContainerNetwork(ctx context.Context, containerID string) (string, error) {
751+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
752+ zerowrap.FieldLayer: "adapter",
753+ zerowrap.FieldAdapter: "docker",
754+ zerowrap.FieldAction: "GetContainerNetwork",
755+ zerowrap.FieldEntityID: containerID,
756+ })
757+ log := zerowrap.FromCtx(ctx)
758+
759+ resp, err := r.client.ContainerInspect(ctx, containerID)
760+ if err != nil {
761+ return "", log.WrapErr(err, "failed to inspect container")
762+ }
763+
764+ networks := resp.NetworkSettings
765+ if networks == nil || networks.Networks == nil || len(networks.Networks) == 0 {
766+ return "bridge", nil
767+ }
768+
769+ selected := ""
770+ for name := range networks.Networks {
771+ if strings.HasPrefix(name, "gordon-") {
772+ selected = name
773+ break
774+ }
775+ if selected == "" {
776+ selected = name
777+ }
778+ }
779+
780+ if selected == "" {
781+ return "bridge", nil
782+ }
783+
784+ log.Debug().Str("network", selected).Msg("container network detected")
785+ return selected, nil
786+}
787+
749788 // InspectImageVolumes gets the volume mount points declared in the image.
750789 func (r *Runtime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {
751790 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -746,6 +746,45 @@ func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID strin
746 return containerIP, selectedPort, nil746 return containerIP, selectedPort, nil
747 }747 }
748 748
749+// GetContainerNetwork returns the network name a container is connected to.
750+func (r *Runtime) GetContainerNetwork(ctx context.Context, containerID string) (string, error) {
751+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
752+ zerowrap.FieldLayer: "adapter",
753+ zerowrap.FieldAdapter: "docker",
754+ zerowrap.FieldAction: "GetContainerNetwork",
755+ zerowrap.FieldEntityID: containerID,
756+ })
757+ log := zerowrap.FromCtx(ctx)
758+
759+ resp, err := r.client.ContainerInspect(ctx, containerID)
760+ if err != nil {
761+ return "", log.WrapErr(err, "failed to inspect container")
762+ }
763+
764+ networks := resp.NetworkSettings
765+ if networks == nil || networks.Networks == nil || len(networks.Networks) == 0 {
766+ return "bridge", nil
767+ }
768+
769+ selected := ""
770+ for name := range networks.Networks {
771+ if strings.HasPrefix(name, "gordon-") {
772+ selected = name
773+ break
774+ }
775+ if selected == "" {
776+ selected = name
777+ }
778+ }
779+
780+ if selected == "" {
781+ return "bridge", nil
782+ }
783+
784+ log.Debug().Str("network", selected).Msg("container network detected")
785+ return selected, nil
786+}
787+
749 // InspectImageVolumes gets the volume mount points declared in the image.788 // InspectImageVolumes gets the volume mount points declared in the image.
750 func (r *Runtime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {789 func (r *Runtime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {
751 ctx = zerowrap.CtxWithFields(ctx, map[string]any{790 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/boundaries/in/container.go +9 -0
@@ -26,6 +26,15 @@ type ContainerService interface {
2626 // List returns all managed containers.
2727 List(ctx context.Context) map[string]*domain.Container
2828
29+ // ListRoutesWithDetails returns routes with network and attachment info.
30+ ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
31+
32+ // ListAttachments returns attachments for a domain.
33+ ListAttachments(ctx context.Context, domain string) []domain.Attachment
34+
35+ // ListNetworks returns Gordon-managed networks.
36+ ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
37+
2938 // HealthCheck performs health checks on all containers.
3039 HealthCheck(ctx context.Context) map[string]bool
3140
@@ -26,6 +26,15 @@ type ContainerService interface {
26 // List returns all managed containers.26 // List returns all managed containers.
27 List(ctx context.Context) map[string]*domain.Container27 List(ctx context.Context) map[string]*domain.Container
28 28
29+ // ListRoutesWithDetails returns routes with network and attachment info.
30+ ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
31+
32+ // ListAttachments returns attachments for a domain.
33+ ListAttachments(ctx context.Context, domain string) []domain.Attachment
34+
35+ // ListNetworks returns Gordon-managed networks.
36+ ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
37+
29 // HealthCheck performs health checks on all containers.38 // HealthCheck performs health checks on all containers.
30 HealthCheck(ctx context.Context) map[string]bool39 HealthCheck(ctx context.Context) map[string]bool
31 40
M internal/boundaries/in/mocks/mock_container_service.go +174 -0
@@ -337,6 +337,180 @@ func (_c *MockContainerService_List_Call) RunAndReturn(run func(ctx context.Cont
337337 return _c
338338 }
339339
340+// ListAttachments provides a mock function for the type MockContainerService
341+func (_mock *MockContainerService) ListAttachments(ctx context.Context, domain1 string) []domain.Attachment {
342+ ret := _mock.Called(ctx, domain1)
343+
344+ if len(ret) == 0 {
345+ panic("no return value specified for ListAttachments")
346+ }
347+
348+ var r0 []domain.Attachment
349+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.Attachment); ok {
350+ r0 = returnFunc(ctx, domain1)
351+ } else {
352+ if ret.Get(0) != nil {
353+ r0 = ret.Get(0).([]domain.Attachment)
354+ }
355+ }
356+ return r0
357+}
358+
359+// MockContainerService_ListAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAttachments'
360+type MockContainerService_ListAttachments_Call struct {
361+ *mock.Call
362+}
363+
364+// ListAttachments is a helper method to define mock.On call
365+// - ctx context.Context
366+// - domain1 string
367+func (_e *MockContainerService_Expecter) ListAttachments(ctx interface{}, domain1 interface{}) *MockContainerService_ListAttachments_Call {
368+ return &MockContainerService_ListAttachments_Call{Call: _e.mock.On("ListAttachments", ctx, domain1)}
369+}
370+
371+func (_c *MockContainerService_ListAttachments_Call) Run(run func(ctx context.Context, domain1 string)) *MockContainerService_ListAttachments_Call {
372+ _c.Call.Run(func(args mock.Arguments) {
373+ var arg0 context.Context
374+ if args[0] != nil {
375+ arg0 = args[0].(context.Context)
376+ }
377+ var arg1 string
378+ if args[1] != nil {
379+ arg1 = args[1].(string)
380+ }
381+ run(
382+ arg0,
383+ arg1,
384+ )
385+ })
386+ return _c
387+}
388+
389+func (_c *MockContainerService_ListAttachments_Call) Return(attachments []domain.Attachment) *MockContainerService_ListAttachments_Call {
390+ _c.Call.Return(attachments)
391+ return _c
392+}
393+
394+func (_c *MockContainerService_ListAttachments_Call) RunAndReturn(run func(ctx context.Context, domain1 string) []domain.Attachment) *MockContainerService_ListAttachments_Call {
395+ _c.Call.Return(run)
396+ return _c
397+}
398+
399+// ListNetworks provides a mock function for the type MockContainerService
400+func (_mock *MockContainerService) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
401+ ret := _mock.Called(ctx)
402+
403+ if len(ret) == 0 {
404+ panic("no return value specified for ListNetworks")
405+ }
406+
407+ var r0 []*domain.NetworkInfo
408+ var r1 error
409+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*domain.NetworkInfo, error)); ok {
410+ return returnFunc(ctx)
411+ }
412+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*domain.NetworkInfo); ok {
413+ r0 = returnFunc(ctx)
414+ } else {
415+ if ret.Get(0) != nil {
416+ r0 = ret.Get(0).([]*domain.NetworkInfo)
417+ }
418+ }
419+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
420+ r1 = returnFunc(ctx)
421+ } else {
422+ r1 = ret.Error(1)
423+ }
424+ return r0, r1
425+}
426+
427+// MockContainerService_ListNetworks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNetworks'
428+type MockContainerService_ListNetworks_Call struct {
429+ *mock.Call
430+}
431+
432+// ListNetworks is a helper method to define mock.On call
433+// - ctx context.Context
434+func (_e *MockContainerService_Expecter) ListNetworks(ctx interface{}) *MockContainerService_ListNetworks_Call {
435+ return &MockContainerService_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}
436+}
437+
438+func (_c *MockContainerService_ListNetworks_Call) Run(run func(ctx context.Context)) *MockContainerService_ListNetworks_Call {
439+ _c.Call.Run(func(args mock.Arguments) {
440+ var arg0 context.Context
441+ if args[0] != nil {
442+ arg0 = args[0].(context.Context)
443+ }
444+ run(
445+ arg0,
446+ )
447+ })
448+ return _c
449+}
450+
451+func (_c *MockContainerService_ListNetworks_Call) Return(networkInfos []*domain.NetworkInfo, err error) *MockContainerService_ListNetworks_Call {
452+ _c.Call.Return(networkInfos, err)
453+ return _c
454+}
455+
456+func (_c *MockContainerService_ListNetworks_Call) RunAndReturn(run func(ctx context.Context) ([]*domain.NetworkInfo, error)) *MockContainerService_ListNetworks_Call {
457+ _c.Call.Return(run)
458+ return _c
459+}
460+
461+// ListRoutesWithDetails provides a mock function for the type MockContainerService
462+func (_mock *MockContainerService) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
463+ ret := _mock.Called(ctx)
464+
465+ if len(ret) == 0 {
466+ panic("no return value specified for ListRoutesWithDetails")
467+ }
468+
469+ var r0 []domain.RouteInfo
470+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.RouteInfo); ok {
471+ r0 = returnFunc(ctx)
472+ } else {
473+ if ret.Get(0) != nil {
474+ r0 = ret.Get(0).([]domain.RouteInfo)
475+ }
476+ }
477+ return r0
478+}
479+
480+// MockContainerService_ListRoutesWithDetails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRoutesWithDetails'
481+type MockContainerService_ListRoutesWithDetails_Call struct {
482+ *mock.Call
483+}
484+
485+// ListRoutesWithDetails is a helper method to define mock.On call
486+// - ctx context.Context
487+func (_e *MockContainerService_Expecter) ListRoutesWithDetails(ctx interface{}) *MockContainerService_ListRoutesWithDetails_Call {
488+ return &MockContainerService_ListRoutesWithDetails_Call{Call: _e.mock.On("ListRoutesWithDetails", ctx)}
489+}
490+
491+func (_c *MockContainerService_ListRoutesWithDetails_Call) Run(run func(ctx context.Context)) *MockContainerService_ListRoutesWithDetails_Call {
492+ _c.Call.Run(func(args mock.Arguments) {
493+ var arg0 context.Context
494+ if args[0] != nil {
495+ arg0 = args[0].(context.Context)
496+ }
497+ run(
498+ arg0,
499+ )
500+ })
501+ return _c
502+}
503+
504+func (_c *MockContainerService_ListRoutesWithDetails_Call) Return(routeInfos []domain.RouteInfo) *MockContainerService_ListRoutesWithDetails_Call {
505+ _c.Call.Return(routeInfos)
506+ return _c
507+}
508+
509+func (_c *MockContainerService_ListRoutesWithDetails_Call) RunAndReturn(run func(ctx context.Context) []domain.RouteInfo) *MockContainerService_ListRoutesWithDetails_Call {
510+ _c.Call.Return(run)
511+ return _c
512+}
513+
340514 // Remove provides a mock function for the type MockContainerService
341515 func (_mock *MockContainerService) Remove(ctx context.Context, containerID string, force bool) error {
342516 ret := _mock.Called(ctx, containerID, force)
@@ -337,6 +337,180 @@ func (_c *MockContainerService_List_Call) RunAndReturn(run func(ctx context.Cont
337 return _c337 return _c
338 }338 }
339 339
340+// ListAttachments provides a mock function for the type MockContainerService
341+func (_mock *MockContainerService) ListAttachments(ctx context.Context, domain1 string) []domain.Attachment {
342+ ret := _mock.Called(ctx, domain1)
343+
344+ if len(ret) == 0 {
345+ panic("no return value specified for ListAttachments")
346+ }
347+
348+ var r0 []domain.Attachment
349+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.Attachment); ok {
350+ r0 = returnFunc(ctx, domain1)
351+ } else {
352+ if ret.Get(0) != nil {
353+ r0 = ret.Get(0).([]domain.Attachment)
354+ }
355+ }
356+ return r0
357+}
358+
359+// MockContainerService_ListAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAttachments'
360+type MockContainerService_ListAttachments_Call struct {
361+ *mock.Call
362+}
363+
364+// ListAttachments is a helper method to define mock.On call
365+// - ctx context.Context
366+// - domain1 string
367+func (_e *MockContainerService_Expecter) ListAttachments(ctx interface{}, domain1 interface{}) *MockContainerService_ListAttachments_Call {
368+ return &MockContainerService_ListAttachments_Call{Call: _e.mock.On("ListAttachments", ctx, domain1)}
369+}
370+
371+func (_c *MockContainerService_ListAttachments_Call) Run(run func(ctx context.Context, domain1 string)) *MockContainerService_ListAttachments_Call {
372+ _c.Call.Run(func(args mock.Arguments) {
373+ var arg0 context.Context
374+ if args[0] != nil {
375+ arg0 = args[0].(context.Context)
376+ }
377+ var arg1 string
378+ if args[1] != nil {
379+ arg1 = args[1].(string)
380+ }
381+ run(
382+ arg0,
383+ arg1,
384+ )
385+ })
386+ return _c
387+}
388+
389+func (_c *MockContainerService_ListAttachments_Call) Return(attachments []domain.Attachment) *MockContainerService_ListAttachments_Call {
390+ _c.Call.Return(attachments)
391+ return _c
392+}
393+
394+func (_c *MockContainerService_ListAttachments_Call) RunAndReturn(run func(ctx context.Context, domain1 string) []domain.Attachment) *MockContainerService_ListAttachments_Call {
395+ _c.Call.Return(run)
396+ return _c
397+}
398+
399+// ListNetworks provides a mock function for the type MockContainerService
400+func (_mock *MockContainerService) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
401+ ret := _mock.Called(ctx)
402+
403+ if len(ret) == 0 {
404+ panic("no return value specified for ListNetworks")
405+ }
406+
407+ var r0 []*domain.NetworkInfo
408+ var r1 error
409+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*domain.NetworkInfo, error)); ok {
410+ return returnFunc(ctx)
411+ }
412+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*domain.NetworkInfo); ok {
413+ r0 = returnFunc(ctx)
414+ } else {
415+ if ret.Get(0) != nil {
416+ r0 = ret.Get(0).([]*domain.NetworkInfo)
417+ }
418+ }
419+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
420+ r1 = returnFunc(ctx)
421+ } else {
422+ r1 = ret.Error(1)
423+ }
424+ return r0, r1
425+}
426+
427+// MockContainerService_ListNetworks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNetworks'
428+type MockContainerService_ListNetworks_Call struct {
429+ *mock.Call
430+}
431+
432+// ListNetworks is a helper method to define mock.On call
433+// - ctx context.Context
434+func (_e *MockContainerService_Expecter) ListNetworks(ctx interface{}) *MockContainerService_ListNetworks_Call {
435+ return &MockContainerService_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}
436+}
437+
438+func (_c *MockContainerService_ListNetworks_Call) Run(run func(ctx context.Context)) *MockContainerService_ListNetworks_Call {
439+ _c.Call.Run(func(args mock.Arguments) {
440+ var arg0 context.Context
441+ if args[0] != nil {
442+ arg0 = args[0].(context.Context)
443+ }
444+ run(
445+ arg0,
446+ )
447+ })
448+ return _c
449+}
450+
451+func (_c *MockContainerService_ListNetworks_Call) Return(networkInfos []*domain.NetworkInfo, err error) *MockContainerService_ListNetworks_Call {
452+ _c.Call.Return(networkInfos, err)
453+ return _c
454+}
455+
456+func (_c *MockContainerService_ListNetworks_Call) RunAndReturn(run func(ctx context.Context) ([]*domain.NetworkInfo, error)) *MockContainerService_ListNetworks_Call {
457+ _c.Call.Return(run)
458+ return _c
459+}
460+
461+// ListRoutesWithDetails provides a mock function for the type MockContainerService
462+func (_mock *MockContainerService) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
463+ ret := _mock.Called(ctx)
464+
465+ if len(ret) == 0 {
466+ panic("no return value specified for ListRoutesWithDetails")
467+ }
468+
469+ var r0 []domain.RouteInfo
470+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.RouteInfo); ok {
471+ r0 = returnFunc(ctx)
472+ } else {
473+ if ret.Get(0) != nil {
474+ r0 = ret.Get(0).([]domain.RouteInfo)
475+ }
476+ }
477+ return r0
478+}
479+
480+// MockContainerService_ListRoutesWithDetails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRoutesWithDetails'
481+type MockContainerService_ListRoutesWithDetails_Call struct {
482+ *mock.Call
483+}
484+
485+// ListRoutesWithDetails is a helper method to define mock.On call
486+// - ctx context.Context
487+func (_e *MockContainerService_Expecter) ListRoutesWithDetails(ctx interface{}) *MockContainerService_ListRoutesWithDetails_Call {
488+ return &MockContainerService_ListRoutesWithDetails_Call{Call: _e.mock.On("ListRoutesWithDetails", ctx)}
489+}
490+
491+func (_c *MockContainerService_ListRoutesWithDetails_Call) Run(run func(ctx context.Context)) *MockContainerService_ListRoutesWithDetails_Call {
492+ _c.Call.Run(func(args mock.Arguments) {
493+ var arg0 context.Context
494+ if args[0] != nil {
495+ arg0 = args[0].(context.Context)
496+ }
497+ run(
498+ arg0,
499+ )
500+ })
501+ return _c
502+}
503+
504+func (_c *MockContainerService_ListRoutesWithDetails_Call) Return(routeInfos []domain.RouteInfo) *MockContainerService_ListRoutesWithDetails_Call {
505+ _c.Call.Return(routeInfos)
506+ return _c
507+}
508+
509+func (_c *MockContainerService_ListRoutesWithDetails_Call) RunAndReturn(run func(ctx context.Context) []domain.RouteInfo) *MockContainerService_ListRoutesWithDetails_Call {
510+ _c.Call.Return(run)
511+ return _c
512+}
513+
340 // Remove provides a mock function for the type MockContainerService514 // Remove provides a mock function for the type MockContainerService
341 func (_mock *MockContainerService) Remove(ctx context.Context, containerID string, force bool) error {515 func (_mock *MockContainerService) Remove(ctx context.Context, containerID string, force bool) error {
342 ret := _mock.Called(ctx, containerID, force)516 ret := _mock.Called(ctx, containerID, force)
M internal/boundaries/out/mocks/mock_container_runtime.go +66 -0
@@ -495,6 +495,72 @@ func (_c *MockContainerRuntime_GetContainerLogs_Call) RunAndReturn(run func(ctx
495495 return _c
496496 }
497497
498+// GetContainerNetwork provides a mock function for the type MockContainerRuntime
499+func (_mock *MockContainerRuntime) GetContainerNetwork(ctx context.Context, containerID string) (string, error) {
500+ ret := _mock.Called(ctx, containerID)
501+
502+ if len(ret) == 0 {
503+ panic("no return value specified for GetContainerNetwork")
504+ }
505+
506+ var r0 string
507+ var r1 error
508+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok {
509+ return returnFunc(ctx, containerID)
510+ }
511+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
512+ r0 = returnFunc(ctx, containerID)
513+ } else {
514+ r0 = ret.Get(0).(string)
515+ }
516+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
517+ r1 = returnFunc(ctx, containerID)
518+ } else {
519+ r1 = ret.Error(1)
520+ }
521+ return r0, r1
522+}
523+
524+// MockContainerRuntime_GetContainerNetwork_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetContainerNetwork'
525+type MockContainerRuntime_GetContainerNetwork_Call struct {
526+ *mock.Call
527+}
528+
529+// GetContainerNetwork is a helper method to define mock.On call
530+// - ctx context.Context
531+// - containerID string
532+func (_e *MockContainerRuntime_Expecter) GetContainerNetwork(ctx interface{}, containerID interface{}) *MockContainerRuntime_GetContainerNetwork_Call {
533+ return &MockContainerRuntime_GetContainerNetwork_Call{Call: _e.mock.On("GetContainerNetwork", ctx, containerID)}
534+}
535+
536+func (_c *MockContainerRuntime_GetContainerNetwork_Call) Run(run func(ctx context.Context, containerID string)) *MockContainerRuntime_GetContainerNetwork_Call {
537+ _c.Call.Run(func(args mock.Arguments) {
538+ var arg0 context.Context
539+ if args[0] != nil {
540+ arg0 = args[0].(context.Context)
541+ }
542+ var arg1 string
543+ if args[1] != nil {
544+ arg1 = args[1].(string)
545+ }
546+ run(
547+ arg0,
548+ arg1,
549+ )
550+ })
551+ return _c
552+}
553+
554+func (_c *MockContainerRuntime_GetContainerNetwork_Call) Return(s string, err error) *MockContainerRuntime_GetContainerNetwork_Call {
555+ _c.Call.Return(s, err)
556+ return _c
557+}
558+
559+func (_c *MockContainerRuntime_GetContainerNetwork_Call) RunAndReturn(run func(ctx context.Context, containerID string) (string, error)) *MockContainerRuntime_GetContainerNetwork_Call {
560+ _c.Call.Return(run)
561+ return _c
562+}
563+
498564 // GetContainerNetworkInfo provides a mock function for the type MockContainerRuntime
499565 func (_mock *MockContainerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
500566 ret := _mock.Called(ctx, containerID)
@@ -495,6 +495,72 @@ func (_c *MockContainerRuntime_GetContainerLogs_Call) RunAndReturn(run func(ctx
495 return _c495 return _c
496 }496 }
497 497
498+// GetContainerNetwork provides a mock function for the type MockContainerRuntime
499+func (_mock *MockContainerRuntime) GetContainerNetwork(ctx context.Context, containerID string) (string, error) {
500+ ret := _mock.Called(ctx, containerID)
501+
502+ if len(ret) == 0 {
503+ panic("no return value specified for GetContainerNetwork")
504+ }
505+
506+ var r0 string
507+ var r1 error
508+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok {
509+ return returnFunc(ctx, containerID)
510+ }
511+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
512+ r0 = returnFunc(ctx, containerID)
513+ } else {
514+ r0 = ret.Get(0).(string)
515+ }
516+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
517+ r1 = returnFunc(ctx, containerID)
518+ } else {
519+ r1 = ret.Error(1)
520+ }
521+ return r0, r1
522+}
523+
524+// MockContainerRuntime_GetContainerNetwork_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetContainerNetwork'
525+type MockContainerRuntime_GetContainerNetwork_Call struct {
526+ *mock.Call
527+}
528+
529+// GetContainerNetwork is a helper method to define mock.On call
530+// - ctx context.Context
531+// - containerID string
532+func (_e *MockContainerRuntime_Expecter) GetContainerNetwork(ctx interface{}, containerID interface{}) *MockContainerRuntime_GetContainerNetwork_Call {
533+ return &MockContainerRuntime_GetContainerNetwork_Call{Call: _e.mock.On("GetContainerNetwork", ctx, containerID)}
534+}
535+
536+func (_c *MockContainerRuntime_GetContainerNetwork_Call) Run(run func(ctx context.Context, containerID string)) *MockContainerRuntime_GetContainerNetwork_Call {
537+ _c.Call.Run(func(args mock.Arguments) {
538+ var arg0 context.Context
539+ if args[0] != nil {
540+ arg0 = args[0].(context.Context)
541+ }
542+ var arg1 string
543+ if args[1] != nil {
544+ arg1 = args[1].(string)
545+ }
546+ run(
547+ arg0,
548+ arg1,
549+ )
550+ })
551+ return _c
552+}
553+
554+func (_c *MockContainerRuntime_GetContainerNetwork_Call) Return(s string, err error) *MockContainerRuntime_GetContainerNetwork_Call {
555+ _c.Call.Return(s, err)
556+ return _c
557+}
558+
559+func (_c *MockContainerRuntime_GetContainerNetwork_Call) RunAndReturn(run func(ctx context.Context, containerID string) (string, error)) *MockContainerRuntime_GetContainerNetwork_Call {
560+ _c.Call.Return(run)
561+ return _c
562+}
563+
498 // GetContainerNetworkInfo provides a mock function for the type MockContainerRuntime564 // GetContainerNetworkInfo provides a mock function for the type MockContainerRuntime
499 func (_mock *MockContainerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {565 func (_mock *MockContainerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
500 ret := _mock.Called(ctx, containerID)566 ret := _mock.Called(ctx, containerID)
M internal/boundaries/out/runtime.go +1 -0
@@ -46,6 +46,7 @@ type ContainerRuntime interface {
4646 GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error)
4747 GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error)
4848 GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error)
49+ GetContainerNetwork(ctx context.Context, containerID string) (string, error)
4950
5051 // Volume management
5152 InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error)
@@ -46,6 +46,7 @@ type ContainerRuntime interface {
46 GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error)46 GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error)
47 GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error)47 GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error)
48 GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error)48 GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error)
49+ GetContainerNetwork(ctx context.Context, containerID string) (string, error)
49 50
50 // Volume management51 // Volume management
51 InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error)52 InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error)
M internal/domain/container.go +18 -0
@@ -21,6 +21,24 @@ type NetworkInfo struct {
2121 Labels map[string]string
2222 }
2323
24+// Attachment represents an attached service container.
25+type Attachment struct {
26+ Name string
27+ Image string
28+ ContainerID string
29+ Status string
30+}
31+
32+// RouteInfo combines route configuration with runtime state.
33+type RouteInfo struct {
34+ Domain string
35+ Image string
36+ ContainerID string
37+ ContainerStatus string
38+ Network string
39+ Attachments []Attachment
40+}
41+
2442 // ContainerConfig holds configuration for creating a container.
2543 type ContainerConfig struct {
2644 Image string
@@ -21,6 +21,24 @@ type NetworkInfo struct {
21 Labels map[string]string21 Labels map[string]string
22 }22 }
23 23
24+// Attachment represents an attached service container.
25+type Attachment struct {
26+ Name string
27+ Image string
28+ ContainerID string
29+ Status string
30+}
31+
32+// RouteInfo combines route configuration with runtime state.
33+type RouteInfo struct {
34+ Domain string
35+ Image string
36+ ContainerID string
37+ ContainerStatus string
38+ Network string
39+ Attachments []Attachment
40+}
41+
24 // ContainerConfig holds configuration for creating a container.42 // ContainerConfig holds configuration for creating a container.
25 type ContainerConfig struct {43 type ContainerConfig struct {
26 Image string44 Image string
A internal/usecase/container/routes_test.go +162 -0
@@ -0,0 +1,162 @@
1+package container
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/mock"
8+
9+ "gordon/internal/boundaries/out/mocks"
10+ "gordon/internal/domain"
11+)
12+
13+func TestService_ListRoutesWithDetails(t *testing.T) {
14+ runtime := mocks.NewMockContainerRuntime(t)
15+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
16+ ctx := testContext()
17+
18+ svc.containers["app.example.com"] = &domain.Container{
19+ ID: "container-1",
20+ Image: "registry/app:latest",
21+ Status: "running",
22+ Labels: map[string]string{
23+ domain.LabelImage: "app:latest",
24+ },
25+ }
26+
27+ runtime.EXPECT().GetContainerNetwork(mock.Anything, "container-1").Return("gordon-app-example-com", nil)
28+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
29+ {
30+ ID: "attach-1",
31+ Name: "gordon-app-example-com-postgres",
32+ Image: "postgres:15",
33+ Status: "running",
34+ Labels: map[string]string{
35+ domain.LabelAttachment: "true",
36+ domain.LabelAttachedTo: "app.example.com",
37+ domain.LabelImage: "postgres:15",
38+ },
39+ },
40+ {
41+ ID: "attach-2",
42+ Name: "gordon-other-redis",
43+ Image: "redis:7",
44+ Status: "running",
45+ Labels: map[string]string{
46+ domain.LabelAttachment: "true",
47+ domain.LabelAttachedTo: "other.example.com",
48+ domain.LabelImage: "redis:7",
49+ },
50+ },
51+ {
52+ ID: "other",
53+ Name: "gordon-other",
54+ Image: "busybox",
55+ Status: "running",
56+ Labels: map[string]string{
57+ domain.LabelAttachment: "false",
58+ },
59+ },
60+ }, nil)
61+
62+ results := svc.ListRoutesWithDetails(ctx)
63+
64+ assert.Len(t, results, 1)
65+ assert.Equal(t, "app.example.com", results[0].Domain)
66+ assert.Equal(t, "app:latest", results[0].Image)
67+ assert.Equal(t, "container-1", results[0].ContainerID)
68+ assert.Equal(t, "running", results[0].ContainerStatus)
69+ assert.Equal(t, "gordon-app-example-com", results[0].Network)
70+ if assert.Len(t, results[0].Attachments, 1) {
71+ assert.Equal(t, "postgres", results[0].Attachments[0].Name)
72+ assert.Equal(t, "postgres:15", results[0].Attachments[0].Image)
73+ assert.Equal(t, "attach-1", results[0].Attachments[0].ContainerID)
74+ }
75+}
76+
77+func TestService_ListRoutesWithDetails_Empty(t *testing.T) {
78+ runtime := mocks.NewMockContainerRuntime(t)
79+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
80+ ctx := testContext()
81+
82+ results := svc.ListRoutesWithDetails(ctx)
83+
84+ assert.Empty(t, results)
85+}
86+
87+func TestService_ListRoutesWithDetails_NetworkError(t *testing.T) {
88+ runtime := mocks.NewMockContainerRuntime(t)
89+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
90+ ctx := testContext()
91+
92+ svc.containers["app.example.com"] = &domain.Container{
93+ ID: "container-1",
94+ Image: "app:latest",
95+ Status: "running",
96+ }
97+
98+ runtime.EXPECT().GetContainerNetwork(mock.Anything, "container-1").Return("", assert.AnError)
99+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
100+
101+ results := svc.ListRoutesWithDetails(ctx)
102+
103+ assert.Len(t, results, 1)
104+ assert.Equal(t, "", results[0].Network)
105+}
106+
107+func TestService_ListAttachments(t *testing.T) {
108+ runtime := mocks.NewMockContainerRuntime(t)
109+ svc := NewService(runtime, nil, nil, nil, Config{})
110+ ctx := testContext()
111+
112+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
113+ {
114+ ID: "attach-1",
115+ Name: "gordon-app-example-com-postgres",
116+ Image: "postgres:15",
117+ Status: "running",
118+ Labels: map[string]string{
119+ domain.LabelAttachment: "true",
120+ domain.LabelAttachedTo: "app.example.com",
121+ domain.LabelImage: "postgres:15",
122+ },
123+ },
124+ {
125+ ID: "attach-2",
126+ Name: "gordon-other-redis",
127+ Image: "redis:7",
128+ Status: "running",
129+ Labels: map[string]string{
130+ domain.LabelAttachment: "true",
131+ domain.LabelAttachedTo: "other.example.com",
132+ domain.LabelImage: "redis:7",
133+ },
134+ },
135+ }, nil)
136+
137+ attachments := svc.ListAttachments(ctx, "app.example.com")
138+
139+ assert.Len(t, attachments, 1)
140+ assert.Equal(t, "postgres", attachments[0].Name)
141+ assert.Equal(t, "postgres:15", attachments[0].Image)
142+ assert.Equal(t, "attach-1", attachments[0].ContainerID)
143+}
144+
145+func TestService_ListNetworks(t *testing.T) {
146+ runtime := mocks.NewMockContainerRuntime(t)
147+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
148+ ctx := testContext()
149+
150+ runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
151+ {Name: "gordon-app"},
152+ {Name: "bridge"},
153+ {Name: "gordon-shared"},
154+ }, nil)
155+
156+ networks, err := svc.ListNetworks(ctx)
157+
158+ assert.NoError(t, err)
159+ assert.Len(t, networks, 2)
160+ assert.Equal(t, "gordon-app", networks[0].Name)
161+ assert.Equal(t, "gordon-shared", networks[1].Name)
162+}
@@ -0,0 +1,162 @@
1+package container
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/mock"
8+
9+ "gordon/internal/boundaries/out/mocks"
10+ "gordon/internal/domain"
11+)
12+
13+func TestService_ListRoutesWithDetails(t *testing.T) {
14+ runtime := mocks.NewMockContainerRuntime(t)
15+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
16+ ctx := testContext()
17+
18+ svc.containers["app.example.com"] = &domain.Container{
19+ ID: "container-1",
20+ Image: "registry/app:latest",
21+ Status: "running",
22+ Labels: map[string]string{
23+ domain.LabelImage: "app:latest",
24+ },
25+ }
26+
27+ runtime.EXPECT().GetContainerNetwork(mock.Anything, "container-1").Return("gordon-app-example-com", nil)
28+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
29+ {
30+ ID: "attach-1",
31+ Name: "gordon-app-example-com-postgres",
32+ Image: "postgres:15",
33+ Status: "running",
34+ Labels: map[string]string{
35+ domain.LabelAttachment: "true",
36+ domain.LabelAttachedTo: "app.example.com",
37+ domain.LabelImage: "postgres:15",
38+ },
39+ },
40+ {
41+ ID: "attach-2",
42+ Name: "gordon-other-redis",
43+ Image: "redis:7",
44+ Status: "running",
45+ Labels: map[string]string{
46+ domain.LabelAttachment: "true",
47+ domain.LabelAttachedTo: "other.example.com",
48+ domain.LabelImage: "redis:7",
49+ },
50+ },
51+ {
52+ ID: "other",
53+ Name: "gordon-other",
54+ Image: "busybox",
55+ Status: "running",
56+ Labels: map[string]string{
57+ domain.LabelAttachment: "false",
58+ },
59+ },
60+ }, nil)
61+
62+ results := svc.ListRoutesWithDetails(ctx)
63+
64+ assert.Len(t, results, 1)
65+ assert.Equal(t, "app.example.com", results[0].Domain)
66+ assert.Equal(t, "app:latest", results[0].Image)
67+ assert.Equal(t, "container-1", results[0].ContainerID)
68+ assert.Equal(t, "running", results[0].ContainerStatus)
69+ assert.Equal(t, "gordon-app-example-com", results[0].Network)
70+ if assert.Len(t, results[0].Attachments, 1) {
71+ assert.Equal(t, "postgres", results[0].Attachments[0].Name)
72+ assert.Equal(t, "postgres:15", results[0].Attachments[0].Image)
73+ assert.Equal(t, "attach-1", results[0].Attachments[0].ContainerID)
74+ }
75+}
76+
77+func TestService_ListRoutesWithDetails_Empty(t *testing.T) {
78+ runtime := mocks.NewMockContainerRuntime(t)
79+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
80+ ctx := testContext()
81+
82+ results := svc.ListRoutesWithDetails(ctx)
83+
84+ assert.Empty(t, results)
85+}
86+
87+func TestService_ListRoutesWithDetails_NetworkError(t *testing.T) {
88+ runtime := mocks.NewMockContainerRuntime(t)
89+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
90+ ctx := testContext()
91+
92+ svc.containers["app.example.com"] = &domain.Container{
93+ ID: "container-1",
94+ Image: "app:latest",
95+ Status: "running",
96+ }
97+
98+ runtime.EXPECT().GetContainerNetwork(mock.Anything, "container-1").Return("", assert.AnError)
99+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
100+
101+ results := svc.ListRoutesWithDetails(ctx)
102+
103+ assert.Len(t, results, 1)
104+ assert.Equal(t, "", results[0].Network)
105+}
106+
107+func TestService_ListAttachments(t *testing.T) {
108+ runtime := mocks.NewMockContainerRuntime(t)
109+ svc := NewService(runtime, nil, nil, nil, Config{})
110+ ctx := testContext()
111+
112+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
113+ {
114+ ID: "attach-1",
115+ Name: "gordon-app-example-com-postgres",
116+ Image: "postgres:15",
117+ Status: "running",
118+ Labels: map[string]string{
119+ domain.LabelAttachment: "true",
120+ domain.LabelAttachedTo: "app.example.com",
121+ domain.LabelImage: "postgres:15",
122+ },
123+ },
124+ {
125+ ID: "attach-2",
126+ Name: "gordon-other-redis",
127+ Image: "redis:7",
128+ Status: "running",
129+ Labels: map[string]string{
130+ domain.LabelAttachment: "true",
131+ domain.LabelAttachedTo: "other.example.com",
132+ domain.LabelImage: "redis:7",
133+ },
134+ },
135+ }, nil)
136+
137+ attachments := svc.ListAttachments(ctx, "app.example.com")
138+
139+ assert.Len(t, attachments, 1)
140+ assert.Equal(t, "postgres", attachments[0].Name)
141+ assert.Equal(t, "postgres:15", attachments[0].Image)
142+ assert.Equal(t, "attach-1", attachments[0].ContainerID)
143+}
144+
145+func TestService_ListNetworks(t *testing.T) {
146+ runtime := mocks.NewMockContainerRuntime(t)
147+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
148+ ctx := testContext()
149+
150+ runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
151+ {Name: "gordon-app"},
152+ {Name: "bridge"},
153+ {Name: "gordon-shared"},
154+ }, nil)
155+
156+ networks, err := svc.ListNetworks(ctx)
157+
158+ assert.NoError(t, err)
159+ assert.Len(t, networks, 2)
160+ assert.Equal(t, "gordon-app", networks[0].Name)
161+ assert.Equal(t, "gordon-shared", networks[1].Name)
162+}
M internal/usecase/container/service.go +100 -0
@@ -316,6 +316,71 @@ func (s *Service) List(_ context.Context) map[string]*domain.Container {
316316 return result
317317 }
318318
319+// ListRoutesWithDetails returns routes with network and attachment info.
320+// Note: Container map is copied under lock, then external calls are made without lock.
321+// If containers are removed between copy and runtime calls, errors are handled gracefully
322+// (network becomes empty string). This trade-off avoids holding locks during I/O.
323+func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
324+ s.mu.RLock()
325+ containers := make(map[string]*domain.Container, len(s.containers))
326+ maps.Copy(containers, s.containers)
327+ s.mu.RUnlock()
328+
329+ results := make([]domain.RouteInfo, 0, len(containers))
330+ for domainName, container := range containers {
331+ network := ""
332+ image := ""
333+ containerID := ""
334+ status := ""
335+ if container != nil {
336+ containerID = container.ID
337+ status = container.Status
338+ image = container.Image
339+ if container.Labels != nil {
340+ if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
341+ image = labelImage
342+ }
343+ }
344+ if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
345+ network = networkName
346+ }
347+ }
348+
349+ results = append(results, domain.RouteInfo{
350+ Domain: domainName,
351+ Image: image,
352+ ContainerID: containerID,
353+ ContainerStatus: status,
354+ Network: network,
355+ Attachments: s.getAttachmentsForDomain(ctx, domainName),
356+ })
357+ }
358+
359+ return results
360+}
361+
362+// ListAttachments returns attachments for a domain.
363+func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {
364+ return s.getAttachmentsForDomain(ctx, domainName)
365+}
366+
367+// ListNetworks returns Gordon-managed networks.
368+func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
369+ networks, err := s.runtime.ListNetworks(ctx)
370+ if err != nil {
371+ return nil, err
372+ }
373+
374+ var filtered []*domain.NetworkInfo
375+ for _, network := range networks {
376+ if strings.HasPrefix(network.Name, s.config.NetworkPrefix+"-") {
377+ filtered = append(filtered, network)
378+ }
379+ }
380+
381+ return filtered, nil
382+}
383+
319384 // HealthCheck performs health checks on all containers.
320385 func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
321386 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -869,6 +934,41 @@ func (s *Service) deployAttachments(ctx context.Context, domainName, networkName
869934 return nil
870935 }
871936
937+func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
938+ containers, err := s.runtime.ListContainers(ctx, true)
939+ if err != nil {
940+ return nil
941+ }
942+
943+ attachments := make([]domain.Attachment, 0)
944+ for _, container := range containers {
945+ if container.Labels == nil {
946+ continue
947+ }
948+ if container.Labels[domain.LabelAttachment] != "true" {
949+ continue
950+ }
951+ if container.Labels[domain.LabelAttachedTo] != domainName {
952+ continue
953+ }
954+ image := container.Image
955+ serviceName := container.Name
956+ if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
957+ image = labelImage
958+ serviceName = extractServiceName(labelImage)
959+ }
960+ attachment := domain.Attachment{
961+ Name: serviceName,
962+ Image: image,
963+ ContainerID: container.ID,
964+ Status: container.Status,
965+ }
966+ attachments = append(attachments, attachment)
967+ }
968+
969+ return attachments
970+}
971+
872972 func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
873973 log := zerowrap.FromCtx(ctx)
874974
@@ -316,6 +316,71 @@ func (s *Service) List(_ context.Context) map[string]*domain.Container {
316 return result316 return result
317 }317 }
318 318
319+// ListRoutesWithDetails returns routes with network and attachment info.
320+// Note: Container map is copied under lock, then external calls are made without lock.
321+// If containers are removed between copy and runtime calls, errors are handled gracefully
322+// (network becomes empty string). This trade-off avoids holding locks during I/O.
323+func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
324+ s.mu.RLock()
325+ containers := make(map[string]*domain.Container, len(s.containers))
326+ maps.Copy(containers, s.containers)
327+ s.mu.RUnlock()
328+
329+ results := make([]domain.RouteInfo, 0, len(containers))
330+ for domainName, container := range containers {
331+ network := ""
332+ image := ""
333+ containerID := ""
334+ status := ""
335+ if container != nil {
336+ containerID = container.ID
337+ status = container.Status
338+ image = container.Image
339+ if container.Labels != nil {
340+ if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
341+ image = labelImage
342+ }
343+ }
344+ if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
345+ network = networkName
346+ }
347+ }
348+
349+ results = append(results, domain.RouteInfo{
350+ Domain: domainName,
351+ Image: image,
352+ ContainerID: containerID,
353+ ContainerStatus: status,
354+ Network: network,
355+ Attachments: s.getAttachmentsForDomain(ctx, domainName),
356+ })
357+ }
358+
359+ return results
360+}
361+
362+// ListAttachments returns attachments for a domain.
363+func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {
364+ return s.getAttachmentsForDomain(ctx, domainName)
365+}
366+
367+// ListNetworks returns Gordon-managed networks.
368+func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
369+ networks, err := s.runtime.ListNetworks(ctx)
370+ if err != nil {
371+ return nil, err
372+ }
373+
374+ var filtered []*domain.NetworkInfo
375+ for _, network := range networks {
376+ if strings.HasPrefix(network.Name, s.config.NetworkPrefix+"-") {
377+ filtered = append(filtered, network)
378+ }
379+ }
380+
381+ return filtered, nil
382+}
383+
319 // HealthCheck performs health checks on all containers.384 // HealthCheck performs health checks on all containers.
320 func (s *Service) HealthCheck(ctx context.Context) map[string]bool {385 func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
321 ctx = zerowrap.CtxWithFields(ctx, map[string]any{386 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -869,6 +934,41 @@ func (s *Service) deployAttachments(ctx context.Context, domainName, networkName
869 return nil934 return nil
870 }935 }
871 936
937+func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
938+ containers, err := s.runtime.ListContainers(ctx, true)
939+ if err != nil {
940+ return nil
941+ }
942+
943+ attachments := make([]domain.Attachment, 0)
944+ for _, container := range containers {
945+ if container.Labels == nil {
946+ continue
947+ }
948+ if container.Labels[domain.LabelAttachment] != "true" {
949+ continue
950+ }
951+ if container.Labels[domain.LabelAttachedTo] != domainName {
952+ continue
953+ }
954+ image := container.Image
955+ serviceName := container.Name
956+ if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
957+ image = labelImage
958+ serviceName = extractServiceName(labelImage)
959+ }
960+ attachment := domain.Attachment{
961+ Name: serviceName,
962+ Image: image,
963+ ContainerID: container.ID,
964+ Status: container.Status,
965+ }
966+ attachments = append(attachments, attachment)
967+ }
968+
969+ return attachments
970+}
971+
872 func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {972 func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
873 log := zerowrap.FromCtx(ctx)973 log := zerowrap.FromCtx(ctx)
874 974
M internal/usecase/container/service_test.go +10 -9
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "testing"
7+ "time"
78
89 "github.com/bnema/zerowrap"
910 "github.com/stretchr/testify/assert"
@@ -25,7 +26,7 @@ func TestService_Deploy_Success(t *testing.T) {
2526 config := Config{
2627 NetworkIsolation: false,
2728 VolumeAutoCreate: false,
28- ReadinessDelay: 0, // No delay for tests
29+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
2930 }
3031
3132 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -124,7 +125,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
124125 eventBus := mocks.NewMockEventPublisher(t)
125126
126127 config := Config{
127- ReadinessDelay: 0, // No delay for tests
128+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
128129 }
129130 svc := NewService(runtime, envLoader, eventBus, nil, config)
130131 ctx := testContext()
@@ -199,7 +200,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
199200 config := Config{
200201 NetworkIsolation: true,
201202 NetworkPrefix: "gordon",
202- ReadinessDelay: 0, // No delay for tests
203+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
203204 }
204205 svc := NewService(runtime, envLoader, eventBus, nil, config)
205206 ctx := testContext()
@@ -250,7 +251,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
250251 config := Config{
251252 VolumeAutoCreate: true,
252253 VolumePrefix: "gordon",
253- ReadinessDelay: 0, // No delay for tests
254+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
254255 }
255256 svc := NewService(runtime, envLoader, eventBus, nil, config)
256257 ctx := testContext()
@@ -723,7 +724,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
723724 RegistryPort: 5000,
724725 InternalRegistryUsername: "internal",
725726 InternalRegistryPassword: "secret",
726- ReadinessDelay: 0,
727+ ReadinessDelay: time.Millisecond,
727728 }
728729
729730 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -791,7 +792,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
791792 eventBus := mocks.NewMockEventPublisher(t)
792793
793794 config := Config{
794- ReadinessDelay: 0,
795+ ReadinessDelay: time.Millisecond,
795796 }
796797 svc := NewService(runtime, envLoader, eventBus, nil, config)
797798 ctx := testContext()
@@ -834,7 +835,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
834835 eventBus := mocks.NewMockEventPublisher(t)
835836
836837 config := Config{
837- ReadinessDelay: 0,
838+ ReadinessDelay: time.Millisecond,
838839 }
839840 svc := NewService(runtime, envLoader, eventBus, nil, config)
840841 ctx := testContext()
@@ -906,7 +907,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
906907 eventBus := mocks.NewMockEventPublisher(t)
907908
908909 config := Config{
909- ReadinessDelay: 0, // No delay for tests
910+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
910911 }
911912 svc := NewService(runtime, envLoader, eventBus, nil, config)
912913 ctx := testContext()
@@ -990,7 +991,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
990991 eventBus := mocks.NewMockEventPublisher(t)
991992
992993 config := Config{
993- ReadinessDelay: 0, // No delay for tests
994+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
994995 }
995996 svc := NewService(runtime, envLoader, eventBus, nil, config)
996997 ctx := testContext()
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "testing"6 "testing"
7+ "time"
7 8
8 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
@@ -25,7 +26,7 @@ func TestService_Deploy_Success(t *testing.T) {
25 config := Config{26 config := Config{
26 NetworkIsolation: false,27 NetworkIsolation: false,
27 VolumeAutoCreate: false,28 VolumeAutoCreate: false,
28- ReadinessDelay: 0, // No delay for tests29+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
29 }30 }
30 31
31 svc := NewService(runtime, envLoader, eventBus, nil, config)32 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -124,7 +125,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
124 eventBus := mocks.NewMockEventPublisher(t)125 eventBus := mocks.NewMockEventPublisher(t)
125 126
126 config := Config{127 config := Config{
127- ReadinessDelay: 0, // No delay for tests128+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
128 }129 }
129 svc := NewService(runtime, envLoader, eventBus, nil, config)130 svc := NewService(runtime, envLoader, eventBus, nil, config)
130 ctx := testContext()131 ctx := testContext()
@@ -199,7 +200,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
199 config := Config{200 config := Config{
200 NetworkIsolation: true,201 NetworkIsolation: true,
201 NetworkPrefix: "gordon",202 NetworkPrefix: "gordon",
202- ReadinessDelay: 0, // No delay for tests203+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
203 }204 }
204 svc := NewService(runtime, envLoader, eventBus, nil, config)205 svc := NewService(runtime, envLoader, eventBus, nil, config)
205 ctx := testContext()206 ctx := testContext()
@@ -250,7 +251,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
250 config := Config{251 config := Config{
251 VolumeAutoCreate: true,252 VolumeAutoCreate: true,
252 VolumePrefix: "gordon",253 VolumePrefix: "gordon",
253- ReadinessDelay: 0, // No delay for tests254+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
254 }255 }
255 svc := NewService(runtime, envLoader, eventBus, nil, config)256 svc := NewService(runtime, envLoader, eventBus, nil, config)
256 ctx := testContext()257 ctx := testContext()
@@ -723,7 +724,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
723 RegistryPort: 5000,724 RegistryPort: 5000,
724 InternalRegistryUsername: "internal",725 InternalRegistryUsername: "internal",
725 InternalRegistryPassword: "secret",726 InternalRegistryPassword: "secret",
726- ReadinessDelay: 0,727+ ReadinessDelay: time.Millisecond,
727 }728 }
728 729
729 svc := NewService(runtime, envLoader, eventBus, nil, config)730 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -791,7 +792,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
791 eventBus := mocks.NewMockEventPublisher(t)792 eventBus := mocks.NewMockEventPublisher(t)
792 793
793 config := Config{794 config := Config{
794- ReadinessDelay: 0,795+ ReadinessDelay: time.Millisecond,
795 }796 }
796 svc := NewService(runtime, envLoader, eventBus, nil, config)797 svc := NewService(runtime, envLoader, eventBus, nil, config)
797 ctx := testContext()798 ctx := testContext()
@@ -834,7 +835,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
834 eventBus := mocks.NewMockEventPublisher(t)835 eventBus := mocks.NewMockEventPublisher(t)
835 836
836 config := Config{837 config := Config{
837- ReadinessDelay: 0,838+ ReadinessDelay: time.Millisecond,
838 }839 }
839 svc := NewService(runtime, envLoader, eventBus, nil, config)840 svc := NewService(runtime, envLoader, eventBus, nil, config)
840 ctx := testContext()841 ctx := testContext()
@@ -906,7 +907,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
906 eventBus := mocks.NewMockEventPublisher(t)907 eventBus := mocks.NewMockEventPublisher(t)
907 908
908 config := Config{909 config := Config{
909- ReadinessDelay: 0, // No delay for tests910+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
910 }911 }
911 svc := NewService(runtime, envLoader, eventBus, nil, config)912 svc := NewService(runtime, envLoader, eventBus, nil, config)
912 ctx := testContext()913 ctx := testContext()
@@ -990,7 +991,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
990 eventBus := mocks.NewMockEventPublisher(t)991 eventBus := mocks.NewMockEventPublisher(t)
991 992
992 config := Config{993 config := Config{
993- ReadinessDelay: 0, // No delay for tests994+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
994 }995 }
995 svc := NewService(runtime, envLoader, eventBus, nil, config)996 svc := NewService(runtime, envLoader, eventBus, nil, config)
996 ctx := testContext()997 ctx := testContext()

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