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