package cli import ( "context" "errors" "fmt" "io" "os" "sort" "strconv" "strings" "sync" "github.com/spf13/cobra" "github.com/bnema/gordon/internal/adapters/dto" "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 name, digest, ok := strings.Cut(image, "@sha256:"); ok { 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] + "..." } const networkPrefix = "gordon-" // httpHealthToStatus maps an HTTP health probe result to a components.Status. func httpHealthToStatus(health *remote.RouteHealth) components.Status { if health == nil { return components.StatusUnknown } if health.HTTPStatus == 0 { if health.Error != "" { return components.StatusError } return components.StatusUnknown } if health.HTTPStatus >= 200 && health.HTTPStatus < 400 { return components.StatusSuccess } return components.StatusError } // stripNetworkPrefix removes the "gordon-" prefix from a network name for display. func stripNetworkPrefix(network string) string { return strings.TrimPrefix(network, networkPrefix) } // networkGroup holds routes that share a network. type networkGroup struct { name string routes []remote.RouteInfo } type routeListItem struct { Domain string `json:"domain"` Image string `json:"image"` } type routeListSection struct { Kind string `json:"kind"` Name string `json:"name"` URL string `json:"url,omitempty"` Error string `json:"error,omitempty"` Routes []routeListItem `json:"routes,omitempty"` } type routeStatusAttachment struct { Name string `json:"name"` Image string `json:"image"` Status string `json:"status"` } type routeStatusItem struct { Domain string `json:"domain"` Image string `json:"image"` ContainerID string `json:"container_id,omitempty"` ContainerStatus string `json:"container_status"` HTTPStatus int `json:"http_status"` HealthError string `json:"health_error,omitempty"` Network string `json:"network"` Attachments []routeStatusAttachment `json:"attachments,omitempty"` } type routeStatusSection struct { Kind string `json:"kind"` Name string `json:"name"` URL string `json:"url,omitempty"` Error string `json:"error,omitempty"` Routes []routeStatusItem `json:"routes,omitempty"` } type routeDiagnosis struct { Domain string `json:"domain"` Configured bool `json:"configured"` Route *domain.Route `json:"route,omitempty"` Runtime *remote.RouteInfo `json:"runtime,omitempty"` Health *remote.RouteHealth `json:"health,omitempty"` Volumes []dto.Volume `json:"volumes,omitempty"` OrphanedAttachments []domain.CleanupAttachment `json:"orphaned_attachments,omitempty"` Warnings []string `json:"warnings,omitempty"` Hints []string `json:"hints,omitempty"` } type routesListDeps struct { explicitRemote func() (*remote.ResolvedRemote, bool, error) loadLocal func(context.Context, string) (routeListSection, error) listRemotes func() (map[string]remote.RemoteEntry, string, error) loadRemote func(context.Context, string, remote.RemoteEntry) (routeListSection, error) } type routesStatusDeps struct { explicitRemote func() (*remote.ResolvedRemote, bool, error) loadLocal func(context.Context, string) (routeStatusSection, error) listRemotes func() (map[string]remote.RemoteEntry, string, error) loadRemote func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) } // groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network) // and solo routes. Both are sorted alphabetically by domain. func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) { byNetwork := make(map[string][]remote.RouteInfo) var networkOrder []string for _, route := range routes { if _, seen := byNetwork[route.Network]; !seen { networkOrder = append(networkOrder, route.Network) } byNetwork[route.Network] = append(byNetwork[route.Network], route) } var groups []networkGroup var solo []remote.RouteInfo for _, net := range networkOrder { members := byNetwork[net] if len(members) >= 2 { sort.Slice(members, func(i, j int) bool { return members[i].Domain < members[j].Domain }) groups = append(groups, networkGroup{ name: stripNetworkPrefix(net), routes: members, }) } else { solo = append(solo, members[0]) } } sort.Slice(solo, func(i, j int) bool { return solo[i].Domain < solo[j].Domain }) return groups, solo } // 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(newRoutesStatusCmd()) cmd.AddCommand(newRoutesShowCmd()) cmd.AddCommand(newRoutesDiagnoseCmd()) cmd.AddCommand(newRoutesPurgeCmd()) cmd.AddCommand(newRoutesAddCmd()) cmd.AddCommand(newRoutesRemoveCmd()) return cmd } // 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 { sections, err := collectRoutesListSections(cmd.Context(), configPath, routesListDeps{}) if err != nil { return err } if jsonOut { return writeJSON(cmd.OutOrStdout(), sections) } return renderRoutesListSections(cmd.OutOrStdout(), sections) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") return cmd } func newRoutesStatusCmd() *cobra.Command { var jsonOut bool cmd := &cobra.Command{ Use: "status", Short: "Show detailed route status", RunE: func(cmd *cobra.Command, args []string) error { sections, err := collectRoutesStatusSections(cmd.Context(), configPath, routesStatusDeps{}) if err != nil { return err } if jsonOut { return writeJSON(cmd.OutOrStdout(), sections) } return renderRoutesStatusSections(cmd.OutOrStdout(), sections) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") return cmd } func collectRoutesListSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) { if deps.explicitRemote == nil { deps.explicitRemote = resolveRoutesExplicitRemote } if deps.loadLocal == nil { deps.loadLocal = loadRoutesListLocalSection } if deps.listRemotes == nil { deps.listRemotes = remote.ListRemotes } if deps.loadRemote == nil { deps.loadRemote = loadRoutesListRemoteSection } if resolved, ok, err := deps.explicitRemote(); err != nil { return nil, err } else if ok { section := loadRoutesListExplicitRemoteSection(ctx, deps, resolved) return []routeListSection{section}, nil } if target, ok := resolveRoutesExplicitTarget(); ok { return []routeListSection{{ Kind: "remote", Name: target, Error: unresolvedRoutesTargetError(target), }}, nil } return collectRoutesListAggregateSections(ctx, cfgPath, deps) } func loadRoutesListExplicitRemoteSection(ctx context.Context, deps routesListDeps, resolved *remote.ResolvedRemote) routeListSection { section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS}) if err != nil && section.Error == "" { section.Error = err.Error() } return normalizeRouteListRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS}) } func collectRoutesListAggregateSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) { type localResult struct { section routeListSection err error } type remotesResult struct { entries map[string]remote.RemoteEntry err error } localCh := make(chan localResult, 1) remotesCh := make(chan remotesResult, 1) go func() { section, err := deps.loadLocal(ctx, cfgPath) localCh <- localResult{section: section, err: err} }() go func() { entries, _, err := deps.listRemotes() remotesCh <- remotesResult{entries: entries, err: err} }() localRes := <-localCh remotes := <-remotesCh local := localRes.section if err := localRes.err; err != nil { if local.Error == "" { local.Error = err.Error() } } sections := make([]routeListSection, 0, 1) sections = append(sections, normalizeRouteListLocalSection(local)) if remotes.err != nil { sections = append(sections, routeListSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()}) return sections, nil } names := sortedRemoteNames(remotes.entries) remoteSections := make([]routeListSection, len(names)) var wg sync.WaitGroup for i, name := range names { entry := remotes.entries[name] wg.Add(1) go func(idx int, remoteName string, remoteEntry remote.RemoteEntry) { defer wg.Done() section, err := deps.loadRemote(ctx, remoteName, remoteEntry) if err != nil && section.Error == "" { section.Error = err.Error() } remoteSections[idx] = normalizeRouteListRemoteSection(section, remoteName, remoteEntry) }(i, name, entry) } wg.Wait() sections = append(sections, remoteSections...) return sections, nil } func normalizeRouteListLocalSection(section routeListSection) routeListSection { if section.Kind == "" { section.Kind = "local" } if section.Name == "" { section.Name = "local" } return section } func normalizeRouteListRemoteSection(section routeListSection, name string, entry remote.RemoteEntry) routeListSection { if section.Kind == "" { section.Kind = "remote" } if section.Name == "" { section.Name = name } if section.URL == "" { section.URL = entry.URL } return section } func collectRoutesStatusSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) { if deps.explicitRemote == nil { deps.explicitRemote = resolveRoutesExplicitRemote } if deps.loadLocal == nil { deps.loadLocal = loadRoutesStatusLocalSection } if deps.listRemotes == nil { deps.listRemotes = remote.ListRemotes } if deps.loadRemote == nil { deps.loadRemote = loadRoutesStatusRemoteSection } if resolved, ok, err := deps.explicitRemote(); err != nil { return nil, err } else if ok { section := loadRoutesStatusExplicitRemoteSection(ctx, deps, resolved) return []routeStatusSection{section}, nil } if target, ok := resolveRoutesExplicitTarget(); ok { return []routeStatusSection{{ Kind: "remote", Name: target, Error: unresolvedRoutesTargetError(target), }}, nil } return collectRoutesStatusAggregateSections(ctx, cfgPath, deps) } func loadRoutesStatusExplicitRemoteSection(ctx context.Context, deps routesStatusDeps, resolved *remote.ResolvedRemote) routeStatusSection { section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS}) if err != nil && section.Error == "" { section.Error = err.Error() } return normalizeRouteStatusRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS}) } func collectRoutesStatusAggregateSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) { type localResult struct { section routeStatusSection err error } type remotesResult struct { entries map[string]remote.RemoteEntry err error } localCh := make(chan localResult, 1) remotesCh := make(chan remotesResult, 1) go func() { section, err := deps.loadLocal(ctx, cfgPath) localCh <- localResult{section: section, err: err} }() go func() { entries, _, err := deps.listRemotes() remotesCh <- remotesResult{entries: entries, err: err} }() localRes := <-localCh remotes := <-remotesCh local := localRes.section if err := localRes.err; err != nil { if local.Error == "" { local.Error = err.Error() } } sections := make([]routeStatusSection, 0, 1) sections = append(sections, normalizeRouteStatusLocalSection(local)) if remotes.err != nil { sections = append(sections, routeStatusSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()}) return sections, nil } names := sortedRemoteNames(remotes.entries) remoteSections := make([]routeStatusSection, len(names)) var wg sync.WaitGroup for i, name := range names { entry := remotes.entries[name] wg.Add(1) go func(idx int, remoteName string, remoteEntry remote.RemoteEntry) { defer wg.Done() section, err := deps.loadRemote(ctx, remoteName, remoteEntry) if err != nil && section.Error == "" { section.Error = err.Error() } remoteSections[idx] = normalizeRouteStatusRemoteSection(section, remoteName, remoteEntry) }(i, name, entry) } wg.Wait() sections = append(sections, remoteSections...) return sections, nil } func normalizeRouteStatusLocalSection(section routeStatusSection) routeStatusSection { if section.Kind == "" { section.Kind = "local" } if section.Name == "" { section.Name = "local" } return section } func normalizeRouteStatusRemoteSection(section routeStatusSection, name string, entry remote.RemoteEntry) routeStatusSection { if section.Kind == "" { section.Kind = "remote" } if section.Name == "" { section.Name = name } if section.URL == "" { section.URL = entry.URL } return section } func renderRoutesListSections(out io.Writer, sections []routeListSection) error { sections = renderedRouteListSections(sections) if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil { return err } if err := cliWriteLine(out, ""); err != nil { return err } for i, section := range sections { if i > 0 { if err := cliWriteLine(out, ""); err != nil { return err } } if err := renderRoutesListSection(out, section); err != nil { return err } } return nil } func renderRoutesListSection(out io.Writer, section routeListSection) error { if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil { return err } if len(section.Routes) > 0 { const imageColWidth = 45 rows := make([][]string, 0, len(section.Routes)) for _, route := range section.Routes { rows = append(rows, []string{route.Domain, truncateImage(route.Image, imageColWidth)}) } table := components.NewTable( components.WithColumns([]components.TableColumn{ {Title: "Domain", Width: 30}, {Title: "Image", Width: imageColWidth}, }), components.WithRows(rows), ) if err := cliWriteLine(out, table.View()); err != nil { return err } } if section.Error != "" { return cliWriteLine(out, cliRenderWarning(section.Error)) } if len(section.Routes) == 0 { return cliWriteLine(out, cliRenderMuted("No routes configured")) } return nil } func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error { sections = renderedRouteStatusSections(sections) if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil { return err } if err := cliWriteLine(out, ""); err != nil { return err } for i, section := range sections { if i > 0 { if err := cliWriteLine(out, ""); err != nil { return err } } if err := renderRoutesStatusSection(out, section); err != nil { return err } } return nil } func renderedRouteListSections(sections []routeListSection) []routeListSection { if !shouldHideLocalRouteListSection(sections) { return sections } return sections[1:] } func shouldHideLocalRouteListSection(sections []routeListSection) bool { if len(sections) < 2 { return false } local := sections[0] if local.Kind != "local" || local.Error == "" { return false } for _, section := range sections[1:] { if section.Kind == "remote" && section.Error == "" { return true } } return false } func renderedRouteStatusSections(sections []routeStatusSection) []routeStatusSection { if !shouldHideLocalRouteStatusSection(sections) { return sections } return sections[1:] } func shouldHideLocalRouteStatusSection(sections []routeStatusSection) bool { if len(sections) < 2 { return false } local := sections[0] if local.Kind != "local" || local.Error == "" { return false } for _, section := range sections[1:] { if section.Kind == "remote" && section.Error == "" { return true } } return false } func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error { if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil { return err } if len(section.Routes) > 0 { tree := buildRouteStatusTree(section.Routes) if err := cliWriteLine(out, strings.TrimSuffix(tree.Render(), "\n")); err != nil { return err } } if section.Error != "" { return cliWriteLine(out, cliRenderWarning(section.Error)) } if len(section.Routes) == 0 { return cliWriteLine(out, cliRenderMuted("No routes configured")) } return nil } func routeSectionHeading(kind, name string) string { switch kind { case "local": return "Local" case "remote": if name != "" { return "Remote: " + name } return "Remote" default: if name != "" { return capitalizeKind(kind) + ": " + name } return capitalizeKind(kind) } } func capitalizeKind(kind string) string { if kind == "" { return "" } if len(kind) == 1 { return strings.ToUpper(kind) } return strings.ToUpper(kind[:1]) + kind[1:] } func unresolvedRoutesTargetError(target string) string { return fmt.Sprintf("could not resolve remote target %q", target) } func resolveRoutesExplicitRemote() (*remote.ResolvedRemote, bool, error) { target, ok := resolveRoutesExplicitTarget() if !ok { return nil, false, nil } if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { return &remote.ResolvedRemote{ URL: target, Token: resolveRoutesTokenForTarget("", remote.RemoteEntry{}), InsecureTLS: resolveRoutesInsecureForTarget("", remote.RemoteEntry{}), }, true, nil } remotes, err := remote.LoadRemotes("") if err != nil { return nil, false, err } if remotes != nil { if entry, found := remotes.Remotes[target]; found { return &remote.ResolvedRemote{ Name: target, URL: entry.URL, Token: resolveRoutesTokenForTarget(target, entry), InsecureTLS: resolveRoutesInsecureForTarget(target, entry), }, true, nil } } return nil, false, nil } func resolveRoutesExplicitTarget() (string, bool) { if target := strings.TrimSpace(remoteFlag); target != "" { return target, true } if target := strings.TrimSpace(os.Getenv("GORDON_REMOTE")); target != "" { return target, true } return "", false } func resolveRoutesTokenForTarget(name string, entry remote.RemoteEntry) string { if token := strings.TrimSpace(tokenFlag); token != "" { return token } if token := strings.TrimSpace(os.Getenv("GORDON_TOKEN")); token != "" { return token } if name != "" { return remote.ResolveTokenForRemote(name, entry) } return "" } func resolveRoutesInsecureForTarget(name string, entry remote.RemoteEntry) bool { if insecureTLSFlag { return true } if env := strings.TrimSpace(os.Getenv("GORDON_INSECURE")); env != "" { if value, err := strconv.ParseBool(env); err == nil { return value } } if name != "" { return entry.InsecureTLS } return false } func newRoutesTargetClient(name string, entry remote.RemoteEntry) *remote.Client { return remote.NewClient(entry.URL, remoteClientOptions(resolveRoutesTokenForTarget(name, entry), resolveRoutesInsecureForTarget(name, entry))...) } func loadRoutesListLocalSection(ctx context.Context, cfgPath string) (routeListSection, error) { local, err := GetLocalServices(cfgPath) if err != nil { return routeListSection{Kind: "local", Name: "local", Error: err.Error()}, nil } routes := local.GetConfigService().GetRoutes(ctx) section := routeListSection{Kind: "local", Name: "local", Routes: make([]routeListItem, 0, len(routes))} for _, route := range routes { section.Routes = append(section.Routes, routeListItem{Domain: route.Domain, Image: route.Image}) } return section, nil } func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) { section := routeListSection{Kind: "remote", Name: name, URL: entry.URL} client := newRoutesTargetClient(name, entry) routes, err := client.ListRoutesWithDetails(ctx) if err != nil { section.Error = err.Error() return section, nil } section.Routes = routeListItemsFromInfos(routes) return section, nil } func loadRoutesStatusLocalSection(ctx context.Context, cfgPath string) (routeStatusSection, error) { section := routeStatusSection{Kind: "local", Name: "local"} handle, err := resolveLocalControlPlane(cfgPath) if err != nil { section.Error = err.Error() return section, nil } defer handle.close() routes, err := handle.plane.ListRoutesWithDetails(ctx) if err != nil { section.Error = err.Error() return section, nil } health, err := handle.plane.GetHealth(ctx) if err != nil { section.Error = err.Error() health = nil } section.Routes = routeStatusItemsFromInfos(routes, health) return section, nil } func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeStatusSection, error) { section := routeStatusSection{Kind: "remote", Name: name, URL: entry.URL} client := newRoutesTargetClient(name, entry) cp := NewRemoteControlPlane(client) routes, err := cp.ListRoutesWithDetails(ctx) if err != nil { section.Error = err.Error() return section, nil } health, err := cp.GetHealth(ctx) if err != nil { section.Error = err.Error() health = nil } section.Routes = routeStatusItemsFromInfos(routes, health) return section, nil } func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem { items := make([]routeStatusItem, 0, len(routes)) for _, route := range routes { items = append(items, routeStatusItemFromInfo(route, health[route.Domain])) } return items } func routeStatusItemFromInfo(route remote.RouteInfo, routeHealth *remote.RouteHealth) routeStatusItem { item := routeStatusItem{ Domain: route.Domain, Image: route.Image, ContainerID: route.ContainerID, ContainerStatus: route.ContainerStatus, Network: route.Network, } if item.ContainerStatus == "" && routeHealth != nil { item.ContainerStatus = routeHealth.ContainerStatus item.HTTPStatus = routeHealth.HTTPStatus item.HealthError = routeHealth.Error } if item.ContainerStatus == "" { item.ContainerStatus = "unknown" } if item.HTTPStatus == 0 && routeHealth != nil { item.HTTPStatus = routeHealth.HTTPStatus item.HealthError = routeHealth.Error } if item.HealthError == "" && routeHealth != nil { item.HealthError = routeHealth.Error } item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments)) for _, attachment := range route.Attachments { status := attachment.Status if status == "" { status = "unknown" } item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status}) } return item } func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem { items := make([]routeListItem, 0, len(routes)) for _, route := range routes { items = append(items, routeListItem{Domain: route.Domain, Image: route.Image}) } return items } func buildRouteStatusTree(routes []routeStatusItem) *components.Tree { sortedRoutes := make([]routeStatusItem, len(routes)) copy(sortedRoutes, routes) sort.SliceStable(sortedRoutes, func(i, j int) bool { if sortedRoutes[i].Network != sortedRoutes[j].Network { return sortedRoutes[i].Network < sortedRoutes[j].Network } if sortedRoutes[i].Domain != sortedRoutes[j].Domain { return sortedRoutes[i].Domain < sortedRoutes[j].Domain } if sortedRoutes[i].Image != sortedRoutes[j].Image { return sortedRoutes[i].Image < sortedRoutes[j].Image } if sortedRoutes[i].ContainerID != sortedRoutes[j].ContainerID { return sortedRoutes[i].ContainerID < sortedRoutes[j].ContainerID } if sortedRoutes[i].ContainerStatus != sortedRoutes[j].ContainerStatus { return sortedRoutes[i].ContainerStatus < sortedRoutes[j].ContainerStatus } return sortedRoutes[i].HTTPStatus < sortedRoutes[j].HTTPStatus }) infos := make([]remote.RouteInfo, 0, len(routes)) itemsByDomain := make(map[string]routeStatusItem, len(routes)) for _, route := range sortedRoutes { infos = append(infos, remote.RouteInfo{ Domain: route.Domain, Image: route.Image, ContainerID: route.ContainerID, ContainerStatus: route.ContainerStatus, Network: route.Network, Attachments: routeStatusAttachmentsToRemote(route.Attachments), }) itemsByDomain[route.Domain] = route } groups, solo := groupRoutesByNetwork(infos) tree := components.NewTree() for _, group := range groups { g := tree.AddGroup(group.name) for _, route := range group.routes { item := itemsByDomain[route.Domain] node := g.AddNode(routeStatusTitle(item), item.Image) addRouteStatusAttachmentChildren(node, item) } } for _, route := range solo { item := itemsByDomain[route.Domain] node := tree.AddNode(routeStatusTitle(item), item.Image) addRouteStatusAttachmentChildren(node, item) } return tree } func routeStatusTitle(route routeStatusItem) string { containerStatus := route.ContainerStatus if containerStatus == "" { containerStatus = "unknown" } httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(&remote.RouteHealth{HTTPStatus: route.HTTPStatus, Error: route.HealthError})) containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus)) return httpIcon + " " + containerIcon + " " + route.Domain } func addRouteStatusAttachmentChildren(node *components.Node, route routeStatusItem) { for _, att := range route.Attachments { status := att.Status if status == "" { status = "unknown" } attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(status)) node.AddChild(attIcon+" "+att.Name, att.Image) } } func routeStatusAttachmentsToRemote(attachments []routeStatusAttachment) []remote.Attachment { result := make([]remote.Attachment, 0, len(attachments)) for _, att := range attachments { result = append(result, remote.Attachment{Name: att.Name, Image: att.Image, Status: att.Status}) } return result } 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 := resolveControlPlaneForRouteDomain(ctx, args[0]) 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, err := cp.GetHealth(ctx) healthErr := "" if err != nil { healthErr = err.Error() health = nil } var routeHealth *remote.RouteHealth if health != nil { routeHealth = health[routeDomain] } containerStatus := "unknown" httpStatus := 0 if routeHealth != nil { if routeHealth.ContainerStatus != "" { containerStatus = routeHealth.ContainerStatus } httpStatus = routeHealth.HTTPStatus if healthErr == "" && routeHealth.Error != "" { healthErr = routeHealth.Error } } if jsonOut { payload := map[string]any{ "domain": route.Domain, "image": route.Image, "container_status": containerStatus, "http_status": httpStatus, } if healthErr != "" { payload["health_error"] = healthErr } return writeJSON(out, payload) } return writeRouteShowText(out, route.Domain, route.Image, containerStatus, httpStatus, healthErr) } func writeRouteShowText(out io.Writer, domainName, image, containerStatus string, httpStatus int, healthErr string) error { if err := cliWriteLine(out, cliRenderTitle("Route: "+domainName)); err != nil { return err } if err := cliWriteLine(out, ""); err != nil { return err } if err := cliWriteLine(out, cliRenderMeta("Domain:", domainName)); err != nil { return err } if err := cliWriteLine(out, cliRenderMeta("Image:", 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 } } if healthErr != "" { if err := cliWriteLine(out, cliRenderWarning(healthErr)); err != nil { return err } } return nil } // newRoutesDiagnoseCmd creates the routes diagnose command. func newRoutesDiagnoseCmd() *cobra.Command { var jsonOut bool cmd := &cobra.Command{ Use: "diagnose ", Short: "Diagnose route configuration, runtime state, and cleanup leftovers", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runRouteDiagnosis(cmd.Context(), args[0], cmd.OutOrStdout(), jsonOut) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") return cmd } func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, jsonOut bool) error { handle, err := resolveControlPlaneForRouteDomain(ctx, domainName) if err != nil { return err } defer handle.close() diag, err := buildRouteDiagnosis(ctx, handle.plane, domainName) if err != nil { return err } if jsonOut { return writeJSON(out, diag) } return writeRouteDiagnosisText(out, diag) } func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) { diag := &routeDiagnosis{Domain: domainName} loadRouteDiagnosisConfig(ctx, cp, domainName, diag) loadRouteDiagnosisRuntime(ctx, cp, domainName, diag) loadRouteDiagnosisHealth(ctx, cp, domainName, diag) loadRouteDiagnosisVolumes(ctx, cp, domainName, diag) loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag) finalizeRouteDiagnosis(diag) return diag, nil } func loadRouteDiagnosisConfig(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { route, err := cp.GetRoute(ctx, domainName) if err == nil && route != nil { diag.Configured = true diag.Route = route return } if err != nil && !errors.Is(err, domain.ErrRouteNotFound) { diag.Warnings = append(diag.Warnings, "failed to load route config: "+err.Error()) } } func loadRouteDiagnosisRuntime(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { routes, err := cp.ListRoutesWithDetails(ctx) if err != nil { diag.Warnings = append(diag.Warnings, "failed to load route runtime details: "+err.Error()) return } for _, info := range routes { if strings.EqualFold(info.Domain, domainName) { copy := info diag.Runtime = © return } } } func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { if health, err := cp.GetHealth(ctx); err == nil && health != nil { diag.Health = health[domainName] } } func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { if volumes, err := cp.ListVolumes(ctx); err == nil { diag.Volumes = volumesForRouteDiagnosis(volumes, domainName) } } func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { lister, ok := cp.(interface { ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) }) if !ok { return } if attachments, err := lister.ListOrphanedAttachments(ctx); err == nil { diag.OrphanedAttachments = attachmentsForRouteDiagnosis(attachments, domainName) } } func finalizeRouteDiagnosis(diag *routeDiagnosis) { if !diag.Configured && diag.Runtime != nil { diag.Warnings = append(diag.Warnings, "route is not configured but runtime state still exists") } if len(diag.Volumes) > 0 { diag.Hints = append(diag.Hints, "persistent volumes are preserved by default") } if len(diag.OrphanedAttachments) > 0 { diag.Hints = append(diag.Hints, "run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes") } } func volumesForRouteDiagnosis(volumes []dto.Volume, domainName string) []dto.Volume { needle := strings.ReplaceAll(strings.ToLower(domainName), ".", "-") var out []dto.Volume for _, volume := range volumes { if strings.Contains(strings.ToLower(volume.Name), needle) { out = append(out, volume) } } return out } func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment { var out []domain.CleanupAttachment for _, attachment := range attachments { if strings.EqualFold(attachment.Owner, domainName) { out = append(out, attachment) } } return out } func writeRouteDiagnosisText(out io.Writer, diag *routeDiagnosis) error { if err := cliWriteLine(out, cliRenderTitle("Route diagnosis: "+diag.Domain)); err != nil { return err } if err := writeRouteDiagnosisSummary(out, diag); err != nil { return err } if err := writeRouteDiagnosisLeftovers(out, diag); err != nil { return err } return writeCleanupMessages(out, diag.Warnings, diag.Hints) } func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error { if diag.Configured && diag.Route != nil { if err := cliWriteLine(out, cliRenderMeta("Image:", diag.Route.Image)); err != nil { return err } } else if err := cliWriteLine(out, cliRenderWarning("Route is not configured")); err != nil { return err } if diag.Runtime == nil { return nil } return cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID))) } func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error { for _, volume := range diag.Volumes { if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil { return err } } for _, attachment := range diag.OrphanedAttachments { if err := cliWriteLine(out, cliRenderWarning("Orphaned attachment: "+attachment.Name)); err != nil { return err } } return nil } func newRoutesPurgeCmd() *cobra.Command { var jsonOut bool var force bool var includeAttachments bool var includeVolumes bool cmd := &cobra.Command{ Use: "purge ", Short: "Review or explicitly purge retained route resources", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runRoutePurgeCommand(cmd.Context(), args[0], cmd.OutOrStdout(), routePurgeOptions{Force: force, Attachments: includeAttachments, Volumes: includeVolumes}, jsonOut) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") cmd.Flags().BoolVar(&force, "force", false, "Execute purge actions; default is dry-run") cmd.Flags().BoolVar(&includeAttachments, "attachments", false, "Include orphaned attachment containers") cmd.Flags().BoolVar(&includeVolumes, "volumes", false, "Include preserved volumes in the purge plan") return cmd } type routePurgeOptions struct { Force bool Attachments bool Volumes bool } func runRoutePurgeCommand(ctx context.Context, domainName string, out io.Writer, opts routePurgeOptions, jsonOut bool) error { handle, err := resolveControlPlaneForRouteDomain(ctx, domainName) if err != nil { return err } defer handle.close() report, err := runRoutePurge(ctx, handle.plane, domainName, opts) if err != nil { return err } if jsonOut { return writeJSON(out, report) } return writeRoutePurgeText(out, report, opts.Force) } func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts routePurgeOptions) (*domain.CleanupReport, error) { diag, err := buildRouteDiagnosis(ctx, cp, domainName) if err != nil { return nil, err } report := &domain.CleanupReport{Domain: domainName} if diag.Runtime != nil && diag.Runtime.ContainerID != "" { report.OrphanedEntities = append(report.OrphanedEntities, domain.CleanupOrphanedEntity{Kind: "route_container", ID: diag.Runtime.ContainerID, Status: diag.Runtime.ContainerStatus, Reason: "route runtime state matched purge target"}) } if opts.Volumes { for _, volume := range diag.Volumes { report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"}) } } if !opts.Attachments || !opts.Force { report.PreservedAttachments = append(report.PreservedAttachments, diag.OrphanedAttachments...) } if opts.Attachments && opts.Force { cleaner, ok := cp.(interface { CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) }) if !ok { return nil, fmt.Errorf("attachment purge is unavailable") } attachmentReport, err := cleaner.CleanupOrphanedAttachments(ctx, domainName, true) if err != nil { return nil, err } report.RemovedContainers = append(report.RemovedContainers, attachmentReport.RemovedContainers...) report.PreservedVolumes = append(report.PreservedVolumes, attachmentReport.PreservedVolumes...) report.PreservedAttachments = append(report.PreservedAttachments, attachmentReport.PreservedAttachments...) report.OrphanedEntities = append(report.OrphanedEntities, attachmentReport.OrphanedEntities...) report.Warnings = append(report.Warnings, attachmentReport.Warnings...) report.Hints = append(report.Hints, attachmentReport.Hints...) report.PartialFailures = append(report.PartialFailures, attachmentReport.PartialFailures...) } if opts.Volumes { report.Warnings = append(report.Warnings, "volume purge is not executed yet; preserved volumes are reported for manual review") } if !opts.Force { report.Hints = append(report.Hints, "dry-run only; add --force with explicit category flags to execute supported purge actions") } return report, nil } func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error { mode := "Purge dry-run" if force { mode = "Purge completed" } if err := cliWriteLine(out, cliRenderTitle(mode+": "+report.Domain)); err != nil { return err } if err := writePreservedAttachments(out, report.PreservedAttachments); err != nil { return err } if err := writeRemovedContainers(out, report.RemovedContainers); err != nil { return err } for _, entity := range report.OrphanedEntities { if err := cliWriteLine(out, cliRenderWarning("Purge candidate: "+entity.Kind+" "+entity.ID)); err != nil { return err } } for _, volume := range report.PreservedVolumes { if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil { return err } } return writeCleanupMessages(out, report.Warnings, report.Hints) } 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 := cmd.Context() 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 } } handle, err := resolveControlPlaneForRouteDomain(ctx, routeDomain) if err != nil { return err } defer handle.close() var cleanup *dto.CleanupReport if remover, ok := handle.plane.(interface { RemoveRouteWithCleanup(context.Context, string) (*dto.RouteDeleteResponse, error) }); ok { resp, err := remover.RemoveRouteWithCleanup(ctx, routeDomain) if err != nil { return fmt.Errorf("failed to remove route: %w", err) } if resp != nil { cleanup = resp.Cleanup } } else if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil { return fmt.Errorf("failed to remove route: %w", err) } return writeRouteRemoveText(cmd.OutOrStdout(), routeDomain, cleanup) }, } cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") return cmd } func writeRouteRemoveText(out io.Writer, routeDomain string, cleanup *dto.CleanupReport) error { if err := cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))); err != nil { return err } if cleanup == nil { return nil } cleanupReport := cleanupReportDTOToDomain(cleanup) if err := writeRemovedContainers(out, cleanupReport.RemovedContainers); err != nil { return err } if err := writePreservedAttachments(out, cleanupReport.PreservedAttachments); err != nil { return err } return writeCleanupMessages(out, cleanupReport.Warnings, cleanupReport.Hints) } func cleanupReportDTOToDomain(cleanup *dto.CleanupReport) *domain.CleanupReport { if cleanup == nil { return nil } out := &domain.CleanupReport{ Domain: cleanup.Domain, Warnings: append([]string(nil), cleanup.Warnings...), Hints: append([]string(nil), cleanup.Hints...), } for _, item := range cleanup.RemovedContainers { out.RemovedContainers = append(out.RemovedContainers, domain.CleanupContainer(item)) } for _, item := range cleanup.PreservedAttachments { out.PreservedAttachments = append(out.PreservedAttachments, domain.CleanupAttachment(item)) } return out } func writeRemovedContainers(out io.Writer, containers []domain.CleanupContainer) error { if len(containers) == 0 { return nil } if err := cliWriteLine(out, ""); err != nil { return err } if err := cliWriteLine(out, styles.Theme.Bold.Render("Removed containers:")); err != nil { return err } for _, c := range containers { label := c.Name if label == "" { label = c.ID } if id := shortContainerID(c.ID); id != "" && id != label { label = fmt.Sprintf("%s (%s)", label, id) } if err := cliWriteLine(out, " "+label); err != nil { return err } } return nil } func writePreservedAttachments(out io.Writer, attachments []domain.CleanupAttachment) error { if len(attachments) == 0 { return nil } if err := cliWriteLine(out, ""); err != nil { return err } if err := cliWriteLine(out, styles.Theme.Bold.Render("Preserved attachments:")); err != nil { return err } for _, a := range attachments { label := a.Name if label == "" { label = a.ContainerID } if a.Status != "" { label = fmt.Sprintf("%s (%s)", label, a.Status) } if err := cliWriteLine(out, " "+label); err != nil { return err } } return nil } func writeCleanupMessages(out io.Writer, warnings, hints []string) error { for _, warning := range warnings { if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil { return err } } for _, hint := range hints { if err := cliWriteLine(out, cliRenderMuted("Hint: "+hint)); err != nil { return err } } return nil } // 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 }, } }