1package cli
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "strings"
9
10 "github.com/spf13/cobra"
11
12 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
13 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
14 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
15 "github.com/bnema/gordon/internal/domain"
16)
17
18// configPath for local operations. If empty, config is auto-discovered
19// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
20var configPath string
21
22// truncateImage shortens long image references for display.
23// For digests (image@sha256:...), shows first 12 chars of digest.
24// For tags, truncates to maxLen with ellipsis if needed.
25func truncateImage(image string, maxLen int) string {
26 // Handle digest references: image@sha256:abc123...
27 if maxLen <= 0 {
28 return ""
29 }
30
31 if idx := strings.Index(image, "@sha256:"); idx != -1 {
32 name := image[:idx]
33 digest := image[idx+8:] // Skip "@sha256:"
34 if len(digest) > 12 {
35 digest = digest[:12]
36 }
37 short := fmt.Sprintf("%s@sha256:%s", name, digest)
38 if len(short) <= maxLen {
39 return short
40 }
41 // Truncate from the end to maintain valid reference format
42 if maxLen <= 3 {
43 return short[:maxLen]
44 }
45 return short[:maxLen-3] + "..."
46 }
47
48 // Regular tag: truncate if needed
49 if len(image) <= maxLen {
50 return image
51 }
52 if maxLen <= 3 {
53 return image[:maxLen]
54 }
55 return image[:maxLen-3] + "..."
56}
57
58func truncateNetwork(network string, maxLen int) string {
59 if network == "" || network == "-" {
60 return "-"
61 }
62 if maxLen <= 0 {
63 return ""
64 }
65 if len(network) <= maxLen {
66 return network
67 }
68 if maxLen <= 3 {
69 return network[:maxLen]
70 }
71 return network[:maxLen-3] + "..."
72}
73
74// newRoutesCmd creates the routes command group.
75func newRoutesCmd() *cobra.Command {
76 cmd := &cobra.Command{
77 Use: "routes",
78 Short: "Manage routes",
79 Long: `Manage Gordon routes. Routes map domains to container images.
80
81When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
82these commands operate on the remote server. Otherwise, they require access to
83the local Gordon configuration.`,
84 }
85
86 cmd.AddCommand(newRoutesListCmd())
87 cmd.AddCommand(newRoutesShowCmd())
88 cmd.AddCommand(newRoutesAddCmd())
89 cmd.AddCommand(newRoutesRemoveCmd())
90
91 return cmd
92}
93
94// formatHTTPStatus formats the HTTP status for display.
95func formatHTTPStatus(health *remote.RouteHealth) string {
96 if health == nil {
97 return styles.Theme.Muted.Render("-")
98 }
99 if health.HTTPStatus == 0 {
100 if health.Error != "" {
101 return styles.Theme.BadgeError.Render("err")
102 }
103 return styles.Theme.Muted.Render("-")
104 }
105 status := fmt.Sprintf("%d", health.HTTPStatus)
106 if health.ResponseTimeMs > 0 {
107 status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs)
108 }
109 if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
110 return styles.Theme.BadgeSuccess.Render(status)
111 }
112 return styles.Theme.BadgeError.Render(status)
113}
114
115// newRoutesListCmd creates the routes list command.
116func newRoutesListCmd() *cobra.Command {
117 var jsonOut bool
118
119 cmd := &cobra.Command{
120 Use: "list",
121 Short: "List all routes",
122 RunE: func(cmd *cobra.Command, args []string) error {
123 ctx := context.Background()
124
125 client, isRemote := GetRemoteClient()
126 if isRemote {
127 return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout())
128 }
129 return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout())
130 },
131 }
132
133 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
134
135 return cmd
136}
137
138func newRoutesShowCmd() *cobra.Command {
139 var jsonOut bool
140
141 cmd := &cobra.Command{
142 Use: "show <domain>",
143 Short: "Show details for a single route",
144 Long: `Display detailed information about a specific route including its image,
145container status, and health.
146
147Examples:
148 gordon routes show app.mydomain.com
149 gordon routes show app.mydomain.com --json
150 gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
151 Args: cobra.ExactArgs(1),
152 RunE: func(cmd *cobra.Command, args []string) error {
153 ctx := cmd.Context()
154 handle, err := resolveControlPlane(configPath)
155 if err != nil {
156 return err
157 }
158 defer handle.close()
159 return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
160 },
161 }
162
163 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
164
165 return cmd
166}
167
168func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
169 route, err := cp.GetRoute(ctx, routeDomain)
170 if err != nil {
171 if errors.Is(err, domain.ErrRouteNotFound) {
172 return fmt.Errorf("route %q not found", routeDomain)
173 }
174 return fmt.Errorf("failed to get route: %w", err)
175 }
176
177 health, _ := cp.GetHealth(ctx)
178 var routeHealth *remote.RouteHealth
179 if health != nil {
180 routeHealth = health[routeDomain]
181 }
182
183 containerStatus := "unknown"
184 httpStatus := 0
185 if routeHealth != nil {
186 containerStatus = routeHealth.ContainerStatus
187 httpStatus = routeHealth.HTTPStatus
188 }
189
190 if jsonOut {
191 payload := map[string]any{
192 "domain": route.Domain,
193 "image": route.Image,
194 "container_status": containerStatus,
195 "http_status": httpStatus,
196 }
197 return writeJSON(out, payload)
198 }
199
200 if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil {
201 return err
202 }
203 if err := cliWriteLine(out, ""); err != nil {
204 return err
205 }
206 if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil {
207 return err
208 }
209 if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil {
210 return err
211 }
212 if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
213 return err
214 }
215 if httpStatus > 0 {
216 if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
217 return err
218 }
219 }
220
221 return nil
222}
223
224// runRoutesListRemote lists routes from a remote Gordon instance.
225func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error {
226 routes, err := client.ListRoutesWithDetails(ctx)
227 if err != nil {
228 return fmt.Errorf("failed to list routes: %w", err)
229 }
230
231 // Get health status for each route (includes container status and HTTP probe)
232 health, _ := client.GetHealth(ctx)
233 if health == nil {
234 health = make(map[string]*remote.RouteHealth)
235 }
236
237 if jsonOut {
238 return routesListJSON(out, routes, health)
239 }
240
241 if len(routes) == 0 {
242 _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))
243 return nil
244 }
245
246 const imageColWidth = 35
247 const networkColWidth = 22
248 rows := make([][]string, 0, len(routes))
249 for _, route := range routes {
250 routeHealth := health[route.Domain]
251
252 // Container status column
253 containerStatus := route.ContainerStatus
254 if containerStatus == "" {
255 if routeHealth != nil {
256 containerStatus = routeHealth.ContainerStatus
257 } else {
258 containerStatus = "unknown"
259 }
260 }
261 containerBadge := components.ContainerStatusBadge(containerStatus)
262
263 // HTTP status column
264 httpStatus := formatHTTPStatus(routeHealth)
265
266 displayImage := truncateImage(route.Image, imageColWidth)
267 displayNetwork := truncateNetwork(route.Network, networkColWidth)
268 rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})
269
270 for i, attachment := range route.Attachments {
271 // Unicode box-drawing tree structure for nested attachments
272 prefix := styles.IconTreeBranch + styles.IconTreeLine
273 if i == len(route.Attachments)-1 {
274 prefix = styles.IconTreeLast + styles.IconTreeLine
275 }
276 attachmentName := prefix + " " + attachment.Name
277 runes := []rune(attachmentName)
278 if len(runes) > 25 {
279 attachmentName = string(runes[:22]) + "..."
280 }
281 attachmentStatus := components.ContainerStatusBadge(attachment.Status)
282 attachmentImage := truncateImage(attachment.Image, imageColWidth)
283 // Display the attachment's actual network in the routes table
284 attachmentNetwork := truncateNetwork(attachment.Network, networkColWidth)
285 rows = append(rows, []string{attachmentName, attachmentImage, attachmentNetwork, attachmentStatus, "-"})
286 }
287 }
288
289 // Render table
290 table := components.NewTable(
291 components.WithColumns([]components.TableColumn{
292 {Title: "Domain", Width: 25},
293 {Title: "Image", Width: imageColWidth},
294 {Title: "Network", Width: networkColWidth},
295 {Title: "Container", Width: 12},
296 {Title: "HTTP", Width: 14},
297 }),
298 components.WithRows(rows),
299 )
300
301 _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes"))
302 _, _ = fmt.Fprintln(out)
303 _, _ = fmt.Fprintln(out, table.View())
304
305 return nil
306}
307
308func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {
309 payload := make([]map[string]any, 0, len(routes))
310 for _, route := range routes {
311 routeHealth := health[route.Domain]
312 containerStatus := route.ContainerStatus
313 if containerStatus == "" {
314 if routeHealth != nil {
315 containerStatus = routeHealth.ContainerStatus
316 } else {
317 containerStatus = "unknown"
318 }
319 }
320
321 httpStatus := 0
322 if routeHealth != nil {
323 httpStatus = routeHealth.HTTPStatus
324 }
325
326 attachments := make([]map[string]string, 0, len(route.Attachments))
327 for _, attachment := range route.Attachments {
328 attachments = append(attachments, map[string]string{
329 "name": attachment.Name,
330 "image": attachment.Image,
331 "status": attachment.Status,
332 })
333 }
334
335 payload = append(payload, map[string]any{
336 "domain": route.Domain,
337 "image": route.Image,
338 "container_status": containerStatus,
339 "network": route.Network,
340 "http_status": httpStatus,
341 "attachments": attachments,
342 })
343 }
344
345 return writeJSON(out, payload)
346}
347
348// runRoutesListLocal lists routes from local configuration.
349func runRoutesListLocal(ctx context.Context, cfgPath string, jsonOut bool, out io.Writer) error {
350 local, err := GetLocalServices(cfgPath)
351 if err != nil {
352 return fmt.Errorf("failed to initialize local services: %w", err)
353 }
354
355 routes := local.GetConfigService().GetRoutes(ctx)
356
357 if jsonOut {
358 payload := make([]map[string]string, 0, len(routes))
359 for _, route := range routes {
360 payload = append(payload, map[string]string{
361 "domain": route.Domain,
362 "image": route.Image,
363 })
364 }
365 return writeJSON(out, payload)
366 }
367
368 if len(routes) == 0 {
369 _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))
370 return nil
371 }
372
373 // Build table rows (no health info in local mode)
374 const imageColWidth = 45
375 rows := make([][]string, len(routes))
376 for i, route := range routes {
377 displayImage := truncateImage(route.Image, imageColWidth)
378 rows[i] = []string{route.Domain, displayImage}
379 }
380
381 // Render table
382 table := components.NewTable(
383 components.WithColumns([]components.TableColumn{
384 {Title: "Domain", Width: 30},
385 {Title: "Image", Width: imageColWidth},
386 }),
387 components.WithRows(rows),
388 )
389
390 _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes (local)"))
391 _, _ = fmt.Fprintln(out)
392 _, _ = fmt.Fprintln(out, table.View())
393
394 return nil
395}
396
397// newRoutesAddCmd creates the routes add command.
398func newRoutesAddCmd() *cobra.Command {
399 var image string
400
401 cmd := &cobra.Command{
402 Use: "add <domain> <image>",
403 Short: "Create or update a route",
404 Long: `Create or update a route mapping a domain to a container image.
405
406If the route already exists with the same image, this is a no-op.
407If it exists with a different image, the image is updated.
408
409Examples:
410 gordon routes add app.mydomain.com myapp:latest
411 gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,
412 Args: cobra.RangeArgs(1, 2),
413 RunE: func(cmd *cobra.Command, args []string) error {
414 ctx := context.Background()
415 routeDomain := args[0]
416
417 // Resolve image from positional argument or flag
418 hasPositionalImage := len(args) > 1
419 hasFlagImage := image != ""
420
421 // Check for conflicts: both positional and flag provided
422 if hasPositionalImage && hasFlagImage {
423 return fmt.Errorf("error: cannot use both positional image argument and --image flag")
424 }
425
426 // Check that image is provided via either method
427 if !hasPositionalImage && !hasFlagImage {
428 return fmt.Errorf("error: image is required (use positional argument or --image flag)")
429 }
430
431 if hasPositionalImage {
432 image = args[1]
433 }
434
435 route := domain.Route{
436 Domain: routeDomain,
437 Image: image,
438 }
439
440 client, isRemote := GetRemoteClient()
441 if isRemote {
442 if err := client.AddRoute(ctx, route); err != nil {
443 return fmt.Errorf("failed to add route: %w", err)
444 }
445 } else {
446 local, err := GetLocalServices(configPath)
447 if err != nil {
448 return fmt.Errorf("failed to initialize local services: %w", err)
449 }
450 if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
451 return fmt.Errorf("failed to add route: %w", err)
452 }
453 }
454
455 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))
456 return nil
457 },
458 }
459
460 cmd.Flags().StringVarP(&image, "image", "i", "", "Container image")
461
462 return cmd
463}
464
465// newRoutesRemoveCmd creates the routes remove command.
466func newRoutesRemoveCmd() *cobra.Command {
467 var force bool
468
469 cmd := &cobra.Command{
470 Use: "remove <domain>",
471 Short: "Remove a route",
472 Long: `Remove a route by its domain name.
473
474Examples:
475 gordon routes remove app.mydomain.com
476 gordon routes remove app.mydomain.com --force`,
477 Args: cobra.ExactArgs(1),
478 RunE: func(cmd *cobra.Command, args []string) error {
479 ctx := context.Background()
480 routeDomain := args[0]
481
482 // Confirm unless --force
483 if !force {
484 confirmed, err := components.RunConfirm(
485 fmt.Sprintf("Remove route '%s'?", routeDomain),
486 components.WithDescription("This will stop and remove the associated container."),
487 )
488 if err != nil {
489 return err
490 }
491 if !confirmed {
492 fmt.Println(styles.Theme.Muted.Render("Cancelled"))
493 return nil
494 }
495 }
496
497 client, isRemote := GetRemoteClient()
498 if isRemote {
499 if err := client.RemoveRoute(ctx, routeDomain); err != nil {
500 return fmt.Errorf("failed to remove route: %w", err)
501 }
502 } else {
503 local, err := GetLocalServices(configPath)
504 if err != nil {
505 return fmt.Errorf("failed to initialize local services: %w", err)
506 }
507 if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil {
508 return fmt.Errorf("failed to remove route: %w", err)
509 }
510 }
511
512 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
513 return nil
514 },
515 }
516
517 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
518
519 return cmd
520}
521
522// newStatusCmd creates the status command.
523func newStatusCmd() *cobra.Command {
524 return &cobra.Command{
525 Use: "status",
526 Short: "Show Gordon server status",
527 RunE: func(cmd *cobra.Command, args []string) error {
528 ctx := context.Background()
529
530 handle, err := resolveControlPlane(configPath)
531 if err != nil {
532 return err
533 }
534 defer handle.close()
535
536 status, err := handle.plane.GetStatus(ctx)
537 if err != nil {
538 return fmt.Errorf("failed to get status: %w", err)
539 }
540
541 // Display status
542 fmt.Println(styles.Theme.Title.Render("Gordon Status"))
543 fmt.Println()
544
545 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)
546 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
547 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
548 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
549 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
550 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
551
552 if len(status.ContainerStatus) > 0 {
553 fmt.Println()
554 fmt.Println(styles.Theme.Bold.Render("Container Status:"))
555 for domain, containerStatus := range status.ContainerStatus {
556 badge := components.ContainerStatusBadge(containerStatus)
557 fmt.Printf(" %s: %s\n", domain, badge)
558 }
559 }
560
561 return nil
562 },
563 }
564}