1package cli
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
9 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
10 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
11 "github.com/bnema/gordon/internal/domain"
12
13 "github.com/spf13/cobra"
14)
15
16// configPath for local operations. If empty, config is auto-discovered
17// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
18var configPath string
19
20// truncateImage shortens long image references for display.
21// For digests (image@sha256:...), shows first 12 chars of digest.
22// For tags, truncates to maxLen with ellipsis if needed.
23func truncateImage(image string, maxLen int) string {
24 // Handle digest references: image@sha256:abc123...
25 if maxLen <= 0 {
26 return ""
27 }
28
29 if idx := strings.Index(image, "@sha256:"); idx != -1 {
30 name := image[:idx]
31 digest := image[idx+8:] // Skip "@sha256:"
32 if len(digest) > 12 {
33 digest = digest[:12]
34 }
35 short := fmt.Sprintf("%s@sha256:%s", name, digest)
36 if len(short) <= maxLen {
37 return short
38 }
39 // Truncate from the end to maintain valid reference format
40 if maxLen <= 3 {
41 return short[:maxLen]
42 }
43 return short[:maxLen-3] + "..."
44 }
45
46 // Regular tag: truncate if needed
47 if len(image) <= maxLen {
48 return image
49 }
50 if maxLen <= 3 {
51 return image[:maxLen]
52 }
53 return image[:maxLen-3] + "..."
54}
55
56func truncateNetwork(network string, maxLen int) string {
57 if network == "" || network == "-" {
58 return "-"
59 }
60 if maxLen <= 0 {
61 return ""
62 }
63 if len(network) <= maxLen {
64 return network
65 }
66 if maxLen <= 3 {
67 return network[:maxLen]
68 }
69 return network[:maxLen-3] + "..."
70}
71
72// newRoutesCmd creates the routes command group.
73func newRoutesCmd() *cobra.Command {
74 cmd := &cobra.Command{
75 Use: "routes",
76 Short: "Manage routes",
77 Long: `Manage Gordon routes. Routes map domains to container images.
78
79When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
80these commands operate on the remote server. Otherwise, they require access to
81the local Gordon configuration.`,
82 }
83
84 cmd.AddCommand(newRoutesListCmd())
85 cmd.AddCommand(newRoutesAddCmd())
86 cmd.AddCommand(newRoutesRemoveCmd())
87
88 return cmd
89}
90
91// formatHTTPStatus formats the HTTP status for display.
92func formatHTTPStatus(health *remote.RouteHealth) string {
93 if health == nil {
94 return styles.Theme.Muted.Render("-")
95 }
96 if health.HTTPStatus == 0 {
97 if health.Error != "" {
98 return styles.Theme.BadgeError.Render("err")
99 }
100 return styles.Theme.Muted.Render("-")
101 }
102 status := fmt.Sprintf("%d", health.HTTPStatus)
103 if health.ResponseTimeMs > 0 {
104 status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs)
105 }
106 if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
107 return styles.Theme.BadgeSuccess.Render(status)
108 }
109 return styles.Theme.BadgeError.Render(status)
110}
111
112// newRoutesListCmd creates the routes list command.
113func newRoutesListCmd() *cobra.Command {
114 return &cobra.Command{
115 Use: "list",
116 Short: "List all routes",
117 RunE: func(cmd *cobra.Command, args []string) error {
118 ctx := context.Background()
119
120 client, isRemote := GetRemoteClient()
121 if isRemote {
122 return runRoutesListRemote(ctx, client)
123 }
124 return runRoutesListLocal(ctx, configPath)
125 },
126 }
127}
128
129// runRoutesListRemote lists routes from a remote Gordon instance.
130func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
131 routes, err := client.ListRoutesWithDetails(ctx)
132 if err != nil {
133 return fmt.Errorf("failed to list routes: %w", err)
134 }
135
136 if len(routes) == 0 {
137 fmt.Println(styles.Theme.Muted.Render("No routes configured"))
138 return nil
139 }
140
141 // Get health status for each route (includes container status and HTTP probe)
142 health, _ := client.GetHealth(ctx)
143 if health == nil {
144 health = make(map[string]*remote.RouteHealth)
145 }
146
147 const imageColWidth = 35
148 const networkColWidth = 22
149 rows := make([][]string, 0, len(routes))
150 for _, route := range routes {
151 routeHealth := health[route.Domain]
152
153 // Container status column
154 containerStatus := route.ContainerStatus
155 if containerStatus == "" {
156 if routeHealth != nil {
157 containerStatus = routeHealth.ContainerStatus
158 } else {
159 containerStatus = "unknown"
160 }
161 }
162 containerBadge := components.ContainerStatusBadge(containerStatus)
163
164 // HTTP status column
165 httpStatus := formatHTTPStatus(routeHealth)
166
167 displayImage := truncateImage(route.Image, imageColWidth)
168 displayNetwork := truncateNetwork(route.Network, networkColWidth)
169 rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})
170
171 for i, attachment := range route.Attachments {
172 // Unicode box-drawing tree structure for nested attachments
173 prefix := styles.IconTreeBranch + styles.IconTreeLine
174 if i == len(route.Attachments)-1 {
175 prefix = styles.IconTreeLast + styles.IconTreeLine
176 }
177 attachmentName := prefix + " " + attachment.Name
178 runes := []rune(attachmentName)
179 if len(runes) > 25 {
180 attachmentName = string(runes[:22]) + "..."
181 }
182 attachmentStatus := components.ContainerStatusBadge(attachment.Status)
183 attachmentImage := truncateImage(attachment.Image, imageColWidth)
184 // Display the attachment's actual network in the routes table
185 attachmentNetwork := truncateNetwork(attachment.Network, networkColWidth)
186 rows = append(rows, []string{attachmentName, attachmentImage, attachmentNetwork, attachmentStatus, "-"})
187 }
188 }
189
190 // Render table
191 table := components.NewTable(
192 components.WithColumns([]components.TableColumn{
193 {Title: "Domain", Width: 25},
194 {Title: "Image", Width: imageColWidth},
195 {Title: "Network", Width: networkColWidth},
196 {Title: "Container", Width: 12},
197 {Title: "HTTP", Width: 14},
198 }),
199 components.WithRows(rows),
200 )
201
202 fmt.Println(styles.Theme.Title.Render("Routes"))
203 fmt.Println()
204 fmt.Println(table.View())
205
206 return nil
207}
208
209// runRoutesListLocal lists routes from local configuration.
210func runRoutesListLocal(ctx context.Context, cfgPath string) error {
211 local, err := GetLocalServices(cfgPath)
212 if err != nil {
213 return fmt.Errorf("failed to initialize local services: %w", err)
214 }
215
216 routes := local.GetConfigService().GetRoutes(ctx)
217
218 if len(routes) == 0 {
219 fmt.Println(styles.Theme.Muted.Render("No routes configured"))
220 return nil
221 }
222
223 // Build table rows (no health info in local mode)
224 const imageColWidth = 45
225 rows := make([][]string, len(routes))
226 for i, route := range routes {
227 displayImage := truncateImage(route.Image, imageColWidth)
228 rows[i] = []string{route.Domain, displayImage}
229 }
230
231 // Render table
232 table := components.NewTable(
233 components.WithColumns([]components.TableColumn{
234 {Title: "Domain", Width: 30},
235 {Title: "Image", Width: imageColWidth},
236 }),
237 components.WithRows(rows),
238 )
239
240 fmt.Println(styles.Theme.Title.Render("Routes (local)"))
241 fmt.Println()
242 fmt.Println(table.View())
243
244 return nil
245}
246
247// newRoutesAddCmd creates the routes add command.
248func newRoutesAddCmd() *cobra.Command {
249 var image string
250
251 cmd := &cobra.Command{
252 Use: "add <domain> <image>",
253 Short: "Add a new route",
254 Long: `Add a new route mapping a domain to a container image.
255
256Examples:
257 gordon routes add app.mydomain.com myapp:latest
258 gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,
259 Args: cobra.RangeArgs(1, 2),
260 RunE: func(cmd *cobra.Command, args []string) error {
261 ctx := context.Background()
262 routeDomain := args[0]
263
264 // Resolve image from positional argument or flag
265 hasPositionalImage := len(args) > 1
266 hasFlagImage := image != ""
267
268 // Check for conflicts: both positional and flag provided
269 if hasPositionalImage && hasFlagImage {
270 return fmt.Errorf("error: cannot use both positional image argument and --image flag")
271 }
272
273 // Check that image is provided via either method
274 if !hasPositionalImage && !hasFlagImage {
275 return fmt.Errorf("error: image is required (use positional argument or --image flag)")
276 }
277
278 if hasPositionalImage {
279 image = args[1]
280 }
281
282 route := domain.Route{
283 Domain: routeDomain,
284 Image: image,
285 }
286
287 client, isRemote := GetRemoteClient()
288 if isRemote {
289 if err := client.AddRoute(ctx, route); err != nil {
290 return fmt.Errorf("failed to add route: %w", err)
291 }
292 } else {
293 local, err := GetLocalServices(configPath)
294 if err != nil {
295 return fmt.Errorf("failed to initialize local services: %w", err)
296 }
297 if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
298 return fmt.Errorf("failed to add route: %w", err)
299 }
300 }
301
302 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))
303 return nil
304 },
305 }
306
307 cmd.Flags().StringVarP(&image, "image", "i", "", "Container image")
308
309 return cmd
310}
311
312// newRoutesRemoveCmd creates the routes remove command.
313func newRoutesRemoveCmd() *cobra.Command {
314 var force bool
315
316 cmd := &cobra.Command{
317 Use: "remove <domain>",
318 Short: "Remove a route",
319 Long: `Remove a route by its domain name.
320
321Examples:
322 gordon routes remove app.mydomain.com
323 gordon routes remove app.mydomain.com --force`,
324 Args: cobra.ExactArgs(1),
325 RunE: func(cmd *cobra.Command, args []string) error {
326 ctx := context.Background()
327 routeDomain := args[0]
328
329 // Confirm unless --force
330 if !force {
331 confirmed, err := components.RunConfirm(
332 fmt.Sprintf("Remove route '%s'?", routeDomain),
333 components.WithDescription("This will stop and remove the associated container."),
334 )
335 if err != nil {
336 return err
337 }
338 if !confirmed {
339 fmt.Println(styles.Theme.Muted.Render("Cancelled"))
340 return nil
341 }
342 }
343
344 client, isRemote := GetRemoteClient()
345 if isRemote {
346 if err := client.RemoveRoute(ctx, routeDomain); err != nil {
347 return fmt.Errorf("failed to remove route: %w", err)
348 }
349 } else {
350 local, err := GetLocalServices(configPath)
351 if err != nil {
352 return fmt.Errorf("failed to initialize local services: %w", err)
353 }
354 if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil {
355 return fmt.Errorf("failed to remove route: %w", err)
356 }
357 }
358
359 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
360 return nil
361 },
362 }
363
364 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
365
366 return cmd
367}
368
369// newStatusCmd creates the status command.
370func newStatusCmd() *cobra.Command {
371 return &cobra.Command{
372 Use: "status",
373 Short: "Show Gordon server status",
374 RunE: func(cmd *cobra.Command, args []string) error {
375 ctx := context.Background()
376
377 handle, err := resolveControlPlane(configPath)
378 if err != nil {
379 return err
380 }
381 defer handle.close()
382
383 status, err := handle.plane.GetStatus(ctx)
384 if err != nil {
385 return fmt.Errorf("failed to get status: %w", err)
386 }
387
388 // Display status
389 fmt.Println(styles.Theme.Title.Render("Gordon Status"))
390 fmt.Println()
391
392 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)
393 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
394 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
395 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
396 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
397 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
398
399 if len(status.ContainerStatus) > 0 {
400 fmt.Println()
401 fmt.Println(styles.Theme.Bold.Render("Container Status:"))
402 for domain, containerStatus := range status.ContainerStatus {
403 badge := components.ContainerStatusBadge(containerStatus)
404 fmt.Printf(" %s: %s\n", domain, badge)
405 }
406 }
407
408 return nil
409 },
410 }
411}