detached 9a89e96

feat: expose route network and attachments

commit
9a89e96cfb31f679d340fa38e148fc8d66cd4c77
author
brice · · 2026-01-17 11:28 UTC
parent
d96ffdd
signature
unsigned
10 files · +611 -14
M internal/adapters/in/cli/remote/client.go +68 -0
@@ -119,6 +119,22 @@ func parseResponse(resp *http.Response, target any) error {
119119
120120 // Routes API
121121
122+type RouteInfo struct {
123+ Domain string `json:"domain"`
124+ Image string `json:"image"`
125+ ContainerID string `json:"container_id"`
126+ ContainerStatus string `json:"container_status"`
127+ Network string `json:"network"`
128+ Attachments []Attachment `json:"attachments"`
129+}
130+
131+type Attachment struct {
132+ Name string `json:"name"`
133+ Image string `json:"image"`
134+ ContainerID string `json:"container_id"`
135+ Status string `json:"status"`
136+}
137+
122138 // ListRoutes returns all configured routes.
123139 func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
124140 resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
@@ -136,6 +152,58 @@ func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
136152 return result.Routes, nil
137153 }
138154
155+// ListRoutesWithDetails returns routes with network and attachment info.
156+func (c *Client) ListRoutesWithDetails(ctx context.Context) ([]RouteInfo, error) {
157+ resp, err := c.request(ctx, http.MethodGet, "/routes?detailed=true", nil)
158+ if err != nil {
159+ return nil, err
160+ }
161+
162+ var result struct {
163+ Routes []RouteInfo `json:"routes"`
164+ }
165+ if err := parseResponse(resp, &result); err != nil {
166+ return nil, err
167+ }
168+
169+ return result.Routes, nil
170+}
171+
172+// ListNetworks returns Gordon-managed networks.
173+func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
174+ resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
175+ if err != nil {
176+ return nil, err
177+ }
178+
179+ var result struct {
180+ Networks []*domain.NetworkInfo `json:"networks"`
181+ }
182+ if err := parseResponse(resp, &result); err != nil {
183+ return nil, err
184+ }
185+
186+ return result.Networks, nil
187+}
188+
189+// ListAttachments returns attachments for a domain.
190+func (c *Client) ListAttachments(ctx context.Context, routeDomain string) ([]Attachment, error) {
191+ path := "/routes/" + url.PathEscape(routeDomain) + "/attachments"
192+ resp, err := c.request(ctx, http.MethodGet, path, nil)
193+ if err != nil {
194+ return nil, err
195+ }
196+
197+ var result struct {
198+ Attachments []Attachment `json:"attachments"`
199+ }
200+ if err := parseResponse(resp, &result); err != nil {
201+ return nil, err
202+ }
203+
204+ return result.Attachments, nil
205+}
206+
139207 // GetRoute returns a specific route by domain.
140208 func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
141209 resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
@@ -119,6 +119,22 @@ func parseResponse(resp *http.Response, target any) error {
119 119
120 // Routes API120 // Routes API
121 121
122+type RouteInfo struct {
123+ Domain string `json:"domain"`
124+ Image string `json:"image"`
125+ ContainerID string `json:"container_id"`
126+ ContainerStatus string `json:"container_status"`
127+ Network string `json:"network"`
128+ Attachments []Attachment `json:"attachments"`
129+}
130+
131+type Attachment struct {
132+ Name string `json:"name"`
133+ Image string `json:"image"`
134+ ContainerID string `json:"container_id"`
135+ Status string `json:"status"`
136+}
137+
122 // ListRoutes returns all configured routes.138 // ListRoutes returns all configured routes.
123 func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {139 func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
124 resp, err := c.request(ctx, http.MethodGet, "/routes", nil)140 resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
@@ -136,6 +152,58 @@ func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
136 return result.Routes, nil152 return result.Routes, nil
137 }153 }
138 154
155+// ListRoutesWithDetails returns routes with network and attachment info.
156+func (c *Client) ListRoutesWithDetails(ctx context.Context) ([]RouteInfo, error) {
157+ resp, err := c.request(ctx, http.MethodGet, "/routes?detailed=true", nil)
158+ if err != nil {
159+ return nil, err
160+ }
161+
162+ var result struct {
163+ Routes []RouteInfo `json:"routes"`
164+ }
165+ if err := parseResponse(resp, &result); err != nil {
166+ return nil, err
167+ }
168+
169+ return result.Routes, nil
170+}
171+
172+// ListNetworks returns Gordon-managed networks.
173+func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
174+ resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
175+ if err != nil {
176+ return nil, err
177+ }
178+
179+ var result struct {
180+ Networks []*domain.NetworkInfo `json:"networks"`
181+ }
182+ if err := parseResponse(resp, &result); err != nil {
183+ return nil, err
184+ }
185+
186+ return result.Networks, nil
187+}
188+
189+// ListAttachments returns attachments for a domain.
190+func (c *Client) ListAttachments(ctx context.Context, routeDomain string) ([]Attachment, error) {
191+ path := "/routes/" + url.PathEscape(routeDomain) + "/attachments"
192+ resp, err := c.request(ctx, http.MethodGet, path, nil)
193+ if err != nil {
194+ return nil, err
195+ }
196+
197+ var result struct {
198+ Attachments []Attachment `json:"attachments"`
199+ }
200+ if err := parseResponse(resp, &result); err != nil {
201+ return nil, err
202+ }
203+
204+ return result.Attachments, nil
205+}
206+
139 // GetRoute returns a specific route by domain.207 // GetRoute returns a specific route by domain.
140 func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {208 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)209 resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
M internal/adapters/in/cli/routes.go +53 -14
@@ -29,25 +29,44 @@ 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 {
3434 // 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] + "…"
35+ available := maxLen - len("@sha256:") - len(digest) - 3 // 3 for ellipsis
36+ if available > 0 {
37+ if len(name) > available {
38+ name = name[:available]
39+ }
40+ name = name + "..."
41+ short = fmt.Sprintf("%s@sha256:%s", name, digest)
3842 }
39- short = fmt.Sprintf("%s@sha256:%s…", name, digest)
4043 }
4144 return short
4245 }
4346
4447 // Regular tag: truncate if needed
4548 if len(image) > maxLen {
46- return image[:maxLen-1] + "…"
49+ if maxLen <= 3 {
50+ return image[:maxLen]
51+ }
52+ return image[:maxLen-3] + "..."
4753 }
4854 return image
4955 }
5056
57+func truncateNetwork(network string, maxLen int) string {
58+ if network == "" || network == "-" {
59+ return "-"
60+ }
61+ if len(network) > maxLen {
62+ if maxLen <= 3 {
63+ return network[:maxLen]
64+ }
65+ return network[:maxLen-3] + "..."
66+ }
67+ return network
68+}
69+
5170 // newRoutesCmd creates the routes command group.
5271 func newRoutesCmd() *cobra.Command {
5372 cmd := &cobra.Command{
@@ -107,7 +126,7 @@ func newRoutesListCmd() *cobra.Command {
107126
108127 // runRoutesListRemote lists routes from a remote Gordon instance.
109128 func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
110- routes, err := client.ListRoutes(ctx)
129+ routes, err := client.ListRoutesWithDetails(ctx)
111130 if err != nil {
112131 return fmt.Errorf("failed to list routes: %w", err)
113132 }
@@ -123,16 +142,20 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
123142 health = make(map[string]*remote.RouteHealth)
124143 }
125144
126- // Build table rows
127145 const imageColWidth = 35
128- rows := make([][]string, len(routes))
129- for i, route := range routes {
146+ const networkColWidth = 22
147+ rows := make([][]string, 0, len(routes))
148+ for _, route := range routes {
130149 routeHealth := health[route.Domain]
131150
132151 // Container status column
133- containerStatus := "unknown"
134- if routeHealth != nil {
135- containerStatus = routeHealth.ContainerStatus
152+ containerStatus := route.ContainerStatus
153+ if containerStatus == "" {
154+ if routeHealth != nil {
155+ containerStatus = routeHealth.ContainerStatus
156+ } else {
157+ containerStatus = "unknown"
158+ }
136159 }
137160 containerBadge := components.ContainerStatusBadge(containerStatus)
138161
@@ -140,7 +163,22 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
140163 httpStatus := formatHTTPStatus(routeHealth)
141164
142165 displayImage := truncateImage(route.Image, imageColWidth)
143- rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}
166+ displayNetwork := truncateNetwork(route.Network, networkColWidth)
167+ rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})
168+
169+ for i, attachment := range route.Attachments {
170+ prefix := "|-"
171+ if i == len(route.Attachments)-1 {
172+ prefix = "`-"
173+ }
174+ attachmentName := prefix + " " + attachment.Name
175+ if len(attachmentName) > 25 {
176+ attachmentName = attachmentName[:22] + "..."
177+ }
178+ attachmentStatus := components.ContainerStatusBadge(attachment.Status)
179+ attachmentImage := truncateImage(attachment.Image, imageColWidth)
180+ rows = append(rows, []string{attachmentName, attachmentImage, "-", attachmentStatus, "-"})
181+ }
144182 }
145183
146184 // Render table
@@ -148,6 +186,7 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
148186 components.WithColumns([]components.TableColumn{
149187 {Title: "Domain", Width: 25},
150188 {Title: "Image", Width: imageColWidth},
189+ {Title: "Network", Width: networkColWidth},
151190 {Title: "Container", Width: 12},
152191 {Title: "HTTP", Width: 14},
153192 }),
@@ -29,25 +29,44 @@ 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 name if still too long
35- available := maxLen - len("@sha256:") - 12 - 1 // 1 for ellipsis35+ available := maxLen - len("@sha256:") - len(digest) - 3 // 3 for ellipsis
36- if available > 3 {36+ if available > 0 {
37- name = name[:available-1] + "…"37+ if len(name) > available {
38+ name = name[:available]
39+ }
40+ name = name + "..."
41+ short = fmt.Sprintf("%s@sha256:%s", name, digest)
38 }42 }
39- short = fmt.Sprintf("%s@sha256:%s…", name, digest)
40 }43 }
41 return short44 return short
42 }45 }
43 46
44 // Regular tag: truncate if needed47 // Regular tag: truncate if needed
45 if len(image) > maxLen {48 if len(image) > maxLen {
46- return image[:maxLen-1] + "…"49+ if maxLen <= 3 {
50+ return image[:maxLen]
51+ }
52+ return image[:maxLen-3] + "..."
47 }53 }
48 return image54 return image
49 }55 }
50 56
57+func truncateNetwork(network string, maxLen int) string {
58+ if network == "" || network == "-" {
59+ return "-"
60+ }
61+ if len(network) > maxLen {
62+ if maxLen <= 3 {
63+ return network[:maxLen]
64+ }
65+ return network[:maxLen-3] + "..."
66+ }
67+ return network
68+}
69+
51 // newRoutesCmd creates the routes command group.70 // newRoutesCmd creates the routes command group.
52 func newRoutesCmd() *cobra.Command {71 func newRoutesCmd() *cobra.Command {
53 cmd := &cobra.Command{72 cmd := &cobra.Command{
@@ -107,7 +126,7 @@ func newRoutesListCmd() *cobra.Command {
107 126
108 // runRoutesListRemote lists routes from a remote Gordon instance.127 // runRoutesListRemote lists routes from a remote Gordon instance.
109 func runRoutesListRemote(ctx context.Context, client *remote.Client) error {128 func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
110- routes, err := client.ListRoutes(ctx)129+ routes, err := client.ListRoutesWithDetails(ctx)
111 if err != nil {130 if err != nil {
112 return fmt.Errorf("failed to list routes: %w", err)131 return fmt.Errorf("failed to list routes: %w", err)
113 }132 }
@@ -123,16 +142,20 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
123 health = make(map[string]*remote.RouteHealth)142 health = make(map[string]*remote.RouteHealth)
124 }143 }
125 144
126- // Build table rows
127 const imageColWidth = 35145 const imageColWidth = 35
128- rows := make([][]string, len(routes))146+ const networkColWidth = 22
129- for i, route := range routes {147+ rows := make([][]string, 0, len(routes))
148+ for _, route := range routes {
130 routeHealth := health[route.Domain]149 routeHealth := health[route.Domain]
131 150
132 // Container status column151 // Container status column
133- containerStatus := "unknown"152+ containerStatus := route.ContainerStatus
134- if routeHealth != nil {153+ if containerStatus == "" {
135- containerStatus = routeHealth.ContainerStatus154+ if routeHealth != nil {
155+ containerStatus = routeHealth.ContainerStatus
156+ } else {
157+ containerStatus = "unknown"
158+ }
136 }159 }
137 containerBadge := components.ContainerStatusBadge(containerStatus)160 containerBadge := components.ContainerStatusBadge(containerStatus)
138 161
@@ -140,7 +163,22 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
140 httpStatus := formatHTTPStatus(routeHealth)163 httpStatus := formatHTTPStatus(routeHealth)
141 164
142 displayImage := truncateImage(route.Image, imageColWidth)165 displayImage := truncateImage(route.Image, imageColWidth)
143- rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}166+ displayNetwork := truncateNetwork(route.Network, networkColWidth)
167+ rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})
168+
169+ for i, attachment := range route.Attachments {
170+ prefix := "|-"
171+ if i == len(route.Attachments)-1 {
172+ prefix = "`-"
173+ }
174+ attachmentName := prefix + " " + attachment.Name
175+ if len(attachmentName) > 25 {
176+ attachmentName = attachmentName[:22] + "..."
177+ }
178+ attachmentStatus := components.ContainerStatusBadge(attachment.Status)
179+ attachmentImage := truncateImage(attachment.Image, imageColWidth)
180+ rows = append(rows, []string{attachmentName, attachmentImage, "-", attachmentStatus, "-"})
181+ }
144 }182 }
145 183
146 // Render table184 // Render table
@@ -148,6 +186,7 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
148 components.WithColumns([]components.TableColumn{186 components.WithColumns([]components.TableColumn{
149 {Title: "Domain", Width: 25},187 {Title: "Domain", Width: 25},
150 {Title: "Image", Width: imageColWidth},188 {Title: "Image", Width: imageColWidth},
189+ {Title: "Network", Width: networkColWidth},
151 {Title: "Container", Width: 12},190 {Title: "Container", Width: 12},
152 {Title: "HTTP", Width: 14},191 {Title: "HTTP", Width: 14},
153 }),192 }),
M internal/adapters/in/http/admin/handler.go +86 -0
@@ -34,6 +34,22 @@ type Handler struct {
3434 log zerowrap.Logger
3535 }
3636
37+type routeInfoResponse struct {
38+ Domain string `json:"domain"`
39+ Image string `json:"image"`
40+ ContainerID string `json:"container_id"`
41+ ContainerStatus string `json:"container_status"`
42+ Network string `json:"network"`
43+ Attachments []attachmentResponse `json:"attachments"`
44+}
45+
46+type attachmentResponse struct {
47+ Name string `json:"name"`
48+ Image string `json:"image"`
49+ ContainerID string `json:"container_id"`
50+ Status string `json:"status"`
51+}
52+
3753 // NewHandler creates a new admin HTTP handler.
3854 func NewHandler(
3955 configSvc in.ConfigService,
@@ -83,6 +99,8 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
8399 switch {
84100 case path == "/routes" || strings.HasPrefix(path, "/routes/"):
85101 h.handleRoutes(w, r, path)
102+ case path == "/networks":
103+ h.handleNetworks(w, r)
86104 case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):
87105 h.handleSecrets(w, r, path)
88106 case path == "/deploy" || strings.HasPrefix(path, "/deploy/"):
@@ -148,11 +166,57 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
148166 }
149167
150168 if routeDomain == "" {
169+ if r.URL.Query().Get("detailed") == "true" {
170+ routes := h.containerSvc.ListRoutesWithDetails(ctx)
171+ response := make([]routeInfoResponse, 0, len(routes))
172+ for _, route := range routes {
173+ attachments := make([]attachmentResponse, 0, len(route.Attachments))
174+ for _, attachment := range route.Attachments {
175+ attachments = append(attachments, attachmentResponse{
176+ Name: attachment.Name,
177+ Image: attachment.Image,
178+ ContainerID: attachment.ContainerID,
179+ Status: attachment.Status,
180+ })
181+ }
182+ response = append(response, routeInfoResponse{
183+ Domain: route.Domain,
184+ Image: route.Image,
185+ ContainerID: route.ContainerID,
186+ ContainerStatus: route.ContainerStatus,
187+ Network: route.Network,
188+ Attachments: attachments,
189+ })
190+ }
191+ h.sendJSON(w, http.StatusOK, map[string]any{"routes": response})
192+ return
193+ }
194+
151195 routes := h.configSvc.GetRoutes(ctx)
152196 h.sendJSON(w, http.StatusOK, map[string]any{"routes": routes})
153197 return
154198 }
155199
200+ if strings.HasSuffix(routeDomain, "/attachments") {
201+ parentDomain := strings.TrimSuffix(routeDomain, "/attachments")
202+ if parentDomain == "" {
203+ h.sendError(w, http.StatusBadRequest, "domain required in path")
204+ return
205+ }
206+ attachments := h.containerSvc.ListAttachments(ctx, parentDomain)
207+ response := make([]attachmentResponse, 0, len(attachments))
208+ for _, attachment := range attachments {
209+ response = append(response, attachmentResponse{
210+ Name: attachment.Name,
211+ Image: attachment.Image,
212+ ContainerID: attachment.ContainerID,
213+ Status: attachment.Status,
214+ })
215+ }
216+ h.sendJSON(w, http.StatusOK, map[string]any{"attachments": response})
217+ return
218+ }
219+
156220 route, err := h.configSvc.GetRoute(ctx, routeDomain)
157221 if err != nil {
158222 h.sendError(w, http.StatusNotFound, "route not found")
@@ -265,6 +329,28 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
265329 h.sendJSON(w, http.StatusOK, map[string]string{"status": "removed"})
266330 }
267331
332+func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
333+ if r.Method != http.MethodGet {
334+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
335+ return
336+ }
337+
338+ ctx := r.Context()
339+
340+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
341+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
342+ return
343+ }
344+
345+ networks, err := h.containerSvc.ListNetworks(ctx)
346+ if err != nil {
347+ h.sendError(w, http.StatusInternalServerError, "failed to list networks")
348+ return
349+ }
350+
351+ h.sendJSON(w, http.StatusOK, map[string]any{"networks": networks})
352+}
353+
268354 // handleSecrets handles /admin/secrets endpoints.
269355 func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
270356 ctx := r.Context()
@@ -34,6 +34,22 @@ type Handler struct {
34 log zerowrap.Logger34 log zerowrap.Logger
35 }35 }
36 36
37+type routeInfoResponse struct {
38+ Domain string `json:"domain"`
39+ Image string `json:"image"`
40+ ContainerID string `json:"container_id"`
41+ ContainerStatus string `json:"container_status"`
42+ Network string `json:"network"`
43+ Attachments []attachmentResponse `json:"attachments"`
44+}
45+
46+type attachmentResponse struct {
47+ Name string `json:"name"`
48+ Image string `json:"image"`
49+ ContainerID string `json:"container_id"`
50+ Status string `json:"status"`
51+}
52+
37 // NewHandler creates a new admin HTTP handler.53 // NewHandler creates a new admin HTTP handler.
38 func NewHandler(54 func NewHandler(
39 configSvc in.ConfigService,55 configSvc in.ConfigService,
@@ -83,6 +99,8 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
83 switch {99 switch {
84 case path == "/routes" || strings.HasPrefix(path, "/routes/"):100 case path == "/routes" || strings.HasPrefix(path, "/routes/"):
85 h.handleRoutes(w, r, path)101 h.handleRoutes(w, r, path)
102+ case path == "/networks":
103+ h.handleNetworks(w, r)
86 case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):104 case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):
87 h.handleSecrets(w, r, path)105 h.handleSecrets(w, r, path)
88 case path == "/deploy" || strings.HasPrefix(path, "/deploy/"):106 case path == "/deploy" || strings.HasPrefix(path, "/deploy/"):
@@ -148,11 +166,57 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
148 }166 }
149 167
150 if routeDomain == "" {168 if routeDomain == "" {
169+ if r.URL.Query().Get("detailed") == "true" {
170+ routes := h.containerSvc.ListRoutesWithDetails(ctx)
171+ response := make([]routeInfoResponse, 0, len(routes))
172+ for _, route := range routes {
173+ attachments := make([]attachmentResponse, 0, len(route.Attachments))
174+ for _, attachment := range route.Attachments {
175+ attachments = append(attachments, attachmentResponse{
176+ Name: attachment.Name,
177+ Image: attachment.Image,
178+ ContainerID: attachment.ContainerID,
179+ Status: attachment.Status,
180+ })
181+ }
182+ response = append(response, routeInfoResponse{
183+ Domain: route.Domain,
184+ Image: route.Image,
185+ ContainerID: route.ContainerID,
186+ ContainerStatus: route.ContainerStatus,
187+ Network: route.Network,
188+ Attachments: attachments,
189+ })
190+ }
191+ h.sendJSON(w, http.StatusOK, map[string]any{"routes": response})
192+ return
193+ }
194+
151 routes := h.configSvc.GetRoutes(ctx)195 routes := h.configSvc.GetRoutes(ctx)
152 h.sendJSON(w, http.StatusOK, map[string]any{"routes": routes})196 h.sendJSON(w, http.StatusOK, map[string]any{"routes": routes})
153 return197 return
154 }198 }
155 199
200+ if strings.HasSuffix(routeDomain, "/attachments") {
201+ parentDomain := strings.TrimSuffix(routeDomain, "/attachments")
202+ if parentDomain == "" {
203+ h.sendError(w, http.StatusBadRequest, "domain required in path")
204+ return
205+ }
206+ attachments := h.containerSvc.ListAttachments(ctx, parentDomain)
207+ response := make([]attachmentResponse, 0, len(attachments))
208+ for _, attachment := range attachments {
209+ response = append(response, attachmentResponse{
210+ Name: attachment.Name,
211+ Image: attachment.Image,
212+ ContainerID: attachment.ContainerID,
213+ Status: attachment.Status,
214+ })
215+ }
216+ h.sendJSON(w, http.StatusOK, map[string]any{"attachments": response})
217+ return
218+ }
219+
156 route, err := h.configSvc.GetRoute(ctx, routeDomain)220 route, err := h.configSvc.GetRoute(ctx, routeDomain)
157 if err != nil {221 if err != nil {
158 h.sendError(w, http.StatusNotFound, "route not found")222 h.sendError(w, http.StatusNotFound, "route not found")
@@ -265,6 +329,28 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
265 h.sendJSON(w, http.StatusOK, map[string]string{"status": "removed"})329 h.sendJSON(w, http.StatusOK, map[string]string{"status": "removed"})
266 }330 }
267 331
332+func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
333+ if r.Method != http.MethodGet {
334+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
335+ return
336+ }
337+
338+ ctx := r.Context()
339+
340+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
341+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
342+ return
343+ }
344+
345+ networks, err := h.containerSvc.ListNetworks(ctx)
346+ if err != nil {
347+ h.sendError(w, http.StatusInternalServerError, "failed to list networks")
348+ return
349+ }
350+
351+ h.sendJSON(w, http.StatusOK, map[string]any{"networks": networks})
352+}
353+
268 // handleSecrets handles /admin/secrets endpoints.354 // handleSecrets handles /admin/secrets endpoints.
269 func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {355 func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
270 ctx := r.Context()356 ctx := r.Context()
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
M internal/usecase/container/service.go +97 -0
@@ -316,6 +316,68 @@ 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+func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
321+ s.mu.RLock()
322+ containers := make(map[string]*domain.Container, len(s.containers))
323+ maps.Copy(containers, s.containers)
324+ s.mu.RUnlock()
325+
326+ results := make([]domain.RouteInfo, 0, len(containers))
327+ for domainName, container := range containers {
328+ network := ""
329+ image := ""
330+ containerID := ""
331+ status := ""
332+ if container != nil {
333+ containerID = container.ID
334+ status = container.Status
335+ image = container.Image
336+ if container.Labels != nil {
337+ if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
338+ image = labelImage
339+ }
340+ }
341+ if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
342+ network = networkName
343+ }
344+ }
345+
346+ results = append(results, domain.RouteInfo{
347+ Domain: domainName,
348+ Image: image,
349+ ContainerID: containerID,
350+ ContainerStatus: status,
351+ Network: network,
352+ Attachments: s.getAttachmentsForDomain(ctx, domainName),
353+ })
354+ }
355+
356+ return results
357+}
358+
359+// ListAttachments returns attachments for a domain.
360+func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {
361+ return s.getAttachmentsForDomain(ctx, domainName)
362+}
363+
364+// ListNetworks returns Gordon-managed networks.
365+func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
366+ networks, err := s.runtime.ListNetworks(ctx)
367+ if err != nil {
368+ return nil, err
369+ }
370+
371+ var filtered []*domain.NetworkInfo
372+ for _, network := range networks {
373+ if strings.HasPrefix(network.Name, s.config.NetworkPrefix+"-") {
374+ filtered = append(filtered, network)
375+ }
376+ }
377+
378+ return filtered, nil
379+}
380+
319381 // HealthCheck performs health checks on all containers.
320382 func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
321383 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -869,6 +931,41 @@ func (s *Service) deployAttachments(ctx context.Context, domainName, networkName
869931 return nil
870932 }
871933
934+func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
935+ containers, err := s.runtime.ListContainers(ctx, true)
936+ if err != nil {
937+ return nil
938+ }
939+
940+ attachments := make([]domain.Attachment, 0)
941+ for _, container := range containers {
942+ if container.Labels == nil {
943+ continue
944+ }
945+ if container.Labels[domain.LabelAttachment] != "true" {
946+ continue
947+ }
948+ if container.Labels[domain.LabelAttachedTo] != domainName {
949+ continue
950+ }
951+ image := container.Image
952+ serviceName := container.Name
953+ if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
954+ image = labelImage
955+ serviceName = extractServiceName(labelImage)
956+ }
957+ attachment := domain.Attachment{
958+ Name: serviceName,
959+ Image: image,
960+ ContainerID: container.ID,
961+ Status: container.Status,
962+ }
963+ attachments = append(attachments, attachment)
964+ }
965+
966+ return attachments
967+}
968+
872969 func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
873970 log := zerowrap.FromCtx(ctx)
874971
@@ -316,6 +316,68 @@ 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+func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
321+ s.mu.RLock()
322+ containers := make(map[string]*domain.Container, len(s.containers))
323+ maps.Copy(containers, s.containers)
324+ s.mu.RUnlock()
325+
326+ results := make([]domain.RouteInfo, 0, len(containers))
327+ for domainName, container := range containers {
328+ network := ""
329+ image := ""
330+ containerID := ""
331+ status := ""
332+ if container != nil {
333+ containerID = container.ID
334+ status = container.Status
335+ image = container.Image
336+ if container.Labels != nil {
337+ if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
338+ image = labelImage
339+ }
340+ }
341+ if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
342+ network = networkName
343+ }
344+ }
345+
346+ results = append(results, domain.RouteInfo{
347+ Domain: domainName,
348+ Image: image,
349+ ContainerID: containerID,
350+ ContainerStatus: status,
351+ Network: network,
352+ Attachments: s.getAttachmentsForDomain(ctx, domainName),
353+ })
354+ }
355+
356+ return results
357+}
358+
359+// ListAttachments returns attachments for a domain.
360+func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {
361+ return s.getAttachmentsForDomain(ctx, domainName)
362+}
363+
364+// ListNetworks returns Gordon-managed networks.
365+func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
366+ networks, err := s.runtime.ListNetworks(ctx)
367+ if err != nil {
368+ return nil, err
369+ }
370+
371+ var filtered []*domain.NetworkInfo
372+ for _, network := range networks {
373+ if strings.HasPrefix(network.Name, s.config.NetworkPrefix+"-") {
374+ filtered = append(filtered, network)
375+ }
376+ }
377+
378+ return filtered, nil
379+}
380+
319 // HealthCheck performs health checks on all containers.381 // HealthCheck performs health checks on all containers.
320 func (s *Service) HealthCheck(ctx context.Context) map[string]bool {382 func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
321 ctx = zerowrap.CtxWithFields(ctx, map[string]any{383 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -869,6 +931,41 @@ func (s *Service) deployAttachments(ctx context.Context, domainName, networkName
869 return nil931 return nil
870 }932 }
871 933
934+func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
935+ containers, err := s.runtime.ListContainers(ctx, true)
936+ if err != nil {
937+ return nil
938+ }
939+
940+ attachments := make([]domain.Attachment, 0)
941+ for _, container := range containers {
942+ if container.Labels == nil {
943+ continue
944+ }
945+ if container.Labels[domain.LabelAttachment] != "true" {
946+ continue
947+ }
948+ if container.Labels[domain.LabelAttachedTo] != domainName {
949+ continue
950+ }
951+ image := container.Image
952+ serviceName := container.Name
953+ if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
954+ image = labelImage
955+ serviceName = extractServiceName(labelImage)
956+ }
957+ attachment := domain.Attachment{
958+ Name: serviceName,
959+ Image: image,
960+ ContainerID: container.ID,
961+ Status: container.Status,
962+ }
963+ attachments = append(attachments, attachment)
964+ }
965+
966+ return attachments
967+}
968+
872 func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {969 func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
873 log := zerowrap.FromCtx(ctx)970 log := zerowrap.FromCtx(ctx)
874 971

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