v2.31.0-dev18 7b9923f

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	loadRouteDiagnosisVolumes(ctx, cp, domainName, diag)
1129	loadRouteDiagnosisOrphanedAttachments(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		diag.Volumes = volumesForRouteDiagnosis(volumes, domainName)
1170	}
1171}
1172
1173func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1174	lister, ok := cp.(interface {
1175		ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)
1176	})
1177	if !ok {
1178		return
1179	}
1180	if attachments, err := lister.ListOrphanedAttachments(ctx); err == nil {
1181		diag.OrphanedAttachments = attachmentsForRouteDiagnosis(attachments, domainName)
1182	}
1183}
1184
1185func finalizeRouteDiagnosis(diag *routeDiagnosis) {
1186	if !diag.Configured && diag.Runtime != nil {
1187		diag.Warnings = append(diag.Warnings, "route is not configured but runtime state still exists")
1188	}
1189	if len(diag.Volumes) > 0 {
1190		diag.Hints = append(diag.Hints, "persistent volumes are preserved by default")
1191	}
1192	if len(diag.OrphanedAttachments) > 0 {
1193		diag.Hints = append(diag.Hints, "run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes")
1194	}
1195}
1196
1197func volumesForRouteDiagnosis(volumes []dto.Volume, domainName string) []dto.Volume {
1198	needle := strings.ReplaceAll(strings.ToLower(domainName), ".", "-")
1199	var out []dto.Volume
1200	for _, volume := range volumes {
1201		if strings.Contains(strings.ToLower(volume.Name), needle) {
1202			out = append(out, volume)
1203		}
1204	}
1205	return out
1206}
1207
1208func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment {
1209	var out []domain.CleanupAttachment
1210	for _, attachment := range attachments {
1211		if strings.EqualFold(attachment.Owner, domainName) {
1212			out = append(out, attachment)
1213		}
1214	}
1215	return out
1216}
1217
1218func writeRouteDiagnosisText(out io.Writer, diag *routeDiagnosis) error {
1219	if err := cliWriteLine(out, cliRenderTitle("Route diagnosis: "+diag.Domain)); err != nil {
1220		return err
1221	}
1222	if err := writeRouteDiagnosisSummary(out, diag); err != nil {
1223		return err
1224	}
1225	if err := writeRouteDiagnosisLeftovers(out, diag); err != nil {
1226		return err
1227	}
1228	return writeCleanupMessages(out, diag.Warnings, diag.Hints)
1229}
1230
1231func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error {
1232	if diag.Configured && diag.Route != nil {
1233		if err := cliWriteLine(out, cliRenderMeta("Image:", diag.Route.Image)); err != nil {
1234			return err
1235		}
1236	} else if err := cliWriteLine(out, cliRenderWarning("Route is not configured")); err != nil {
1237		return err
1238	}
1239	if diag.Runtime == nil {
1240		return nil
1241	}
1242	return cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID)))
1243}
1244
1245func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error {
1246	for _, volume := range diag.Volumes {
1247		if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil {
1248			return err
1249		}
1250	}
1251	for _, attachment := range diag.OrphanedAttachments {
1252		if err := cliWriteLine(out, cliRenderWarning("Orphaned attachment: "+attachment.Name)); err != nil {
1253			return err
1254		}
1255	}
1256	return nil
1257}
1258
1259func newRoutesPurgeCmd() *cobra.Command {
1260	var jsonOut bool
1261	var force bool
1262	var includeAttachments bool
1263	var includeVolumes bool
1264	cmd := &cobra.Command{
1265		Use:   "purge <domain>",
1266		Short: "Review or explicitly purge retained route resources",
1267		Args:  cobra.ExactArgs(1),
1268		RunE: func(cmd *cobra.Command, args []string) error {
1269			return runRoutePurgeCommand(cmd.Context(), args[0], cmd.OutOrStdout(), routePurgeOptions{Force: force, Attachments: includeAttachments, Volumes: includeVolumes}, jsonOut)
1270		},
1271	}
1272	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1273	cmd.Flags().BoolVar(&force, "force", false, "Execute purge actions; default is dry-run")
1274	cmd.Flags().BoolVar(&includeAttachments, "attachments", false, "Include orphaned attachment containers")
1275	cmd.Flags().BoolVar(&includeVolumes, "volumes", false, "Include preserved volumes in the purge plan")
1276	return cmd
1277}
1278
1279type routePurgeOptions struct {
1280	Force       bool
1281	Attachments bool
1282	Volumes     bool
1283}
1284
1285func runRoutePurgeCommand(ctx context.Context, domainName string, out io.Writer, opts routePurgeOptions, jsonOut bool) error {
1286	handle, err := resolveControlPlaneForRouteDomain(ctx, domainName)
1287	if err != nil {
1288		return err
1289	}
1290	defer handle.close()
1291	report, err := runRoutePurge(ctx, handle.plane, domainName, opts)
1292	if err != nil {
1293		return err
1294	}
1295	if jsonOut {
1296		return writeJSON(out, report)
1297	}
1298	return writeRoutePurgeText(out, report, opts.Force)
1299}
1300
1301func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts routePurgeOptions) (*domain.CleanupReport, error) {
1302	diag, err := buildRouteDiagnosis(ctx, cp, domainName)
1303	if err != nil {
1304		return nil, err
1305	}
1306	report := &domain.CleanupReport{Domain: domainName}
1307	if diag.Runtime != nil && diag.Runtime.ContainerID != "" {
1308		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"})
1309	}
1310	if opts.Volumes {
1311		for _, volume := range diag.Volumes {
1312			report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})
1313		}
1314	}
1315	if !opts.Attachments || !opts.Force {
1316		report.PreservedAttachments = append(report.PreservedAttachments, diag.OrphanedAttachments...)
1317	}
1318	if opts.Attachments && opts.Force {
1319		cleaner, ok := cp.(interface {
1320			CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error)
1321		})
1322		if !ok {
1323			return nil, fmt.Errorf("attachment purge is unavailable")
1324		}
1325		attachmentReport, err := cleaner.CleanupOrphanedAttachments(ctx, domainName, true)
1326		if err != nil {
1327			return nil, err
1328		}
1329		report.RemovedContainers = append(report.RemovedContainers, attachmentReport.RemovedContainers...)
1330		report.PreservedVolumes = append(report.PreservedVolumes, attachmentReport.PreservedVolumes...)
1331		report.PreservedAttachments = append(report.PreservedAttachments, attachmentReport.PreservedAttachments...)
1332		report.OrphanedEntities = append(report.OrphanedEntities, attachmentReport.OrphanedEntities...)
1333		report.Warnings = append(report.Warnings, attachmentReport.Warnings...)
1334		report.Hints = append(report.Hints, attachmentReport.Hints...)
1335		report.PartialFailures = append(report.PartialFailures, attachmentReport.PartialFailures...)
1336	}
1337	if opts.Volumes {
1338		report.Warnings = append(report.Warnings, "volume purge is not executed yet; preserved volumes are reported for manual review")
1339	}
1340	if !opts.Force {
1341		report.Hints = append(report.Hints, "dry-run only; add --force with explicit category flags to execute supported purge actions")
1342	}
1343	return report, nil
1344}
1345
1346func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error {
1347	mode := "Purge dry-run"
1348	if force {
1349		mode = "Purge completed"
1350	}
1351	if err := cliWriteLine(out, cliRenderTitle(mode+": "+report.Domain)); err != nil {
1352		return err
1353	}
1354	if err := writePreservedAttachments(out, report.PreservedAttachments); err != nil {
1355		return err
1356	}
1357	if err := writeRemovedContainers(out, report.RemovedContainers); err != nil {
1358		return err
1359	}
1360	for _, entity := range report.OrphanedEntities {
1361		if err := cliWriteLine(out, cliRenderWarning("Purge candidate: "+entity.Kind+" "+entity.ID)); err != nil {
1362			return err
1363		}
1364	}
1365	for _, volume := range report.PreservedVolumes {
1366		if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil {
1367			return err
1368		}
1369	}
1370	return writeCleanupMessages(out, report.Warnings, report.Hints)
1371}
1372
1373func newRoutesAddCmd() *cobra.Command {
1374	var image string
1375
1376	cmd := &cobra.Command{
1377		Use:   "add <domain> <image>",
1378		Short: "Create or update a route",
1379		Long: `Create or update a route mapping a domain to a container image.
1380
1381If the route already exists with the same image, this is a no-op.
1382If it exists with a different image, the image is updated.
1383
1384Examples:
1385  gordon routes add app.mydomain.com myapp:latest
1386  gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,
1387		Args: cobra.RangeArgs(1, 2),
1388		RunE: func(cmd *cobra.Command, args []string) error {
1389			ctx := context.Background()
1390			routeDomain := args[0]
1391
1392			// Resolve image from positional argument or flag
1393			hasPositionalImage := len(args) > 1
1394			hasFlagImage := image != ""
1395
1396			// Check for conflicts: both positional and flag provided
1397			if hasPositionalImage && hasFlagImage {
1398				return fmt.Errorf("error: cannot use both positional image argument and --image flag")
1399			}
1400
1401			// Check that image is provided via either method
1402			if !hasPositionalImage && !hasFlagImage {
1403				return fmt.Errorf("error: image is required (use positional argument or --image flag)")
1404			}
1405
1406			if hasPositionalImage {
1407				image = args[1]
1408			}
1409
1410			route := domain.Route{
1411				Domain: routeDomain,
1412				Image:  image,
1413			}
1414
1415			client, isRemote := GetRemoteClient()
1416			if isRemote {
1417				if err := client.AddRoute(ctx, route); err != nil {
1418					return fmt.Errorf("failed to add route: %w", err)
1419				}
1420			} else {
1421				local, err := GetLocalServices(configPath)
1422				if err != nil {
1423					return fmt.Errorf("failed to initialize local services: %w", err)
1424				}
1425				if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
1426					return fmt.Errorf("failed to add route: %w", err)
1427				}
1428			}
1429
1430			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))
1431			return nil
1432		},
1433	}
1434
1435	cmd.Flags().StringVarP(&image, "image", "i", "", "Container image")
1436
1437	return cmd
1438}
1439
1440// newRoutesRemoveCmd creates the routes remove command.
1441func newRoutesRemoveCmd() *cobra.Command {
1442	var force bool
1443
1444	cmd := &cobra.Command{
1445		Use:   "remove <domain>",
1446		Short: "Remove a route",
1447		Long: `Remove a route by its domain name.
1448
1449Examples:
1450  gordon routes remove app.mydomain.com
1451  gordon routes remove app.mydomain.com --force`,
1452		Args: cobra.ExactArgs(1),
1453		RunE: func(cmd *cobra.Command, args []string) error {
1454			ctx := cmd.Context()
1455			routeDomain := args[0]
1456
1457			// Confirm unless --force
1458			if !force {
1459				confirmed, err := components.RunConfirm(
1460					fmt.Sprintf("Remove route '%s'?", routeDomain),
1461					components.WithDescription("This will stop and remove the associated container."),
1462				)
1463				if err != nil {
1464					return err
1465				}
1466				if !confirmed {
1467					fmt.Println(styles.Theme.Muted.Render("Cancelled"))
1468					return nil
1469				}
1470			}
1471
1472			handle, err := resolveControlPlaneForRouteDomain(ctx, routeDomain)
1473			if err != nil {
1474				return err
1475			}
1476			defer handle.close()
1477			var cleanup *dto.CleanupReport
1478			if remover, ok := handle.plane.(interface {
1479				RemoveRouteWithCleanup(context.Context, string) (*dto.RouteDeleteResponse, error)
1480			}); ok {
1481				resp, err := remover.RemoveRouteWithCleanup(ctx, routeDomain)
1482				if err != nil {
1483					return fmt.Errorf("failed to remove route: %w", err)
1484				}
1485				if resp != nil {
1486					cleanup = resp.Cleanup
1487				}
1488			} else if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil {
1489				return fmt.Errorf("failed to remove route: %w", err)
1490			}
1491
1492			return writeRouteRemoveText(cmd.OutOrStdout(), routeDomain, cleanup)
1493		},
1494	}
1495
1496	cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
1497
1498	return cmd
1499}
1500
1501func writeRouteRemoveText(out io.Writer, routeDomain string, cleanup *dto.CleanupReport) error {
1502	if err := cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))); err != nil {
1503		return err
1504	}
1505	if cleanup == nil {
1506		return nil
1507	}
1508	cleanupReport := cleanupReportDTOToDomain(cleanup)
1509	if err := writeRemovedContainers(out, cleanupReport.RemovedContainers); err != nil {
1510		return err
1511	}
1512	if err := writePreservedAttachments(out, cleanupReport.PreservedAttachments); err != nil {
1513		return err
1514	}
1515	return writeCleanupMessages(out, cleanupReport.Warnings, cleanupReport.Hints)
1516}
1517
1518func cleanupReportDTOToDomain(cleanup *dto.CleanupReport) *domain.CleanupReport {
1519	if cleanup == nil {
1520		return nil
1521	}
1522	out := &domain.CleanupReport{
1523		Domain:   cleanup.Domain,
1524		Warnings: append([]string(nil), cleanup.Warnings...),
1525		Hints:    append([]string(nil), cleanup.Hints...),
1526	}
1527	for _, item := range cleanup.RemovedContainers {
1528		out.RemovedContainers = append(out.RemovedContainers, domain.CleanupContainer(item))
1529	}
1530	for _, item := range cleanup.PreservedAttachments {
1531		out.PreservedAttachments = append(out.PreservedAttachments, domain.CleanupAttachment(item))
1532	}
1533	return out
1534}
1535
1536func writeRemovedContainers(out io.Writer, containers []domain.CleanupContainer) error {
1537	if len(containers) == 0 {
1538		return nil
1539	}
1540	if err := cliWriteLine(out, ""); err != nil {
1541		return err
1542	}
1543	if err := cliWriteLine(out, styles.Theme.Bold.Render("Removed containers:")); err != nil {
1544		return err
1545	}
1546	for _, c := range containers {
1547		label := c.Name
1548		if label == "" {
1549			label = c.ID
1550		}
1551		if id := shortContainerID(c.ID); id != "" && id != label {
1552			label = fmt.Sprintf("%s (%s)", label, id)
1553		}
1554		if err := cliWriteLine(out, "  "+label); err != nil {
1555			return err
1556		}
1557	}
1558	return nil
1559}
1560
1561func writePreservedAttachments(out io.Writer, attachments []domain.CleanupAttachment) error {
1562	if len(attachments) == 0 {
1563		return nil
1564	}
1565	if err := cliWriteLine(out, ""); err != nil {
1566		return err
1567	}
1568	if err := cliWriteLine(out, styles.Theme.Bold.Render("Preserved attachments:")); err != nil {
1569		return err
1570	}
1571	for _, a := range attachments {
1572		label := a.Name
1573		if label == "" {
1574			label = a.ContainerID
1575		}
1576		if a.Status != "" {
1577			label = fmt.Sprintf("%s (%s)", label, a.Status)
1578		}
1579		if err := cliWriteLine(out, "  "+label); err != nil {
1580			return err
1581		}
1582	}
1583	return nil
1584}
1585
1586func writeCleanupMessages(out io.Writer, warnings, hints []string) error {
1587	for _, warning := range warnings {
1588		if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil {
1589			return err
1590		}
1591	}
1592	for _, hint := range hints {
1593		if err := cliWriteLine(out, cliRenderMuted("Hint: "+hint)); err != nil {
1594			return err
1595		}
1596	}
1597	return nil
1598}
1599
1600// newStatusCmd creates the status command.
1601func newStatusCmd() *cobra.Command {
1602	return &cobra.Command{
1603		Use:   "status",
1604		Short: "Show Gordon server status",
1605		RunE: func(cmd *cobra.Command, args []string) error {
1606			ctx := context.Background()
1607
1608			handle, err := resolveControlPlane(configPath)
1609			if err != nil {
1610				return err
1611			}
1612			defer handle.close()
1613
1614			status, err := handle.plane.GetStatus(ctx)
1615			if err != nil {
1616				return fmt.Errorf("failed to get status: %w", err)
1617			}
1618
1619			// Display status
1620			fmt.Println(styles.Theme.Title.Render("Gordon Status"))
1621			fmt.Println()
1622
1623			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)
1624			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
1625			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
1626			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
1627			fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
1628			fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
1629
1630			if len(status.ContainerStatus) > 0 {
1631				fmt.Println()
1632				fmt.Println(styles.Theme.Bold.Render("Container Status:"))
1633				for domain, containerStatus := range status.ContainerStatus {
1634					badge := components.ContainerStatusBadge(containerStatus)
1635					fmt.Printf("  %s: %s\n", domain, badge)
1636				}
1637			}
1638
1639			return nil
1640		},
1641	}
1642}

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