v2.31.0-dev23 58a92f3

routes.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"io"
8	"os"
9	"sort"
10	"strconv"
11	"strings"
12	"sync"
13
14	"github.com/spf13/cobra"
15
16	"github.com/bnema/gordon/internal/adapters/dto"
17	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
18	"github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
19	"github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
20	"github.com/bnema/gordon/internal/domain"
21)
22
23// configPath for local operations. If empty, config is auto-discovered
24// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
25var configPath string
26
27// truncateImage shortens long image references for display.
28// For digests (image@sha256:...), shows first 12 chars of digest.
29// For tags, truncates to maxLen with ellipsis if needed.
30func truncateImage(image string, maxLen int) string {
31	// Handle digest references: image@sha256:abc123...
32	if maxLen <= 0 {
33		return ""
34	}
35
36	if name, digest, ok := strings.Cut(image, "@sha256:"); ok {
37		if len(digest) > 12 {
38			digest = digest[:12]
39		}
40		short := fmt.Sprintf("%s@sha256:%s", name, digest)
41		if len(short) <= maxLen {
42			return short
43		}
44		// Truncate from the end to maintain valid reference format
45		if maxLen <= 3 {
46			return short[:maxLen]
47		}
48		return short[:maxLen-3] + "..."
49	}
50
51	// Regular tag: truncate if needed
52	if len(image) <= maxLen {
53		return image
54	}
55	if maxLen <= 3 {
56		return image[:maxLen]
57	}
58	return image[:maxLen-3] + "..."
59}
60
61const networkPrefix = "gordon-"
62
63// httpHealthToStatus maps an HTTP health probe result to a components.Status.
64func httpHealthToStatus(health *remote.RouteHealth) components.Status {
65	if health == nil {
66		return components.StatusUnknown
67	}
68	if health.HTTPStatus == 0 {
69		if health.Error != "" {
70			return components.StatusError
71		}
72		return components.StatusUnknown
73	}
74	if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
75		return components.StatusSuccess
76	}
77	return components.StatusError
78}
79
80// stripNetworkPrefix removes the "gordon-" prefix from a network name for display.
81func stripNetworkPrefix(network string) string {
82	return strings.TrimPrefix(network, networkPrefix)
83}
84
85// networkGroup holds routes that share a network.
86type networkGroup struct {
87	name   string
88	routes []remote.RouteInfo
89}
90
91type routeListItem struct {
92	Domain string `json:"domain"`
93	Image  string `json:"image"`
94}
95
96type routeListSection struct {
97	Kind   string          `json:"kind"`
98	Name   string          `json:"name"`
99	URL    string          `json:"url,omitempty"`
100	Error  string          `json:"error,omitempty"`
101	Routes []routeListItem `json:"routes,omitempty"`
102}
103
104type routeStatusAttachment struct {
105	Name   string `json:"name"`
106	Image  string `json:"image"`
107	Status string `json:"status"`
108}
109
110type routeStatusItem struct {
111	Domain          string                  `json:"domain"`
112	Image           string                  `json:"image"`
113	ContainerID     string                  `json:"container_id,omitempty"`
114	ContainerStatus string                  `json:"container_status"`
115	HTTPStatus      int                     `json:"http_status"`
116	HealthError     string                  `json:"health_error,omitempty"`
117	Network         string                  `json:"network"`
118	Attachments     []routeStatusAttachment `json:"attachments,omitempty"`
119}
120
121type routeStatusSection struct {
122	Kind   string            `json:"kind"`
123	Name   string            `json:"name"`
124	URL    string            `json:"url,omitempty"`
125	Error  string            `json:"error,omitempty"`
126	Routes []routeStatusItem `json:"routes,omitempty"`
127}
128
129type routeDiagnosis struct {
130	Domain              string                         `json:"domain"`
131	Configured          bool                           `json:"configured"`
132	Route               *domain.Route                  `json:"route,omitempty"`
133	Runtime             *remote.RouteInfo              `json:"runtime,omitempty"`
134	Health              *remote.RouteHealth            `json:"health,omitempty"`
135	Volumes             []dto.Volume                   `json:"volumes,omitempty"`
136	OrphanedAttachments []domain.CleanupAttachment     `json:"orphaned_attachments,omitempty"`
137	OrphanedEntities    []domain.CleanupOrphanedEntity `json:"orphaned_entities,omitempty"`
138	Warnings            []string                       `json:"warnings,omitempty"`
139	Hints               []string                       `json:"hints,omitempty"`
140}
141
142type routesListDeps struct {
143	explicitRemote func() (*remote.ResolvedRemote, bool, error)
144	loadLocal      func(context.Context, string) (routeListSection, error)
145	listRemotes    func() (map[string]remote.RemoteEntry, string, error)
146	loadRemote     func(context.Context, string, remote.RemoteEntry) (routeListSection, error)
147}
148
149type routesStatusDeps struct {
150	explicitRemote func() (*remote.ResolvedRemote, bool, error)
151	loadLocal      func(context.Context, string) (routeStatusSection, error)
152	listRemotes    func() (map[string]remote.RemoteEntry, string, error)
153	loadRemote     func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error)
154}
155
156// groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
157// and solo routes. Both are sorted alphabetically by domain.
158func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
159	byNetwork := make(map[string][]remote.RouteInfo)
160	var networkOrder []string
161	for _, route := range routes {
162		if _, seen := byNetwork[route.Network]; !seen {
163			networkOrder = append(networkOrder, route.Network)
164		}
165		byNetwork[route.Network] = append(byNetwork[route.Network], route)
166	}
167
168	var groups []networkGroup
169	var solo []remote.RouteInfo
170
171	for _, net := range networkOrder {
172		members := byNetwork[net]
173		if len(members) >= 2 {
174			sort.Slice(members, func(i, j int) bool {
175				return members[i].Domain < members[j].Domain
176			})
177			groups = append(groups, networkGroup{
178				name:   stripNetworkPrefix(net),
179				routes: members,
180			})
181		} else {
182			solo = append(solo, members[0])
183		}
184	}
185
186	sort.Slice(solo, func(i, j int) bool {
187		return solo[i].Domain < solo[j].Domain
188	})
189
190	return groups, solo
191}
192
193// newRoutesCmd creates the routes command group.
194func newRoutesCmd() *cobra.Command {
195	cmd := &cobra.Command{
196		Use:   "routes",
197		Short: "Manage routes",
198		Long: `Manage Gordon routes. Routes map domains to container images.
199
200When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
201these commands operate on the remote server. Otherwise, they require access to
202the local Gordon configuration.`,
203	}
204
205	cmd.AddCommand(newRoutesListCmd())
206	cmd.AddCommand(newRoutesStatusCmd())
207
208	cmd.AddCommand(newRoutesShowCmd())
209	cmd.AddCommand(newRoutesDiagnoseCmd())
210	cmd.AddCommand(newRoutesPurgeCmd())
211	cmd.AddCommand(newRoutesAddCmd())
212	cmd.AddCommand(newRoutesRemoveCmd())
213
214	return cmd
215}
216
217// newRoutesListCmd creates the routes list command.
218func newRoutesListCmd() *cobra.Command {
219	var jsonOut bool
220
221	cmd := &cobra.Command{
222		Use:   "list",
223		Short: "List all routes",
224		RunE: func(cmd *cobra.Command, args []string) error {
225			sections, err := collectRoutesListSections(cmd.Context(), configPath, routesListDeps{})
226			if err != nil {
227				return err
228			}
229
230			if jsonOut {
231				return writeJSON(cmd.OutOrStdout(), sections)
232			}
233
234			return renderRoutesListSections(cmd.OutOrStdout(), sections)
235		},
236	}
237
238	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
239
240	return cmd
241}
242
243func newRoutesStatusCmd() *cobra.Command {
244	var jsonOut bool
245
246	cmd := &cobra.Command{
247		Use:   "status",
248		Short: "Show detailed route status",
249		RunE: func(cmd *cobra.Command, args []string) error {
250			sections, err := collectRoutesStatusSections(cmd.Context(), configPath, routesStatusDeps{})
251			if err != nil {
252				return err
253			}
254
255			if jsonOut {
256				return writeJSON(cmd.OutOrStdout(), sections)
257			}
258
259			return renderRoutesStatusSections(cmd.OutOrStdout(), sections)
260		},
261	}
262
263	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
264
265	return cmd
266}
267
268func collectRoutesListSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
269	if deps.explicitRemote == nil {
270		deps.explicitRemote = resolveRoutesExplicitRemote
271	}
272	if deps.loadLocal == nil {
273		deps.loadLocal = loadRoutesListLocalSection
274	}
275	if deps.listRemotes == nil {
276		deps.listRemotes = remote.ListRemotes
277	}
278	if deps.loadRemote == nil {
279		deps.loadRemote = loadRoutesListRemoteSection
280	}
281
282	if resolved, ok, err := deps.explicitRemote(); err != nil {
283		return nil, err
284	} else if ok {
285		section := loadRoutesListExplicitRemoteSection(ctx, deps, resolved)
286		return []routeListSection{section}, nil
287	}
288	if target, ok := resolveRoutesExplicitTarget(); ok {
289		return []routeListSection{{
290			Kind:  "remote",
291			Name:  target,
292			Error: unresolvedRoutesTargetError(target),
293		}}, nil
294	}
295
296	return collectRoutesListAggregateSections(ctx, cfgPath, deps)
297}
298
299func loadRoutesListExplicitRemoteSection(ctx context.Context, deps routesListDeps, resolved *remote.ResolvedRemote) routeListSection {
300	section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
301	if err != nil && section.Error == "" {
302		section.Error = err.Error()
303	}
304	return normalizeRouteListRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
305}
306
307func collectRoutesListAggregateSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
308
309	type localResult struct {
310		section routeListSection
311		err     error
312	}
313	type remotesResult struct {
314		entries map[string]remote.RemoteEntry
315		err     error
316	}
317
318	localCh := make(chan localResult, 1)
319	remotesCh := make(chan remotesResult, 1)
320
321	go func() {
322		section, err := deps.loadLocal(ctx, cfgPath)
323		localCh <- localResult{section: section, err: err}
324	}()
325	go func() {
326		entries, _, err := deps.listRemotes()
327		remotesCh <- remotesResult{entries: entries, err: err}
328	}()
329
330	localRes := <-localCh
331	remotes := <-remotesCh
332	local := localRes.section
333	if err := localRes.err; err != nil {
334		if local.Error == "" {
335			local.Error = err.Error()
336		}
337	}
338
339	sections := make([]routeListSection, 0, 1)
340	sections = append(sections, normalizeRouteListLocalSection(local))
341
342	if remotes.err != nil {
343		sections = append(sections, routeListSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
344		return sections, nil
345	}
346
347	names := sortedRemoteNames(remotes.entries)
348	remoteSections := make([]routeListSection, len(names))
349	var wg sync.WaitGroup
350	for i, name := range names {
351		entry := remotes.entries[name]
352		wg.Add(1)
353		go func(idx int, remoteName string, remoteEntry remote.RemoteEntry) {
354			defer wg.Done()
355			section, err := deps.loadRemote(ctx, remoteName, remoteEntry)
356			if err != nil && section.Error == "" {
357				section.Error = err.Error()
358			}
359			remoteSections[idx] = normalizeRouteListRemoteSection(section, remoteName, remoteEntry)
360		}(i, name, entry)
361	}
362	wg.Wait()
363
364	sections = append(sections, remoteSections...)
365	return sections, nil
366}
367
368func normalizeRouteListLocalSection(section routeListSection) routeListSection {
369	if section.Kind == "" {
370		section.Kind = "local"
371	}
372	if section.Name == "" {
373		section.Name = "local"
374	}
375	return section
376}
377
378func normalizeRouteListRemoteSection(section routeListSection, name string, entry remote.RemoteEntry) routeListSection {
379	if section.Kind == "" {
380		section.Kind = "remote"
381	}
382	if section.Name == "" {
383		section.Name = name
384	}
385	if section.URL == "" {
386		section.URL = entry.URL
387	}
388	return section
389}
390
391func collectRoutesStatusSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
392	if deps.explicitRemote == nil {
393		deps.explicitRemote = resolveRoutesExplicitRemote
394	}
395	if deps.loadLocal == nil {
396		deps.loadLocal = loadRoutesStatusLocalSection
397	}
398	if deps.listRemotes == nil {
399		deps.listRemotes = remote.ListRemotes
400	}
401	if deps.loadRemote == nil {
402		deps.loadRemote = loadRoutesStatusRemoteSection
403	}
404
405	if resolved, ok, err := deps.explicitRemote(); err != nil {
406		return nil, err
407	} else if ok {
408		section := loadRoutesStatusExplicitRemoteSection(ctx, deps, resolved)
409		return []routeStatusSection{section}, nil
410	}
411	if target, ok := resolveRoutesExplicitTarget(); ok {
412		return []routeStatusSection{{
413			Kind:  "remote",
414			Name:  target,
415			Error: unresolvedRoutesTargetError(target),
416		}}, nil
417	}
418
419	return collectRoutesStatusAggregateSections(ctx, cfgPath, deps)
420}
421
422func loadRoutesStatusExplicitRemoteSection(ctx context.Context, deps routesStatusDeps, resolved *remote.ResolvedRemote) routeStatusSection {
423	section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
424	if err != nil && section.Error == "" {
425		section.Error = err.Error()
426	}
427	return normalizeRouteStatusRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
428}
429
430func collectRoutesStatusAggregateSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
431
432	type localResult struct {
433		section routeStatusSection
434		err     error
435	}
436	type remotesResult struct {
437		entries map[string]remote.RemoteEntry
438		err     error
439	}
440
441	localCh := make(chan localResult, 1)
442	remotesCh := make(chan remotesResult, 1)
443
444	go func() {
445		section, err := deps.loadLocal(ctx, cfgPath)
446		localCh <- localResult{section: section, err: err}
447	}()
448	go func() {
449		entries, _, err := deps.listRemotes()
450		remotesCh <- remotesResult{entries: entries, err: err}
451	}()
452
453	localRes := <-localCh
454	remotes := <-remotesCh
455	local := localRes.section
456	if err := localRes.err; err != nil {
457		if local.Error == "" {
458			local.Error = err.Error()
459		}
460	}
461
462	sections := make([]routeStatusSection, 0, 1)
463	sections = append(sections, normalizeRouteStatusLocalSection(local))
464
465	if remotes.err != nil {
466		sections = append(sections, routeStatusSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
467		return sections, nil
468	}
469
470	names := sortedRemoteNames(remotes.entries)
471	remoteSections := make([]routeStatusSection, len(names))
472	var wg sync.WaitGroup
473	for i, name := range names {
474		entry := remotes.entries[name]
475		wg.Add(1)
476		go func(idx int, remoteName string, remoteEntry remote.RemoteEntry) {
477			defer wg.Done()
478			section, err := deps.loadRemote(ctx, remoteName, remoteEntry)
479			if err != nil && section.Error == "" {
480				section.Error = err.Error()
481			}
482			remoteSections[idx] = normalizeRouteStatusRemoteSection(section, remoteName, remoteEntry)
483		}(i, name, entry)
484	}
485	wg.Wait()
486
487	sections = append(sections, remoteSections...)
488	return sections, nil
489}
490
491func normalizeRouteStatusLocalSection(section routeStatusSection) routeStatusSection {
492	if section.Kind == "" {
493		section.Kind = "local"
494	}
495	if section.Name == "" {
496		section.Name = "local"
497	}
498	return section
499}
500
501func normalizeRouteStatusRemoteSection(section routeStatusSection, name string, entry remote.RemoteEntry) routeStatusSection {
502	if section.Kind == "" {
503		section.Kind = "remote"
504	}
505	if section.Name == "" {
506		section.Name = name
507	}
508	if section.URL == "" {
509		section.URL = entry.URL
510	}
511	return section
512}
513
514func renderRoutesListSections(out io.Writer, sections []routeListSection) error {
515	sections = renderedRouteListSections(sections)
516
517	if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
518		return err
519	}
520	if err := cliWriteLine(out, ""); err != nil {
521		return err
522	}
523
524	for i, section := range sections {
525		if i > 0 {
526			if err := cliWriteLine(out, ""); err != nil {
527				return err
528			}
529		}
530		if err := renderRoutesListSection(out, section); err != nil {
531			return err
532		}
533	}
534
535	return nil
536}
537
538func renderRoutesListSection(out io.Writer, section routeListSection) error {
539	if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
540		return err
541	}
542
543	if len(section.Routes) > 0 {
544		const imageColWidth = 45
545		rows := make([][]string, 0, len(section.Routes))
546		for _, route := range section.Routes {
547			rows = append(rows, []string{route.Domain, truncateImage(route.Image, imageColWidth)})
548		}
549
550		table := components.NewTable(
551			components.WithColumns([]components.TableColumn{
552				{Title: "Domain", Width: 30},
553				{Title: "Image", Width: imageColWidth},
554			}),
555			components.WithRows(rows),
556		)
557
558		if err := cliWriteLine(out, table.View()); err != nil {
559			return err
560		}
561	}
562
563	if section.Error != "" {
564		return cliWriteLine(out, cliRenderWarning(section.Error))
565	}
566
567	if len(section.Routes) == 0 {
568		return cliWriteLine(out, cliRenderMuted("No routes configured"))
569	}
570
571	return nil
572}
573
574func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error {
575	sections = renderedRouteStatusSections(sections)
576
577	if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil {
578		return err
579	}
580	if err := cliWriteLine(out, ""); err != nil {
581		return err
582	}
583
584	for i, section := range sections {
585		if i > 0 {
586			if err := cliWriteLine(out, ""); err != nil {
587				return err
588			}
589		}
590		if err := renderRoutesStatusSection(out, section); err != nil {
591			return err
592		}
593	}
594
595	return nil
596}
597
598func renderedRouteListSections(sections []routeListSection) []routeListSection {
599	if !shouldHideLocalRouteListSection(sections) {
600		return sections
601	}
602	return sections[1:]
603}
604
605func shouldHideLocalRouteListSection(sections []routeListSection) bool {
606	if len(sections) < 2 {
607		return false
608	}
609	local := sections[0]
610	if local.Kind != "local" || local.Error == "" {
611		return false
612	}
613	for _, section := range sections[1:] {
614		if section.Kind == "remote" && section.Error == "" {
615			return true
616		}
617	}
618	return false
619}
620
621func renderedRouteStatusSections(sections []routeStatusSection) []routeStatusSection {
622	if !shouldHideLocalRouteStatusSection(sections) {
623		return sections
624	}
625	return sections[1:]
626}
627
628func shouldHideLocalRouteStatusSection(sections []routeStatusSection) bool {
629	if len(sections) < 2 {
630		return false
631	}
632	local := sections[0]
633	if local.Kind != "local" || local.Error == "" {
634		return false
635	}
636	for _, section := range sections[1:] {
637		if section.Kind == "remote" && section.Error == "" {
638			return true
639		}
640	}
641	return false
642}
643
644func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error {
645	if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
646		return err
647	}
648
649	if len(section.Routes) > 0 {
650		tree := buildRouteStatusTree(section.Routes)
651		if err := cliWriteLine(out, strings.TrimSuffix(tree.Render(), "\n")); err != nil {
652			return err
653		}
654	}
655
656	if section.Error != "" {
657		return cliWriteLine(out, cliRenderWarning(section.Error))
658	}
659
660	if len(section.Routes) == 0 {
661		return cliWriteLine(out, cliRenderMuted("No routes configured"))
662	}
663
664	return nil
665}
666
667func routeSectionHeading(kind, name string) string {
668	switch kind {
669	case "local":
670		return "Local"
671	case "remote":
672		if name != "" {
673			return "Remote: " + name
674		}
675		return "Remote"
676	default:
677		if name != "" {
678			return capitalizeKind(kind) + ": " + name
679		}
680		return capitalizeKind(kind)
681	}
682}
683
684func capitalizeKind(kind string) string {
685	if kind == "" {
686		return ""
687	}
688	if len(kind) == 1 {
689		return strings.ToUpper(kind)
690	}
691	return strings.ToUpper(kind[:1]) + kind[1:]
692}
693
694func unresolvedRoutesTargetError(target string) string {
695	return fmt.Sprintf("could not resolve remote target %q", target)
696}
697
698func resolveRoutesExplicitRemote() (*remote.ResolvedRemote, bool, error) {
699	target, ok := resolveRoutesExplicitTarget()
700	if !ok {
701		return nil, false, nil
702	}
703
704	if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
705		return &remote.ResolvedRemote{
706			URL:         target,
707			Token:       resolveRoutesTokenForTarget("", remote.RemoteEntry{}),
708			InsecureTLS: resolveRoutesInsecureForTarget("", remote.RemoteEntry{}),
709		}, true, nil
710	}
711
712	remotes, err := remote.LoadRemotes("")
713	if err != nil {
714		return nil, false, err
715	}
716
717	if remotes != nil {
718		if entry, found := remotes.Remotes[target]; found {
719			return &remote.ResolvedRemote{
720				Name:        target,
721				URL:         entry.URL,
722				Token:       resolveRoutesTokenForTarget(target, entry),
723				InsecureTLS: resolveRoutesInsecureForTarget(target, entry),
724			}, true, nil
725		}
726	}
727
728	return nil, false, nil
729}
730
731func resolveRoutesExplicitTarget() (string, bool) {
732	if target := strings.TrimSpace(remoteFlag); target != "" {
733		return target, true
734	}
735	if target := strings.TrimSpace(os.Getenv("GORDON_REMOTE")); target != "" {
736		return target, true
737	}
738	return "", false
739}
740
741func resolveRoutesTokenForTarget(name string, entry remote.RemoteEntry) string {
742	if token := strings.TrimSpace(tokenFlag); token != "" {
743		return token
744	}
745	if token := strings.TrimSpace(os.Getenv("GORDON_TOKEN")); token != "" {
746		return token
747	}
748	if name != "" {
749		return remote.ResolveTokenForRemote(name, entry)
750	}
751	return ""
752}
753
754func resolveRoutesInsecureForTarget(name string, entry remote.RemoteEntry) bool {
755	if insecureTLSFlag {
756		return true
757	}
758	if env := strings.TrimSpace(os.Getenv("GORDON_INSECURE")); env != "" {
759		if value, err := strconv.ParseBool(env); err == nil {
760			return value
761		}
762	}
763	if name != "" {
764		return entry.InsecureTLS
765	}
766	return false
767}
768
769func newRoutesTargetClient(name string, entry remote.RemoteEntry) *remote.Client {
770	return remote.NewClient(entry.URL, remoteClientOptions(resolveRoutesTokenForTarget(name, entry), resolveRoutesInsecureForTarget(name, entry))...)
771}
772
773func loadRoutesListLocalSection(ctx context.Context, cfgPath string) (routeListSection, error) {
774	local, err := GetLocalServices(cfgPath)
775	if err != nil {
776		return routeListSection{Kind: "local", Name: "local", Error: err.Error()}, nil
777	}
778
779	routes := local.GetConfigService().GetRoutes(ctx)
780	section := routeListSection{Kind: "local", Name: "local", Routes: make([]routeListItem, 0, len(routes))}
781	for _, route := range routes {
782		section.Routes = append(section.Routes, routeListItem{Domain: route.Domain, Image: route.Image})
783	}
784
785	return section, nil
786}
787
788func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
789	section := routeListSection{Kind: "remote", Name: name, URL: entry.URL}
790	client := newRoutesTargetClient(name, entry)
791	routes, err := client.ListRoutes(ctx)
792	if err != nil {
793		section.Error = err.Error()
794		return section, nil
795	}
796
797	section.Routes = routeListItemsFromRoutes(routes)
798	return section, nil
799}
800
801func loadRoutesStatusLocalSection(ctx context.Context, cfgPath string) (routeStatusSection, error) {
802	section := routeStatusSection{Kind: "local", Name: "local"}
803	handle, err := resolveLocalControlPlane(cfgPath)
804	if err != nil {
805		section.Error = err.Error()
806		return section, nil
807	}
808	defer handle.close()
809
810	routes, err := handle.plane.ListRoutesWithDetails(ctx)
811	if err != nil {
812		section.Error = err.Error()
813		return section, nil
814	}
815
816	health, err := handle.plane.GetHealth(ctx)
817	if err != nil {
818		section.Error = err.Error()
819		health = nil
820	}
821
822	section.Routes = routeStatusItemsFromInfos(routes, health)
823	return section, nil
824}
825
826func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeStatusSection, error) {
827	section := routeStatusSection{Kind: "remote", Name: name, URL: entry.URL}
828	client := newRoutesTargetClient(name, entry)
829	cp := NewRemoteControlPlane(client)
830
831	routes, err := cp.ListRoutesWithDetails(ctx)
832	if err != nil {
833		section.Error = err.Error()
834		return section, nil
835	}
836
837	health, err := cp.GetHealth(ctx)
838	if err != nil {
839		section.Error = err.Error()
840		health = nil
841	}
842
843	section.Routes = routeStatusItemsFromInfos(routes, health)
844	return section, nil
845}
846
847func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem {
848	items := make([]routeStatusItem, 0, len(routes))
849	for _, route := range routes {
850		items = append(items, routeStatusItemFromInfo(route, health[route.Domain]))
851	}
852	return items
853}
854
855func routeStatusItemFromInfo(route remote.RouteInfo, routeHealth *remote.RouteHealth) routeStatusItem {
856	item := routeStatusItem{
857		Domain:          route.Domain,
858		Image:           route.Image,
859		ContainerID:     route.ContainerID,
860		ContainerStatus: route.ContainerStatus,
861		Network:         route.Network,
862	}
863	if item.ContainerStatus == "" && routeHealth != nil {
864		item.ContainerStatus = routeHealth.ContainerStatus
865		item.HTTPStatus = routeHealth.HTTPStatus
866		item.HealthError = routeHealth.Error
867	}
868	if item.ContainerStatus == "" {
869		item.ContainerStatus = "unknown"
870	}
871	if item.HTTPStatus == 0 && routeHealth != nil {
872		item.HTTPStatus = routeHealth.HTTPStatus
873		item.HealthError = routeHealth.Error
874	}
875	if item.HealthError == "" && routeHealth != nil {
876		item.HealthError = routeHealth.Error
877	}
878	item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments))
879	for _, attachment := range route.Attachments {
880		status := attachment.Status
881		if status == "" {
882			status = "unknown"
883		}
884		item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status})
885	}
886	return item
887}
888
889func routeListItemsFromRoutes(routes []domain.Route) []routeListItem {
890	items := make([]routeListItem, 0, len(routes))
891	for _, route := range routes {
892		items = append(items, routeListItem{Domain: route.Domain, Image: route.Image})
893	}
894	return items
895}
896
897func buildRouteStatusTree(routes []routeStatusItem) *components.Tree {
898	sortedRoutes := make([]routeStatusItem, len(routes))
899	copy(sortedRoutes, routes)
900	sort.SliceStable(sortedRoutes, func(i, j int) bool {
901		if sortedRoutes[i].Network != sortedRoutes[j].Network {
902			return sortedRoutes[i].Network < sortedRoutes[j].Network
903		}
904		if sortedRoutes[i].Domain != sortedRoutes[j].Domain {
905			return sortedRoutes[i].Domain < sortedRoutes[j].Domain
906		}
907		if sortedRoutes[i].Image != sortedRoutes[j].Image {
908			return sortedRoutes[i].Image < sortedRoutes[j].Image
909		}
910		if sortedRoutes[i].ContainerID != sortedRoutes[j].ContainerID {
911			return sortedRoutes[i].ContainerID < sortedRoutes[j].ContainerID
912		}
913		if sortedRoutes[i].ContainerStatus != sortedRoutes[j].ContainerStatus {
914			return sortedRoutes[i].ContainerStatus < sortedRoutes[j].ContainerStatus
915		}
916		return sortedRoutes[i].HTTPStatus < sortedRoutes[j].HTTPStatus
917	})
918
919	infos := make([]remote.RouteInfo, 0, len(routes))
920	itemsByDomain := make(map[string]routeStatusItem, len(routes))
921	for _, route := range sortedRoutes {
922		infos = append(infos, remote.RouteInfo{
923			Domain:          route.Domain,
924			Image:           route.Image,
925			ContainerID:     route.ContainerID,
926			ContainerStatus: route.ContainerStatus,
927			Network:         route.Network,
928			Attachments:     routeStatusAttachmentsToRemote(route.Attachments),
929		})
930		itemsByDomain[route.Domain] = route
931	}
932
933	groups, solo := groupRoutesByNetwork(infos)
934	tree := components.NewTree()
935
936	for _, group := range groups {
937		g := tree.AddGroup(group.name)
938		for _, route := range group.routes {
939			item := itemsByDomain[route.Domain]
940			node := g.AddNode(routeStatusTitle(item), item.Image)
941			addRouteStatusAttachmentChildren(node, item)
942		}
943	}
944
945	for _, route := range solo {
946		item := itemsByDomain[route.Domain]
947		node := tree.AddNode(routeStatusTitle(item), item.Image)
948		addRouteStatusAttachmentChildren(node, item)
949	}
950
951	return tree
952}
953
954func routeStatusTitle(route routeStatusItem) string {
955	containerStatus := route.ContainerStatus
956	if containerStatus == "" {
957		containerStatus = "unknown"
958	}
959
960	httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(&remote.RouteHealth{HTTPStatus: route.HTTPStatus, Error: route.HealthError}))
961	containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
962
963	return httpIcon + " " + containerIcon + " " + route.Domain
964}
965
966func addRouteStatusAttachmentChildren(node *components.Node, route routeStatusItem) {
967	for _, att := range route.Attachments {
968		status := att.Status
969		if status == "" {
970			status = "unknown"
971		}
972		attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(status))
973		node.AddChild(attIcon+" "+att.Name, att.Image)
974	}
975}
976
977func routeStatusAttachmentsToRemote(attachments []routeStatusAttachment) []remote.Attachment {
978	result := make([]remote.Attachment, 0, len(attachments))
979	for _, att := range attachments {
980		result = append(result, remote.Attachment{Name: att.Name, Image: att.Image, Status: att.Status})
981	}
982	return result
983}
984
985func newRoutesShowCmd() *cobra.Command {
986	var jsonOut bool
987
988	cmd := &cobra.Command{
989		Use:   "show <domain>",
990		Short: "Show details for a single route",
991		Long: `Display detailed information about a specific route including its image,
992container status, and health.
993
994Examples:
995  gordon routes show app.mydomain.com
996  gordon routes show app.mydomain.com --json
997  gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
998		Args: cobra.ExactArgs(1),
999		RunE: func(cmd *cobra.Command, args []string) error {
1000			ctx := cmd.Context()
1001			handle, err := resolveControlPlaneForRouteDomain(ctx, args[0])
1002			if err != nil {
1003				return err
1004			}
1005			defer handle.close()
1006			return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
1007		},
1008	}
1009
1010	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1011
1012	return cmd
1013}
1014
1015func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
1016	route, err := cp.GetRoute(ctx, routeDomain)
1017	if err != nil {
1018		if errors.Is(err, domain.ErrRouteNotFound) {
1019			return fmt.Errorf("route %q not found", routeDomain)
1020		}
1021		return fmt.Errorf("failed to get route: %w", err)
1022	}
1023
1024	health, err := cp.GetHealth(ctx)
1025	healthErr := ""
1026	if err != nil {
1027		healthErr = err.Error()
1028		health = nil
1029	}
1030	var routeHealth *remote.RouteHealth
1031	if health != nil {
1032		routeHealth = health[routeDomain]
1033	}
1034
1035	containerStatus := "unknown"
1036	httpStatus := 0
1037	if routeHealth != nil {
1038		if routeHealth.ContainerStatus != "" {
1039			containerStatus = routeHealth.ContainerStatus
1040		}
1041		httpStatus = routeHealth.HTTPStatus
1042		if healthErr == "" && routeHealth.Error != "" {
1043			healthErr = routeHealth.Error
1044		}
1045	}
1046
1047	if jsonOut {
1048		payload := map[string]any{
1049			"domain":           route.Domain,
1050			"image":            route.Image,
1051			"container_status": containerStatus,
1052			"http_status":      httpStatus,
1053		}
1054		if healthErr != "" {
1055			payload["health_error"] = healthErr
1056		}
1057		return writeJSON(out, payload)
1058	}
1059
1060	return writeRouteShowText(out, route.Domain, route.Image, containerStatus, httpStatus, healthErr)
1061}
1062
1063func writeRouteShowText(out io.Writer, domainName, image, containerStatus string, httpStatus int, healthErr string) error {
1064	if err := cliWriteLine(out, cliRenderTitle("Route: "+domainName)); err != nil {
1065		return err
1066	}
1067	if err := cliWriteLine(out, ""); err != nil {
1068		return err
1069	}
1070	if err := cliWriteLine(out, cliRenderMeta("Domain:", domainName)); err != nil {
1071		return err
1072	}
1073	if err := cliWriteLine(out, cliRenderMeta("Image:", image)); err != nil {
1074		return err
1075	}
1076	if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
1077		return err
1078	}
1079	if httpStatus > 0 {
1080		if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
1081			return err
1082		}
1083	}
1084	if healthErr != "" {
1085		if err := cliWriteLine(out, cliRenderWarning(healthErr)); err != nil {
1086			return err
1087		}
1088	}
1089
1090	return nil
1091}
1092
1093// newRoutesDiagnoseCmd creates the routes diagnose command.
1094func newRoutesDiagnoseCmd() *cobra.Command {
1095	var jsonOut bool
1096	cmd := &cobra.Command{
1097		Use:   "diagnose <domain>",
1098		Short: "Diagnose route configuration, runtime state, and cleanup leftovers",
1099		Args:  cobra.ExactArgs(1),
1100		RunE: func(cmd *cobra.Command, args []string) error {
1101			return runRouteDiagnosis(cmd.Context(), args[0], cmd.OutOrStdout(), jsonOut)
1102		},
1103	}
1104	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1105	return cmd
1106}
1107
1108func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, jsonOut bool) error {
1109	handle, err := resolveControlPlaneForRouteCleanupDomain(ctx, domainName)
1110	if err != nil {
1111		return err
1112	}
1113	defer handle.close()
1114	diag, err := buildRouteDiagnosis(ctx, handle.plane, domainName)
1115	if err != nil {
1116		return err
1117	}
1118	if jsonOut {
1119		return writeJSON(out, diag)
1120	}
1121	return writeRouteDiagnosisText(out, diag)
1122}
1123
1124func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) {
1125	diag := &routeDiagnosis{Domain: domainName}
1126	loadRouteDiagnosisConfig(ctx, cp, domainName, diag)
1127	loadRouteDiagnosisCleanupPreview(ctx, cp, domainName, diag)
1128	loadRouteDiagnosisRuntime(ctx, cp, domainName, diag)
1129	loadRouteDiagnosisHealth(ctx, cp, domainName, diag)
1130	loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag)
1131	loadRouteDiagnosisVolumes(ctx, cp, domainName, diag)
1132	finalizeRouteDiagnosis(diag)
1133	return diag, nil
1134}
1135
1136func loadRouteDiagnosisConfig(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1137	route, err := cp.GetRoute(ctx, domainName)
1138	if err == nil && route != nil {
1139		diag.Configured = true
1140		diag.Route = route
1141		return
1142	}
1143	if err != nil && !errors.Is(err, domain.ErrRouteNotFound) {
1144		diag.Warnings = append(diag.Warnings, "failed to load route config: "+err.Error())
1145	}
1146}
1147
1148func loadRouteDiagnosisCleanupPreview(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1149	if diag.Configured {
1150		return
1151	}
1152	previewer, ok := cp.(interface {
1153		GetRouteCleanupPreview(context.Context, string) (*domain.CleanupReport, error)
1154	})
1155	if !ok {
1156		return
1157	}
1158
1159	report, err := previewer.GetRouteCleanupPreview(ctx, domainName)
1160	if err != nil {
1161		if isRemoteNotFoundError(err) {
1162			return
1163		}
1164		diag.Warnings = append(diag.Warnings, "failed to load route cleanup preview: "+err.Error())
1165		return
1166	}
1167	if report == nil {
1168		return
1169	}
1170	if len(diag.OrphanedAttachments) == 0 {
1171		diag.OrphanedAttachments = append(diag.OrphanedAttachments, report.PreservedAttachments...)
1172	}
1173	if len(diag.Volumes) == 0 {
1174		diag.Volumes = cleanupVolumesForDiagnosis(report.PreservedVolumes)
1175	}
1176	diag.OrphanedEntities = append(diag.OrphanedEntities, report.OrphanedEntities...)
1177	diag.Warnings = append(diag.Warnings, report.Warnings...)
1178	diag.Hints = append(diag.Hints, report.Hints...)
1179}
1180
1181func loadRouteDiagnosisRuntime(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1182	routes, err := cp.ListRoutesWithDetails(ctx)
1183	if err != nil {
1184		diag.Warnings = append(diag.Warnings, "failed to load route runtime details: "+err.Error())
1185		return
1186	}
1187	for _, info := range routes {
1188		if strings.EqualFold(info.Domain, domainName) {
1189			copy := info
1190			diag.Runtime = &copy
1191			return
1192		}
1193	}
1194}
1195
1196func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1197	if health, err := cp.GetHealth(ctx); err == nil && health != nil {
1198		diag.Health = health[domainName]
1199	}
1200}
1201
1202func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1203	if len(diag.Volumes) > 0 {
1204		return
1205	}
1206	if volumes, err := cp.ListVolumes(ctx); err == nil {
1207		if len(volumes) == 0 {
1208			return
1209		}
1210		scope := newRouteResourceScope(domainName, routeDiagnosisVolumePrefix(ctx, cp), diag.Runtime, diag.OrphanedAttachments)
1211		diag.Volumes = volumesForRouteDiagnosis(volumes, scope)
1212	}
1213}
1214
1215func routeDiagnosisVolumePrefix(ctx context.Context, cp ControlPlane) string {
1216	const defaultPrefix = "gordon"
1217	cfg, err := cp.GetConfig(ctx)
1218	if err != nil || cfg == nil || cfg.Volumes.Prefix == "" {
1219		return defaultPrefix
1220	}
1221	return cfg.Volumes.Prefix
1222}
1223
1224func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1225	if len(diag.OrphanedAttachments) > 0 {
1226		return
1227	}
1228	lister, ok := cp.(interface {
1229		ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)
1230	})
1231	if !ok {
1232		return
1233	}
1234	if attachments, err := lister.ListOrphanedAttachments(ctx); err == nil {
1235		diag.OrphanedAttachments = attachmentsForRouteDiagnosis(attachments, domainName)
1236	}
1237}
1238
1239func cleanupVolumesForDiagnosis(volumes []domain.CleanupVolume) []dto.Volume {
1240	result := make([]dto.Volume, 0, len(volumes))
1241	for _, volume := range volumes {
1242		result = append(result, dto.Volume{Name: volume.Name})
1243	}
1244	return result
1245}
1246
1247func finalizeRouteDiagnosis(diag *routeDiagnosis) {
1248	if !diag.Configured && (diag.Runtime != nil || hasRouteCleanupEntity(diag.OrphanedEntities, "route_container")) {
1249		diag.Warnings = append(diag.Warnings, "route is not configured but runtime state still exists")
1250	}
1251	if len(diag.Volumes) > 0 {
1252		diag.Hints = append(diag.Hints, "persistent volumes are preserved by default")
1253	}
1254	if len(diag.OrphanedAttachments) > 0 {
1255		diag.Hints = append(diag.Hints, fmt.Sprintf("run 'gordon routes purge %s --attachments' to review orphaned attachment cleanup for this route", diag.Domain))
1256	}
1257}
1258
1259func hasRouteCleanupEntity(entities []domain.CleanupOrphanedEntity, kind string) bool {
1260	for _, entity := range entities {
1261		if entity.Kind == kind {
1262			return true
1263		}
1264	}
1265	return false
1266}
1267
1268type routeResourceScope struct {
1269	containerNames map[string]struct{}
1270	volumePrefixes []string
1271}
1272
1273func newRouteResourceScope(domainName, volumePrefix string, runtime *remote.RouteInfo, orphaned []domain.CleanupAttachment) routeResourceScope {
1274	if volumePrefix == "" {
1275		volumePrefix = "gordon"
1276	}
1277	scope := routeResourceScope{
1278		containerNames: make(map[string]struct{}),
1279		volumePrefixes: []string{volumePrefix + "-" + strings.ReplaceAll(domainName, ".", "-") + "-"},
1280	}
1281	for _, name := range []string{
1282		fmt.Sprintf("gordon-%s", domainName),
1283		fmt.Sprintf("gordon-%s-new", domainName),
1284		fmt.Sprintf("gordon-%s-next", domainName),
1285	} {
1286		scope.containerNames[name] = struct{}{}
1287	}
1288	if runtime != nil {
1289		for _, attachment := range runtime.Attachments {
1290			for _, name := range routeAttachmentContainerNameCandidates(domainName, attachment.Name) {
1291				scope.containerNames[name] = struct{}{}
1292				scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+name+"-")
1293			}
1294		}
1295	}
1296	for _, attachment := range orphaned {
1297		if attachment.Name == "" {
1298			continue
1299		}
1300		scope.containerNames[attachment.Name] = struct{}{}
1301		scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+attachment.Name+"-")
1302	}
1303	return scope
1304}
1305
1306func routeAttachmentContainerNameCandidates(domainName, serviceName string) []string {
1307	if serviceName == "" {
1308		return nil
1309	}
1310	return []string{
1311		fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainer(domainName), serviceName),
1312		fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainerLegacy(domainName), serviceName),
1313	}
1314}
1315
1316func volumesForRouteDiagnosis(volumes []dto.Volume, scope routeResourceScope) []dto.Volume {
1317	var out []dto.Volume
1318	for _, volume := range volumes {
1319		if scope.matchesVolume(volume) {
1320			out = append(out, volume)
1321		}
1322	}
1323	return out
1324}
1325
1326func (s routeResourceScope) matchesVolume(volume dto.Volume) bool {
1327	for _, containerName := range volume.Containers {
1328		if _, ok := s.containerNames[containerName]; ok {
1329			return true
1330		}
1331	}
1332	for _, prefix := range s.volumePrefixes {
1333		if strings.HasPrefix(volume.Name, prefix) {
1334			return true
1335		}
1336	}
1337	return false
1338}
1339
1340func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment {
1341	var out []domain.CleanupAttachment
1342	for _, attachment := range attachments {
1343		if strings.EqualFold(attachment.Owner, domainName) {
1344			out = append(out, attachment)
1345		}
1346	}
1347	return out
1348}
1349
1350func writeRouteDiagnosisText(out io.Writer, diag *routeDiagnosis) error {
1351	if err := cliWriteLine(out, cliRenderTitle("Route diagnosis: "+diag.Domain)); err != nil {
1352		return err
1353	}
1354	if err := writeRouteDiagnosisSummary(out, diag); err != nil {
1355		return err
1356	}
1357	if err := writeRouteDiagnosisLeftovers(out, diag); err != nil {
1358		return err
1359	}
1360	return writeCleanupMessages(out, diag.Warnings, diag.Hints)
1361}
1362
1363func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error {
1364	if diag.Configured && diag.Route != nil {
1365		if err := cliWriteLine(out, cliRenderMeta("Image:", diag.Route.Image)); err != nil {
1366			return err
1367		}
1368	} else if err := cliWriteLine(out, cliRenderWarning("Route is not configured")); err != nil {
1369		return err
1370	}
1371	if diag.Runtime == nil {
1372		return nil
1373	}
1374	if err := cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID))); err != nil {
1375		return err
1376	}
1377	if len(diag.Runtime.Attachments) == 0 {
1378		return nil
1379	}
1380	if err := cliWriteLine(out, cliRenderMeta("Active attachments:", "")); err != nil {
1381		return err
1382	}
1383	for _, attachment := range diag.Runtime.Attachments {
1384		label := attachment.Name
1385		if label == "" {
1386			label = attachment.ContainerID
1387		}
1388		if attachment.Status != "" {
1389			label = fmt.Sprintf("%s (%s)", label, attachment.Status)
1390		}
1391		if err := cliWriteLine(out, "  "+label); err != nil {
1392			return err
1393		}
1394	}
1395	return nil
1396}
1397
1398func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error {
1399	for _, entity := range diag.OrphanedEntities {
1400		label := entity.Name
1401		if label == "" {
1402			label = entity.ID
1403		}
1404		if label == "" {
1405			label = entity.Kind
1406		}
1407		if entity.Status != "" {
1408			label = fmt.Sprintf("%s (%s)", label, entity.Status)
1409		}
1410		if err := cliWriteLine(out, cliRenderWarning("Orphaned "+strings.ReplaceAll(entity.Kind, "_", " ")+": "+label)); err != nil {
1411			return err
1412		}
1413	}
1414	volumeLabel := "Volume:"
1415	if !diag.Configured {
1416		volumeLabel = "Preserved volume:"
1417	}
1418	for _, volume := range diag.Volumes {
1419		if err := cliWriteLine(out, cliRenderMeta(volumeLabel, volume.Name)); err != nil {
1420			return err
1421		}
1422	}
1423	for _, attachment := range diag.OrphanedAttachments {
1424		if err := cliWriteLine(out, cliRenderWarning("Orphaned attachment: "+attachment.Name)); err != nil {
1425			return err
1426		}
1427	}
1428	return nil
1429}
1430
1431func newRoutesPurgeCmd() *cobra.Command {
1432	var jsonOut bool
1433	var force bool
1434	var includeAttachments bool
1435	var includeVolumes bool
1436	cmd := &cobra.Command{
1437		Use:   "purge <domain>",
1438		Short: "Review or explicitly purge retained route resources",
1439		Args:  cobra.ExactArgs(1),
1440		RunE: func(cmd *cobra.Command, args []string) error {
1441			return runRoutePurgeCommand(cmd.Context(), args[0], cmd.OutOrStdout(), routePurgeOptions{Force: force, Attachments: includeAttachments, Volumes: includeVolumes}, jsonOut)
1442		},
1443	}
1444	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1445	cmd.Flags().BoolVar(&force, "force", false, "Execute purge actions; default is dry-run")
1446	cmd.Flags().BoolVar(&includeAttachments, "attachments", false, "Include orphaned attachment containers")
1447	cmd.Flags().BoolVar(&includeVolumes, "volumes", false, "Include preserved volumes in the purge plan")
1448	return cmd
1449}
1450
1451type routePurgeOptions struct {
1452	Force       bool
1453	Attachments bool
1454	Volumes     bool
1455}
1456
1457func runRoutePurgeCommand(ctx context.Context, domainName string, out io.Writer, opts routePurgeOptions, jsonOut bool) error {
1458	handle, err := resolveControlPlaneForRouteCleanupDomain(ctx, domainName)
1459	if err != nil {
1460		return err
1461	}
1462	defer handle.close()
1463	report, err := runRoutePurge(ctx, handle.plane, domainName, opts)
1464	if err != nil {
1465		return err
1466	}
1467	if jsonOut {
1468		return writeJSON(out, report)
1469	}
1470	return writeRoutePurgeText(out, report, opts.Force)
1471}
1472
1473func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts routePurgeOptions) (*domain.CleanupReport, error) {
1474	diag, err := buildRouteDiagnosis(ctx, cp, domainName)
1475	if err != nil {
1476		return nil, err
1477	}
1478	report := &domain.CleanupReport{Domain: domainName}
1479	if diag.Runtime != nil && diag.Runtime.ContainerID != "" {
1480		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"})
1481	}
1482	report.OrphanedEntities = appendUniqueCleanupEntities(report.OrphanedEntities, diag.OrphanedEntities...)
1483	if opts.Volumes {
1484		for _, volume := range diag.Volumes {
1485			report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})
1486		}
1487	}
1488	if !opts.Attachments || !opts.Force {
1489		report.PreservedAttachments = append(report.PreservedAttachments, diag.OrphanedAttachments...)
1490	}
1491	if opts.Attachments && opts.Force {
1492		cleaner, ok := cp.(interface {
1493			CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error)
1494		})
1495		if !ok {
1496			return nil, fmt.Errorf("attachment purge is unavailable")
1497		}
1498		attachmentReport, err := cleaner.CleanupOrphanedAttachments(ctx, domainName, true)
1499		if err != nil {
1500			return nil, err
1501		}
1502		report.RemovedContainers = append(report.RemovedContainers, attachmentReport.RemovedContainers...)
1503		report.PreservedVolumes = append(report.PreservedVolumes, attachmentReport.PreservedVolumes...)
1504		report.PreservedAttachments = append(report.PreservedAttachments, attachmentReport.PreservedAttachments...)
1505		report.OrphanedEntities = append(report.OrphanedEntities, attachmentReport.OrphanedEntities...)
1506		report.Warnings = append(report.Warnings, attachmentReport.Warnings...)
1507		report.Hints = append(report.Hints, attachmentReport.Hints...)
1508		report.PartialFailures = append(report.PartialFailures, attachmentReport.PartialFailures...)
1509	}
1510	if opts.Volumes {
1511		report.Warnings = append(report.Warnings, "volume purge is not executed yet; preserved volumes are reported for manual review")
1512	}
1513	if !opts.Force {
1514		report.Hints = append(report.Hints, "dry-run only; add --force with explicit category flags to execute supported purge actions")
1515	}
1516	return report, nil
1517}
1518
1519func appendUniqueCleanupEntities(existing []domain.CleanupOrphanedEntity, candidates ...domain.CleanupOrphanedEntity) []domain.CleanupOrphanedEntity {
1520	seen := make(map[string]struct{}, len(existing))
1521	for _, entity := range existing {
1522		seen[cleanupEntityKey(entity)] = struct{}{}
1523	}
1524	for _, entity := range candidates {
1525		key := cleanupEntityKey(entity)
1526		if _, ok := seen[key]; ok {
1527			continue
1528		}
1529		existing = append(existing, entity)
1530		seen[key] = struct{}{}
1531	}
1532	return existing
1533}
1534
1535func cleanupEntityKey(entity domain.CleanupOrphanedEntity) string {
1536	return entity.Kind + "|" + entity.ID + "|" + entity.Name
1537}
1538
1539func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error {
1540	mode := "Purge dry-run"
1541	if force {
1542		mode = "Purge completed"
1543	}
1544	if err := cliWriteLine(out, cliRenderTitle(mode+": "+report.Domain)); err != nil {
1545		return err
1546	}
1547	if err := writePreservedAttachments(out, report.PreservedAttachments); err != nil {
1548		return err
1549	}
1550	if err := writeRemovedContainers(out, report.RemovedContainers); err != nil {
1551		return err
1552	}
1553	for _, entity := range report.OrphanedEntities {
1554		if err := cliWriteLine(out, cliRenderWarning("Purge candidate: "+entity.Kind+" "+entity.ID)); err != nil {
1555			return err
1556		}
1557	}
1558	for _, volume := range report.PreservedVolumes {
1559		if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil {
1560			return err
1561		}
1562	}
1563	return writeCleanupMessages(out, report.Warnings, report.Hints)
1564}
1565
1566func newRoutesAddCmd() *cobra.Command {
1567	var image string
1568
1569	cmd := &cobra.Command{
1570		Use:   "add <domain> <image>",
1571		Short: "Create or update a route",
1572		Long: `Create or update a route mapping a domain to a container image.
1573
1574If the route already exists with the same image, this is a no-op.
1575If it exists with a different image, the image is updated.
1576
1577Examples:
1578  gordon routes add app.mydomain.com myapp:latest
1579  gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,
1580		Args: cobra.RangeArgs(1, 2),
1581		RunE: func(cmd *cobra.Command, args []string) error {
1582			ctx := cmd.Context()
1583			routeDomain := args[0]
1584
1585			// Resolve image from positional argument or flag
1586			hasPositionalImage := len(args) > 1
1587			hasFlagImage := image != ""
1588
1589			// Check for conflicts: both positional and flag provided
1590			if hasPositionalImage && hasFlagImage {
1591				return fmt.Errorf("error: cannot use both positional image argument and --image flag")
1592			}
1593
1594			// Check that image is provided via either method
1595			if !hasPositionalImage && !hasFlagImage {
1596				return fmt.Errorf("error: image is required (use positional argument or --image flag)")
1597			}
1598
1599			if hasPositionalImage {
1600				image = args[1]
1601			}
1602
1603			route := domain.Route{
1604				Domain: routeDomain,
1605				Image:  image,
1606			}
1607
1608			client, isRemote, err := GetRemoteClient()
1609			if err != nil {
1610				return err
1611			}
1612			if isRemote {
1613				if err := client.AddRoute(ctx, route); err != nil {
1614					return fmt.Errorf("failed to add route: %w", err)
1615				}
1616			} else {
1617				local, err := GetLocalServices(configPath)
1618				if err != nil {
1619					return fmt.Errorf("failed to initialize local services: %w", err)
1620				}
1621				if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
1622					return fmt.Errorf("failed to add route: %w", err)
1623				}
1624			}
1625
1626			return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))
1627		},
1628	}
1629
1630	cmd.Flags().StringVarP(&image, "image", "i", "", "Container image")
1631
1632	return cmd
1633}
1634
1635// newRoutesRemoveCmd creates the routes remove command.
1636func newRoutesRemoveCmd() *cobra.Command {
1637	var force bool
1638
1639	cmd := &cobra.Command{
1640		Use:   "remove <domain>",
1641		Short: "Remove a route",
1642		Long: `Remove a route by its domain name.
1643
1644Examples:
1645  gordon routes remove app.mydomain.com
1646  gordon routes remove app.mydomain.com --force`,
1647		Args: cobra.ExactArgs(1),
1648		RunE: func(cmd *cobra.Command, args []string) error {
1649			ctx := cmd.Context()
1650			routeDomain := args[0]
1651
1652			// Confirm unless --force
1653			if !force {
1654				confirmed, err := components.RunConfirm(
1655					fmt.Sprintf("Remove route '%s'?", routeDomain),
1656					components.WithDescription("This will stop and remove the associated container."),
1657				)
1658				if err != nil {
1659					return err
1660				}
1661				if !confirmed {
1662					fmt.Println(styles.Theme.Muted.Render("Cancelled"))
1663					return nil
1664				}
1665			}
1666
1667			handle, err := resolveControlPlaneForRouteCleanupDomain(ctx, routeDomain)
1668			if err != nil {
1669				return err
1670			}
1671			defer handle.close()
1672			var cleanup *dto.CleanupReport
1673			if remover, ok := handle.plane.(interface {
1674				RemoveRouteWithCleanup(context.Context, string) (*dto.RouteDeleteResponse, error)
1675			}); ok {
1676				resp, err := remover.RemoveRouteWithCleanup(ctx, routeDomain)
1677				if err != nil {
1678					return fmt.Errorf("failed to remove route: %w", err)
1679				}
1680				if resp != nil {
1681					cleanup = resp.Cleanup
1682				}
1683			} else if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil {
1684				return fmt.Errorf("failed to remove route: %w", err)
1685			}
1686
1687			return writeRouteRemoveText(cmd.OutOrStdout(), routeDomain, cleanup)
1688		},
1689	}
1690
1691	cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
1692
1693	return cmd
1694}
1695
1696func writeRouteRemoveText(out io.Writer, routeDomain string, cleanup *dto.CleanupReport) error {
1697	if err := cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))); err != nil {
1698		return err
1699	}
1700	if cleanup == nil {
1701		return nil
1702	}
1703	cleanupReport := cleanupReportDTOToDomain(cleanup)
1704	if err := writeRemovedContainers(out, cleanupReport.RemovedContainers); err != nil {
1705		return err
1706	}
1707	if err := writePreservedAttachments(out, cleanupReport.PreservedAttachments); err != nil {
1708		return err
1709	}
1710	return writeCleanupMessages(out, cleanupReport.Warnings, cleanupReport.Hints)
1711}
1712
1713func cleanupReportDTOToDomain(cleanup *dto.CleanupReport) *domain.CleanupReport {
1714	if cleanup == nil {
1715		return nil
1716	}
1717	out := &domain.CleanupReport{
1718		Domain:   cleanup.Domain,
1719		Warnings: append([]string(nil), cleanup.Warnings...),
1720		Hints:    append([]string(nil), cleanup.Hints...),
1721	}
1722	for _, item := range cleanup.RemovedContainers {
1723		out.RemovedContainers = append(out.RemovedContainers, domain.CleanupContainer(item))
1724	}
1725	for _, item := range cleanup.PreservedAttachments {
1726		out.PreservedAttachments = append(out.PreservedAttachments, domain.CleanupAttachment(item))
1727	}
1728	return out
1729}
1730
1731func writeRemovedContainers(out io.Writer, containers []domain.CleanupContainer) error {
1732	if len(containers) == 0 {
1733		return nil
1734	}
1735	if err := cliWriteLine(out, ""); err != nil {
1736		return err
1737	}
1738	if err := cliWriteLine(out, styles.Theme.Bold.Render("Removed containers:")); err != nil {
1739		return err
1740	}
1741	for _, c := range containers {
1742		label := c.Name
1743		if label == "" {
1744			label = c.ID
1745		}
1746		if id := shortContainerID(c.ID); id != "" && id != label {
1747			label = fmt.Sprintf("%s (%s)", label, id)
1748		}
1749		if err := cliWriteLine(out, "  "+label); err != nil {
1750			return err
1751		}
1752	}
1753	return nil
1754}
1755
1756func writePreservedAttachments(out io.Writer, attachments []domain.CleanupAttachment) error {
1757	if len(attachments) == 0 {
1758		return nil
1759	}
1760	if err := cliWriteLine(out, ""); err != nil {
1761		return err
1762	}
1763	if err := cliWriteLine(out, styles.Theme.Bold.Render("Preserved attachments:")); err != nil {
1764		return err
1765	}
1766	for _, a := range attachments {
1767		label := a.Name
1768		if label == "" {
1769			label = a.ContainerID
1770		}
1771		if a.Status != "" {
1772			label = fmt.Sprintf("%s (%s)", label, a.Status)
1773		}
1774		if err := cliWriteLine(out, "  "+label); err != nil {
1775			return err
1776		}
1777	}
1778	return nil
1779}
1780
1781func writeCleanupMessages(out io.Writer, warnings, hints []string) error {
1782	for _, warning := range warnings {
1783		if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil {
1784			return err
1785		}
1786	}
1787	for _, hint := range hints {
1788		if err := cliWriteLine(out, cliRenderMuted("Hint: "+hint)); err != nil {
1789			return err
1790		}
1791	}
1792	return nil
1793}
1794
1795// newStatusCmd creates the status command.
1796func newStatusCmd() *cobra.Command {
1797	return &cobra.Command{
1798		Use:   "status",
1799		Short: "Show Gordon server status",
1800		RunE: func(cmd *cobra.Command, args []string) error {
1801			ctx := context.Background()
1802
1803			handle, err := resolveControlPlane(configPath)
1804			if err != nil {
1805				return err
1806			}
1807			defer handle.close()
1808
1809			status, err := handle.plane.GetStatus(ctx)
1810			if err != nil {
1811				return fmt.Errorf("failed to get status: %w", err)
1812			}
1813
1814			// Display status
1815			fmt.Println(styles.Theme.Title.Render("Gordon Status"))
1816			fmt.Println()
1817
1818			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)
1819			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
1820			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
1821			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
1822			fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
1823			fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
1824
1825			if len(status.ContainerStatus) > 0 {
1826				fmt.Println()
1827				fmt.Println(styles.Theme.Bold.Render("Container Status:"))
1828				for domain, containerStatus := range status.ContainerStatus {
1829					badge := components.ContainerStatusBadge(containerStatus)
1830					fmt.Printf("  %s: %s\n", domain, badge)
1831				}
1832			}
1833
1834			return nil
1835		},
1836	}
1837}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close