v2.31.0-dev19 35467d7

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

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