package cli import ( "context" "errors" "fmt" "io" "strings" "github.com/spf13/cobra" "github.com/bnema/gordon/internal/adapters/in/cli/remote" "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" "github.com/bnema/gordon/internal/domain" ) // configPath for local operations. If empty, config is auto-discovered // from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml). var configPath string // truncateImage shortens long image references for display. // For digests (image@sha256:...), shows first 12 chars of digest. // For tags, truncates to maxLen with ellipsis if needed. func truncateImage(image string, maxLen int) string { // Handle digest references: image@sha256:abc123... if maxLen <= 0 { return "" } if idx := strings.Index(image, "@sha256:"); idx != -1 { name := image[:idx] digest := image[idx+8:] // Skip "@sha256:" if len(digest) > 12 { digest = digest[:12] } short := fmt.Sprintf("%s@sha256:%s", name, digest) if len(short) <= maxLen { return short } // Truncate from the end to maintain valid reference format if maxLen <= 3 { return short[:maxLen] } return short[:maxLen-3] + "..." } // Regular tag: truncate if needed if len(image) <= maxLen { return image } if maxLen <= 3 { return image[:maxLen] } return image[:maxLen-3] + "..." } func truncateNetwork(network string, maxLen int) string { if network == "" || network == "-" { return "-" } if maxLen <= 0 { return "" } if len(network) <= maxLen { return network } if maxLen <= 3 { return network[:maxLen] } return network[:maxLen-3] + "..." } // newRoutesCmd creates the routes command group. func newRoutesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "routes", Short: "Manage routes", Long: `Manage Gordon routes. Routes map domains to container images. When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), these commands operate on the remote server. Otherwise, they require access to the local Gordon configuration.`, } cmd.AddCommand(newRoutesListCmd()) cmd.AddCommand(newRoutesShowCmd()) cmd.AddCommand(newRoutesAddCmd()) cmd.AddCommand(newRoutesRemoveCmd()) return cmd } // formatHTTPStatus formats the HTTP status for display. func formatHTTPStatus(health *remote.RouteHealth) string { if health == nil { return styles.Theme.Muted.Render("-") } if health.HTTPStatus == 0 { if health.Error != "" { return styles.Theme.BadgeError.Render("err") } return styles.Theme.Muted.Render("-") } status := fmt.Sprintf("%d", health.HTTPStatus) if health.ResponseTimeMs > 0 { status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs) } if health.HTTPStatus >= 200 && health.HTTPStatus < 400 { return styles.Theme.BadgeSuccess.Render(status) } return styles.Theme.BadgeError.Render(status) } // newRoutesListCmd creates the routes list command. func newRoutesListCmd() *cobra.Command { var jsonOut bool cmd := &cobra.Command{ Use: "list", Short: "List all routes", RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() client, isRemote := GetRemoteClient() if isRemote { return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout()) } return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout()) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") return cmd } func newRoutesShowCmd() *cobra.Command { var jsonOut bool cmd := &cobra.Command{ Use: "show ", Short: "Show details for a single route", Long: `Display detailed information about a specific route including its image, container status, and health. Examples: gordon routes show app.mydomain.com gordon routes show app.mydomain.com --json gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() handle, err := resolveControlPlane(configPath) if err != nil { return err } defer handle.close() return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") return cmd } func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error { route, err := cp.GetRoute(ctx, routeDomain) if err != nil { if errors.Is(err, domain.ErrRouteNotFound) { return fmt.Errorf("route %q not found", routeDomain) } return fmt.Errorf("failed to get route: %w", err) } health, _ := cp.GetHealth(ctx) var routeHealth *remote.RouteHealth if health != nil { routeHealth = health[routeDomain] } containerStatus := "unknown" httpStatus := 0 if routeHealth != nil { containerStatus = routeHealth.ContainerStatus httpStatus = routeHealth.HTTPStatus } if jsonOut { payload := map[string]any{ "domain": route.Domain, "image": route.Image, "container_status": containerStatus, "http_status": httpStatus, } return writeJSON(out, payload) } if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil { return err } if err := cliWriteLine(out, ""); err != nil { return err } if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil { return err } if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil { return err } if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil { return err } if httpStatus > 0 { if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil { return err } } return nil } // runRoutesListRemote lists routes from a remote Gordon instance. func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error { routes, err := client.ListRoutesWithDetails(ctx) if err != nil { return fmt.Errorf("failed to list routes: %w", err) } // Get health status for each route (includes container status and HTTP probe) health, _ := client.GetHealth(ctx) if health == nil { health = make(map[string]*remote.RouteHealth) } if jsonOut { return routesListJSON(out, routes, health) } if len(routes) == 0 { _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured")) return nil } const imageColWidth = 35 const networkColWidth = 22 rows := make([][]string, 0, len(routes)) for _, route := range routes { routeHealth := health[route.Domain] // Container status column containerStatus := route.ContainerStatus if containerStatus == "" { if routeHealth != nil { containerStatus = routeHealth.ContainerStatus } else { containerStatus = "unknown" } } containerBadge := components.ContainerStatusBadge(containerStatus) // HTTP status column httpStatus := formatHTTPStatus(routeHealth) displayImage := truncateImage(route.Image, imageColWidth) displayNetwork := truncateNetwork(route.Network, networkColWidth) rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus}) for i, attachment := range route.Attachments { // Unicode box-drawing tree structure for nested attachments prefix := styles.IconTreeBranch + styles.IconTreeLine if i == len(route.Attachments)-1 { prefix = styles.IconTreeLast + styles.IconTreeLine } attachmentName := prefix + " " + attachment.Name runes := []rune(attachmentName) if len(runes) > 25 { attachmentName = string(runes[:22]) + "..." } attachmentStatus := components.ContainerStatusBadge(attachment.Status) attachmentImage := truncateImage(attachment.Image, imageColWidth) // Display the attachment's actual network in the routes table attachmentNetwork := truncateNetwork(attachment.Network, networkColWidth) rows = append(rows, []string{attachmentName, attachmentImage, attachmentNetwork, attachmentStatus, "-"}) } } // Render table table := components.NewTable( components.WithColumns([]components.TableColumn{ {Title: "Domain", Width: 25}, {Title: "Image", Width: imageColWidth}, {Title: "Network", Width: networkColWidth}, {Title: "Container", Width: 12}, {Title: "HTTP", Width: 14}, }), components.WithRows(rows), ) _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes")) _, _ = fmt.Fprintln(out) _, _ = fmt.Fprintln(out, table.View()) return nil } func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error { payload := make([]map[string]any, 0, len(routes)) for _, route := range routes { routeHealth := health[route.Domain] containerStatus := route.ContainerStatus if containerStatus == "" { if routeHealth != nil { containerStatus = routeHealth.ContainerStatus } else { containerStatus = "unknown" } } httpStatus := 0 if routeHealth != nil { httpStatus = routeHealth.HTTPStatus } attachments := make([]map[string]string, 0, len(route.Attachments)) for _, attachment := range route.Attachments { attachments = append(attachments, map[string]string{ "name": attachment.Name, "image": attachment.Image, "status": attachment.Status, }) } payload = append(payload, map[string]any{ "domain": route.Domain, "image": route.Image, "container_status": containerStatus, "network": route.Network, "http_status": httpStatus, "attachments": attachments, }) } return writeJSON(out, payload) } // runRoutesListLocal lists routes from local configuration. func runRoutesListLocal(ctx context.Context, cfgPath string, jsonOut bool, out io.Writer) error { local, err := GetLocalServices(cfgPath) if err != nil { return fmt.Errorf("failed to initialize local services: %w", err) } routes := local.GetConfigService().GetRoutes(ctx) if jsonOut { payload := make([]map[string]string, 0, len(routes)) for _, route := range routes { payload = append(payload, map[string]string{ "domain": route.Domain, "image": route.Image, }) } return writeJSON(out, payload) } if len(routes) == 0 { _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured")) return nil } // Build table rows (no health info in local mode) const imageColWidth = 45 rows := make([][]string, len(routes)) for i, route := range routes { displayImage := truncateImage(route.Image, imageColWidth) rows[i] = []string{route.Domain, displayImage} } // Render table table := components.NewTable( components.WithColumns([]components.TableColumn{ {Title: "Domain", Width: 30}, {Title: "Image", Width: imageColWidth}, }), components.WithRows(rows), ) _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes (local)")) _, _ = fmt.Fprintln(out) _, _ = fmt.Fprintln(out, table.View()) return nil } // newRoutesAddCmd creates the routes add command. func newRoutesAddCmd() *cobra.Command { var image string cmd := &cobra.Command{ Use: "add ", Short: "Create or update a route", Long: `Create or update a route mapping a domain to a container image. If the route already exists with the same image, this is a no-op. If it exists with a different image, the image is updated. Examples: gordon routes add app.mydomain.com myapp:latest gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`, Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() routeDomain := args[0] // Resolve image from positional argument or flag hasPositionalImage := len(args) > 1 hasFlagImage := image != "" // Check for conflicts: both positional and flag provided if hasPositionalImage && hasFlagImage { return fmt.Errorf("error: cannot use both positional image argument and --image flag") } // Check that image is provided via either method if !hasPositionalImage && !hasFlagImage { return fmt.Errorf("error: image is required (use positional argument or --image flag)") } if hasPositionalImage { image = args[1] } route := domain.Route{ Domain: routeDomain, Image: image, } client, isRemote := GetRemoteClient() if isRemote { if err := client.AddRoute(ctx, route); err != nil { return fmt.Errorf("failed to add route: %w", err) } } else { local, err := GetLocalServices(configPath) if err != nil { return fmt.Errorf("failed to initialize local services: %w", err) } if err := local.GetConfigService().AddRoute(ctx, route); err != nil { return fmt.Errorf("failed to add route: %w", err) } } fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image))) return nil }, } cmd.Flags().StringVarP(&image, "image", "i", "", "Container image") return cmd } // newRoutesRemoveCmd creates the routes remove command. func newRoutesRemoveCmd() *cobra.Command { var force bool cmd := &cobra.Command{ Use: "remove ", Short: "Remove a route", Long: `Remove a route by its domain name. Examples: gordon routes remove app.mydomain.com gordon routes remove app.mydomain.com --force`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() routeDomain := args[0] // Confirm unless --force if !force { confirmed, err := components.RunConfirm( fmt.Sprintf("Remove route '%s'?", routeDomain), components.WithDescription("This will stop and remove the associated container."), ) if err != nil { return err } if !confirmed { fmt.Println(styles.Theme.Muted.Render("Cancelled")) return nil } } client, isRemote := GetRemoteClient() if isRemote { if err := client.RemoveRoute(ctx, routeDomain); err != nil { return fmt.Errorf("failed to remove route: %w", err) } } else { local, err := GetLocalServices(configPath) if err != nil { return fmt.Errorf("failed to initialize local services: %w", err) } if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil { return fmt.Errorf("failed to remove route: %w", err) } } fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))) return nil }, } cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") return cmd } // newStatusCmd creates the status command. func newStatusCmd() *cobra.Command { return &cobra.Command{ Use: "status", Short: "Show Gordon server status", RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() handle, err := resolveControlPlane(configPath) if err != nil { return err } defer handle.close() status, err := handle.plane.GetStatus(ctx) if err != nil { return fmt.Errorf("failed to get status: %w", err) } // Display status fmt.Println(styles.Theme.Title.Render("Gordon Status")) fmt.Println() fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain) fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort) fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort) fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes) fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute) fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation) if len(status.ContainerStatus) > 0 { fmt.Println() fmt.Println(styles.Theme.Bold.Render("Container Status:")) for domain, containerStatus := range status.ContainerStatus { badge := components.ContainerStatusBadge(containerStatus) fmt.Printf(" %s: %s\n", domain, badge) } } return nil }, } }