v2.31.0-dev17 bd898f0

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/in/cli/remote"
17	"github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
18	"github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
19	"github.com/bnema/gordon/internal/domain"
20)
21
22// configPath for local operations. If empty, config is auto-discovered
23// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
24var configPath string
25
26// truncateImage shortens long image references for display.
27// For digests (image@sha256:...), shows first 12 chars of digest.
28// For tags, truncates to maxLen with ellipsis if needed.
29func truncateImage(image string, maxLen int) string {
30	// Handle digest references: image@sha256:abc123...
31	if maxLen <= 0 {
32		return ""
33	}
34
35	if idx := strings.Index(image, "@sha256:"); idx != -1 {
36		name := image[:idx]
37		digest := image[idx+8:] // Skip "@sha256:"
38		if len(digest) > 12 {
39			digest = digest[:12]
40		}
41		short := fmt.Sprintf("%s@sha256:%s", name, digest)
42		if len(short) <= maxLen {
43			return short
44		}
45		// Truncate from the end to maintain valid reference format
46		if maxLen <= 3 {
47			return short[:maxLen]
48		}
49		return short[:maxLen-3] + "..."
50	}
51
52	// Regular tag: truncate if needed
53	if len(image) <= maxLen {
54		return image
55	}
56	if maxLen <= 3 {
57		return image[:maxLen]
58	}
59	return image[:maxLen-3] + "..."
60}
61
62const networkPrefix = "gordon-"
63
64// httpHealthToStatus maps an HTTP health probe result to a components.Status.
65func httpHealthToStatus(health *remote.RouteHealth) components.Status {
66	if health == nil {
67		return components.StatusUnknown
68	}
69	if health.HTTPStatus == 0 {
70		if health.Error != "" {
71			return components.StatusError
72		}
73		return components.StatusUnknown
74	}
75	if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
76		return components.StatusSuccess
77	}
78	return components.StatusError
79}
80
81// stripNetworkPrefix removes the "gordon-" prefix from a network name for display.
82func stripNetworkPrefix(network string) string {
83	return strings.TrimPrefix(network, networkPrefix)
84}
85
86// networkGroup holds routes that share a network.
87type networkGroup struct {
88	name   string
89	routes []remote.RouteInfo
90}
91
92type routeListItem struct {
93	Domain string `json:"domain"`
94	Image  string `json:"image"`
95}
96
97type routeListSection struct {
98	Kind   string          `json:"kind"`
99	Name   string          `json:"name"`
100	URL    string          `json:"url,omitempty"`
101	Error  string          `json:"error,omitempty"`
102	Routes []routeListItem `json:"routes,omitempty"`
103}
104
105type routeStatusAttachment struct {
106	Name   string `json:"name"`
107	Image  string `json:"image"`
108	Status string `json:"status"`
109}
110
111type routeStatusItem struct {
112	Domain          string                  `json:"domain"`
113	Image           string                  `json:"image"`
114	ContainerID     string                  `json:"container_id,omitempty"`
115	ContainerStatus string                  `json:"container_status"`
116	HTTPStatus      int                     `json:"http_status"`
117	HealthError     string                  `json:"health_error,omitempty"`
118	Network         string                  `json:"network"`
119	Attachments     []routeStatusAttachment `json:"attachments,omitempty"`
120}
121
122type routeStatusSection struct {
123	Kind   string            `json:"kind"`
124	Name   string            `json:"name"`
125	URL    string            `json:"url,omitempty"`
126	Error  string            `json:"error,omitempty"`
127	Routes []routeStatusItem `json:"routes,omitempty"`
128}
129
130type routesListDeps struct {
131	explicitRemote func() (*remote.ResolvedRemote, bool, error)
132	loadLocal      func(context.Context, string) (routeListSection, error)
133	listRemotes    func() (map[string]remote.RemoteEntry, string, error)
134	loadRemote     func(context.Context, string, remote.RemoteEntry) (routeListSection, error)
135}
136
137type routesStatusDeps struct {
138	explicitRemote func() (*remote.ResolvedRemote, bool, error)
139	loadLocal      func(context.Context, string) (routeStatusSection, error)
140	listRemotes    func() (map[string]remote.RemoteEntry, string, error)
141	loadRemote     func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error)
142}
143
144// groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
145// and solo routes. Both are sorted alphabetically by domain.
146func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
147	byNetwork := make(map[string][]remote.RouteInfo)
148	var networkOrder []string
149	for _, route := range routes {
150		if _, seen := byNetwork[route.Network]; !seen {
151			networkOrder = append(networkOrder, route.Network)
152		}
153		byNetwork[route.Network] = append(byNetwork[route.Network], route)
154	}
155
156	var groups []networkGroup
157	var solo []remote.RouteInfo
158
159	for _, net := range networkOrder {
160		members := byNetwork[net]
161		if len(members) >= 2 {
162			sort.Slice(members, func(i, j int) bool {
163				return members[i].Domain < members[j].Domain
164			})
165			groups = append(groups, networkGroup{
166				name:   stripNetworkPrefix(net),
167				routes: members,
168			})
169		} else {
170			solo = append(solo, members[0])
171		}
172	}
173
174	sort.Slice(solo, func(i, j int) bool {
175		return solo[i].Domain < solo[j].Domain
176	})
177
178	return groups, solo
179}
180
181// newRoutesCmd creates the routes command group.
182func newRoutesCmd() *cobra.Command {
183	cmd := &cobra.Command{
184		Use:   "routes",
185		Short: "Manage routes",
186		Long: `Manage Gordon routes. Routes map domains to container images.
187
188When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
189these commands operate on the remote server. Otherwise, they require access to
190the local Gordon configuration.`,
191	}
192
193	cmd.AddCommand(newRoutesListCmd())
194	cmd.AddCommand(newRoutesStatusCmd())
195
196	cmd.AddCommand(newRoutesShowCmd())
197	cmd.AddCommand(newRoutesAddCmd())
198	cmd.AddCommand(newRoutesRemoveCmd())
199
200	return cmd
201}
202
203// newRoutesListCmd creates the routes list command.
204func newRoutesListCmd() *cobra.Command {
205	var jsonOut bool
206
207	cmd := &cobra.Command{
208		Use:   "list",
209		Short: "List all routes",
210		RunE: func(cmd *cobra.Command, args []string) error {
211			sections, err := collectRoutesListSections(cmd.Context(), configPath, routesListDeps{})
212			if err != nil {
213				return err
214			}
215
216			if jsonOut {
217				return writeJSON(cmd.OutOrStdout(), sections)
218			}
219
220			return renderRoutesListSections(cmd.OutOrStdout(), sections)
221		},
222	}
223
224	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
225
226	return cmd
227}
228
229func newRoutesStatusCmd() *cobra.Command {
230	var jsonOut bool
231
232	cmd := &cobra.Command{
233		Use:   "status",
234		Short: "Show detailed route status",
235		RunE: func(cmd *cobra.Command, args []string) error {
236			sections, err := collectRoutesStatusSections(cmd.Context(), configPath, routesStatusDeps{})
237			if err != nil {
238				return err
239			}
240
241			if jsonOut {
242				return writeJSON(cmd.OutOrStdout(), sections)
243			}
244
245			return renderRoutesStatusSections(cmd.OutOrStdout(), sections)
246		},
247	}
248
249	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
250
251	return cmd
252}
253
254func collectRoutesListSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
255	if deps.explicitRemote == nil {
256		deps.explicitRemote = resolveRoutesExplicitRemote
257	}
258	if deps.loadLocal == nil {
259		deps.loadLocal = loadRoutesListLocalSection
260	}
261	if deps.listRemotes == nil {
262		deps.listRemotes = remote.ListRemotes
263	}
264	if deps.loadRemote == nil {
265		deps.loadRemote = loadRoutesListRemoteSection
266	}
267
268	if resolved, ok, err := deps.explicitRemote(); err != nil {
269		return nil, err
270	} else if ok {
271		section := loadRoutesListExplicitRemoteSection(ctx, deps, resolved)
272		return []routeListSection{section}, nil
273	}
274	if target, ok := resolveRoutesExplicitTarget(); ok {
275		return []routeListSection{{
276			Kind:  "remote",
277			Name:  target,
278			Error: unresolvedRoutesTargetError(target),
279		}}, nil
280	}
281
282	return collectRoutesListAggregateSections(ctx, cfgPath, deps)
283}
284
285func loadRoutesListExplicitRemoteSection(ctx context.Context, deps routesListDeps, resolved *remote.ResolvedRemote) routeListSection {
286	section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
287	if err != nil && section.Error == "" {
288		section.Error = err.Error()
289	}
290	return normalizeRouteListRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
291}
292
293func collectRoutesListAggregateSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
294
295	type localResult struct {
296		section routeListSection
297		err     error
298	}
299	type remotesResult struct {
300		entries map[string]remote.RemoteEntry
301		err     error
302	}
303
304	localCh := make(chan localResult, 1)
305	remotesCh := make(chan remotesResult, 1)
306
307	go func() {
308		section, err := deps.loadLocal(ctx, cfgPath)
309		localCh <- localResult{section: section, err: err}
310	}()
311	go func() {
312		entries, _, err := deps.listRemotes()
313		remotesCh <- remotesResult{entries: entries, err: err}
314	}()
315
316	localRes := <-localCh
317	remotes := <-remotesCh
318	local := localRes.section
319	if err := localRes.err; err != nil {
320		if local.Error == "" {
321			local.Error = err.Error()
322		}
323	}
324
325	sections := make([]routeListSection, 0, 1)
326	sections = append(sections, normalizeRouteListLocalSection(local))
327
328	if remotes.err != nil {
329		sections = append(sections, routeListSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
330		return sections, nil
331	}
332
333	names := sortedRemoteNames(remotes.entries)
334	remoteSections := make([]routeListSection, len(names))
335	var wg sync.WaitGroup
336	for i, name := range names {
337		i, name := i, name
338		entry := remotes.entries[name]
339		wg.Add(1)
340		go func() {
341			defer wg.Done()
342			section, err := deps.loadRemote(ctx, name, entry)
343			if err != nil && section.Error == "" {
344				section.Error = err.Error()
345			}
346			remoteSections[i] = normalizeRouteListRemoteSection(section, name, entry)
347		}()
348	}
349	wg.Wait()
350
351	sections = append(sections, remoteSections...)
352	return sections, nil
353}
354
355func normalizeRouteListLocalSection(section routeListSection) routeListSection {
356	if section.Kind == "" {
357		section.Kind = "local"
358	}
359	if section.Name == "" {
360		section.Name = "local"
361	}
362	return section
363}
364
365func normalizeRouteListRemoteSection(section routeListSection, name string, entry remote.RemoteEntry) routeListSection {
366	if section.Kind == "" {
367		section.Kind = "remote"
368	}
369	if section.Name == "" {
370		section.Name = name
371	}
372	if section.URL == "" {
373		section.URL = entry.URL
374	}
375	return section
376}
377
378func collectRoutesStatusSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
379	if deps.explicitRemote == nil {
380		deps.explicitRemote = resolveRoutesExplicitRemote
381	}
382	if deps.loadLocal == nil {
383		deps.loadLocal = loadRoutesStatusLocalSection
384	}
385	if deps.listRemotes == nil {
386		deps.listRemotes = remote.ListRemotes
387	}
388	if deps.loadRemote == nil {
389		deps.loadRemote = loadRoutesStatusRemoteSection
390	}
391
392	if resolved, ok, err := deps.explicitRemote(); err != nil {
393		return nil, err
394	} else if ok {
395		section := loadRoutesStatusExplicitRemoteSection(ctx, deps, resolved)
396		return []routeStatusSection{section}, nil
397	}
398	if target, ok := resolveRoutesExplicitTarget(); ok {
399		return []routeStatusSection{{
400			Kind:  "remote",
401			Name:  target,
402			Error: unresolvedRoutesTargetError(target),
403		}}, nil
404	}
405
406	return collectRoutesStatusAggregateSections(ctx, cfgPath, deps)
407}
408
409func loadRoutesStatusExplicitRemoteSection(ctx context.Context, deps routesStatusDeps, resolved *remote.ResolvedRemote) routeStatusSection {
410	section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
411	if err != nil && section.Error == "" {
412		section.Error = err.Error()
413	}
414	return normalizeRouteStatusRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
415}
416
417func collectRoutesStatusAggregateSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
418
419	type localResult struct {
420		section routeStatusSection
421		err     error
422	}
423	type remotesResult struct {
424		entries map[string]remote.RemoteEntry
425		err     error
426	}
427
428	localCh := make(chan localResult, 1)
429	remotesCh := make(chan remotesResult, 1)
430
431	go func() {
432		section, err := deps.loadLocal(ctx, cfgPath)
433		localCh <- localResult{section: section, err: err}
434	}()
435	go func() {
436		entries, _, err := deps.listRemotes()
437		remotesCh <- remotesResult{entries: entries, err: err}
438	}()
439
440	localRes := <-localCh
441	remotes := <-remotesCh
442	local := localRes.section
443	if err := localRes.err; err != nil {
444		if local.Error == "" {
445			local.Error = err.Error()
446		}
447	}
448
449	sections := make([]routeStatusSection, 0, 1)
450	sections = append(sections, normalizeRouteStatusLocalSection(local))
451
452	if remotes.err != nil {
453		sections = append(sections, routeStatusSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
454		return sections, nil
455	}
456
457	names := sortedRemoteNames(remotes.entries)
458	remoteSections := make([]routeStatusSection, len(names))
459	var wg sync.WaitGroup
460	for i, name := range names {
461		i, name := i, name
462		entry := remotes.entries[name]
463		wg.Add(1)
464		go func() {
465			defer wg.Done()
466			section, err := deps.loadRemote(ctx, name, entry)
467			if err != nil && section.Error == "" {
468				section.Error = err.Error()
469			}
470			remoteSections[i] = normalizeRouteStatusRemoteSection(section, name, entry)
471		}()
472	}
473	wg.Wait()
474
475	sections = append(sections, remoteSections...)
476	return sections, nil
477}
478
479func normalizeRouteStatusLocalSection(section routeStatusSection) routeStatusSection {
480	if section.Kind == "" {
481		section.Kind = "local"
482	}
483	if section.Name == "" {
484		section.Name = "local"
485	}
486	return section
487}
488
489func normalizeRouteStatusRemoteSection(section routeStatusSection, name string, entry remote.RemoteEntry) routeStatusSection {
490	if section.Kind == "" {
491		section.Kind = "remote"
492	}
493	if section.Name == "" {
494		section.Name = name
495	}
496	if section.URL == "" {
497		section.URL = entry.URL
498	}
499	return section
500}
501
502func renderRoutesListSections(out io.Writer, sections []routeListSection) error {
503	sections = renderedRouteListSections(sections)
504
505	if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
506		return err
507	}
508	if err := cliWriteLine(out, ""); err != nil {
509		return err
510	}
511
512	for i, section := range sections {
513		if i > 0 {
514			if err := cliWriteLine(out, ""); err != nil {
515				return err
516			}
517		}
518		if err := renderRoutesListSection(out, section); err != nil {
519			return err
520		}
521	}
522
523	return nil
524}
525
526func renderRoutesListSection(out io.Writer, section routeListSection) error {
527	if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
528		return err
529	}
530
531	if len(section.Routes) > 0 {
532		const imageColWidth = 45
533		rows := make([][]string, 0, len(section.Routes))
534		for _, route := range section.Routes {
535			rows = append(rows, []string{route.Domain, truncateImage(route.Image, imageColWidth)})
536		}
537
538		table := components.NewTable(
539			components.WithColumns([]components.TableColumn{
540				{Title: "Domain", Width: 30},
541				{Title: "Image", Width: imageColWidth},
542			}),
543			components.WithRows(rows),
544		)
545
546		if err := cliWriteLine(out, table.View()); err != nil {
547			return err
548		}
549	}
550
551	if section.Error != "" {
552		return cliWriteLine(out, cliRenderWarning(section.Error))
553	}
554
555	if len(section.Routes) == 0 {
556		return cliWriteLine(out, cliRenderMuted("No routes configured"))
557	}
558
559	return nil
560}
561
562func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error {
563	sections = renderedRouteStatusSections(sections)
564
565	if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil {
566		return err
567	}
568	if err := cliWriteLine(out, ""); err != nil {
569		return err
570	}
571
572	for i, section := range sections {
573		if i > 0 {
574			if err := cliWriteLine(out, ""); err != nil {
575				return err
576			}
577		}
578		if err := renderRoutesStatusSection(out, section); err != nil {
579			return err
580		}
581	}
582
583	return nil
584}
585
586func renderedRouteListSections(sections []routeListSection) []routeListSection {
587	if !shouldHideLocalRouteListSection(sections) {
588		return sections
589	}
590	return sections[1:]
591}
592
593func shouldHideLocalRouteListSection(sections []routeListSection) bool {
594	if len(sections) < 2 {
595		return false
596	}
597	local := sections[0]
598	if local.Kind != "local" || local.Error == "" {
599		return false
600	}
601	for _, section := range sections[1:] {
602		if section.Kind == "remote" && section.Error == "" {
603			return true
604		}
605	}
606	return false
607}
608
609func renderedRouteStatusSections(sections []routeStatusSection) []routeStatusSection {
610	if !shouldHideLocalRouteStatusSection(sections) {
611		return sections
612	}
613	return sections[1:]
614}
615
616func shouldHideLocalRouteStatusSection(sections []routeStatusSection) bool {
617	if len(sections) < 2 {
618		return false
619	}
620	local := sections[0]
621	if local.Kind != "local" || local.Error == "" {
622		return false
623	}
624	for _, section := range sections[1:] {
625		if section.Kind == "remote" && section.Error == "" {
626			return true
627		}
628	}
629	return false
630}
631
632func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error {
633	if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
634		return err
635	}
636
637	if len(section.Routes) > 0 {
638		tree := buildRouteStatusTree(section.Routes)
639		if err := cliWriteLine(out, strings.TrimSuffix(tree.Render(), "\n")); err != nil {
640			return err
641		}
642	}
643
644	if section.Error != "" {
645		return cliWriteLine(out, cliRenderWarning(section.Error))
646	}
647
648	if len(section.Routes) == 0 {
649		return cliWriteLine(out, cliRenderMuted("No routes configured"))
650	}
651
652	return nil
653}
654
655func routeSectionHeading(kind, name string) string {
656	switch kind {
657	case "local":
658		return "Local"
659	case "remote":
660		if name != "" {
661			return "Remote: " + name
662		}
663		return "Remote"
664	default:
665		if name != "" {
666			return capitalizeKind(kind) + ": " + name
667		}
668		return capitalizeKind(kind)
669	}
670}
671
672func capitalizeKind(kind string) string {
673	if kind == "" {
674		return ""
675	}
676	if len(kind) == 1 {
677		return strings.ToUpper(kind)
678	}
679	return strings.ToUpper(kind[:1]) + kind[1:]
680}
681
682func unresolvedRoutesTargetError(target string) string {
683	return fmt.Sprintf("could not resolve remote target %q", target)
684}
685
686func resolveRoutesExplicitRemote() (*remote.ResolvedRemote, bool, error) {
687	target, ok := resolveRoutesExplicitTarget()
688	if !ok {
689		return nil, false, nil
690	}
691
692	if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
693		return &remote.ResolvedRemote{
694			URL:         target,
695			Token:       resolveRoutesTokenForTarget("", remote.RemoteEntry{}),
696			InsecureTLS: resolveRoutesInsecureForTarget("", remote.RemoteEntry{}),
697		}, true, nil
698	}
699
700	remotes, err := remote.LoadRemotes("")
701	if err != nil {
702		return nil, false, err
703	}
704
705	if remotes != nil {
706		if entry, found := remotes.Remotes[target]; found {
707			return &remote.ResolvedRemote{
708				Name:        target,
709				URL:         entry.URL,
710				Token:       resolveRoutesTokenForTarget(target, entry),
711				InsecureTLS: resolveRoutesInsecureForTarget(target, entry),
712			}, true, nil
713		}
714	}
715
716	return nil, false, nil
717}
718
719func resolveRoutesExplicitTarget() (string, bool) {
720	if target := strings.TrimSpace(remoteFlag); target != "" {
721		return target, true
722	}
723	if target := strings.TrimSpace(os.Getenv("GORDON_REMOTE")); target != "" {
724		return target, true
725	}
726	return "", false
727}
728
729func resolveRoutesTokenForTarget(name string, entry remote.RemoteEntry) string {
730	if token := strings.TrimSpace(tokenFlag); token != "" {
731		return token
732	}
733	if token := strings.TrimSpace(os.Getenv("GORDON_TOKEN")); token != "" {
734		return token
735	}
736	if name != "" {
737		return remote.ResolveTokenForRemote(name, entry)
738	}
739	return ""
740}
741
742func resolveRoutesInsecureForTarget(name string, entry remote.RemoteEntry) bool {
743	if insecureTLSFlag {
744		return true
745	}
746	if env := strings.TrimSpace(os.Getenv("GORDON_INSECURE")); env != "" {
747		if value, err := strconv.ParseBool(env); err == nil {
748			return value
749		}
750	}
751	if name != "" {
752		return entry.InsecureTLS
753	}
754	return false
755}
756
757func newRoutesTargetClient(name string, entry remote.RemoteEntry) *remote.Client {
758	return remote.NewClient(entry.URL, remoteClientOptions(resolveRoutesTokenForTarget(name, entry), resolveRoutesInsecureForTarget(name, entry))...)
759}
760
761func loadRoutesListLocalSection(ctx context.Context, cfgPath string) (routeListSection, error) {
762	local, err := GetLocalServices(cfgPath)
763	if err != nil {
764		return routeListSection{Kind: "local", Name: "local", Error: err.Error()}, nil
765	}
766
767	routes := local.GetConfigService().GetRoutes(ctx)
768	section := routeListSection{Kind: "local", Name: "local", Routes: make([]routeListItem, 0, len(routes))}
769	for _, route := range routes {
770		section.Routes = append(section.Routes, routeListItem{Domain: route.Domain, Image: route.Image})
771	}
772
773	return section, nil
774}
775
776func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
777	section := routeListSection{Kind: "remote", Name: name, URL: entry.URL}
778	client := newRoutesTargetClient(name, entry)
779	routes, err := client.ListRoutesWithDetails(ctx)
780	if err != nil {
781		section.Error = err.Error()
782		return section, nil
783	}
784
785	section.Routes = routeListItemsFromInfos(routes)
786	return section, nil
787}
788
789func loadRoutesStatusLocalSection(ctx context.Context, cfgPath string) (routeStatusSection, error) {
790	section := routeStatusSection{Kind: "local", Name: "local"}
791	handle, err := resolveLocalControlPlane(cfgPath)
792	if err != nil {
793		section.Error = err.Error()
794		return section, nil
795	}
796	defer handle.close()
797
798	routes, err := handle.plane.ListRoutesWithDetails(ctx)
799	if err != nil {
800		section.Error = err.Error()
801		return section, nil
802	}
803
804	health, err := handle.plane.GetHealth(ctx)
805	if err != nil {
806		section.Error = err.Error()
807		health = nil
808	}
809
810	section.Routes = routeStatusItemsFromInfos(routes, health)
811	return section, nil
812}
813
814func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeStatusSection, error) {
815	section := routeStatusSection{Kind: "remote", Name: name, URL: entry.URL}
816	client := newRoutesTargetClient(name, entry)
817	cp := NewRemoteControlPlane(client)
818
819	routes, err := cp.ListRoutesWithDetails(ctx)
820	if err != nil {
821		section.Error = err.Error()
822		return section, nil
823	}
824
825	health, err := cp.GetHealth(ctx)
826	if err != nil {
827		section.Error = err.Error()
828		health = nil
829	}
830
831	section.Routes = routeStatusItemsFromInfos(routes, health)
832	return section, nil
833}
834
835func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem {
836	items := make([]routeStatusItem, 0, len(routes))
837	for _, route := range routes {
838		item := routeStatusItem{
839			Domain:          route.Domain,
840			Image:           route.Image,
841			ContainerID:     route.ContainerID,
842			ContainerStatus: route.ContainerStatus,
843			Network:         route.Network,
844		}
845		if item.ContainerStatus == "" {
846			if routeHealth := health[route.Domain]; routeHealth != nil {
847				item.ContainerStatus = routeHealth.ContainerStatus
848				item.HTTPStatus = routeHealth.HTTPStatus
849				item.HealthError = routeHealth.Error
850			}
851		}
852		if item.ContainerStatus == "" {
853			item.ContainerStatus = "unknown"
854		}
855		if item.HTTPStatus == 0 {
856			if routeHealth := health[route.Domain]; routeHealth != nil {
857				item.HTTPStatus = routeHealth.HTTPStatus
858				item.HealthError = routeHealth.Error
859			}
860		}
861		if item.HealthError == "" {
862			if routeHealth := health[route.Domain]; routeHealth != nil {
863				item.HealthError = routeHealth.Error
864			}
865		}
866		item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments))
867		for _, attachment := range route.Attachments {
868			status := attachment.Status
869			if status == "" {
870				status = "unknown"
871			}
872			item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status})
873		}
874		items = append(items, item)
875	}
876	return items
877}
878
879func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem {
880	items := make([]routeListItem, 0, len(routes))
881	for _, route := range routes {
882		items = append(items, routeListItem{Domain: route.Domain, Image: route.Image})
883	}
884	return items
885}
886
887func buildRouteStatusTree(routes []routeStatusItem) *components.Tree {
888	sortedRoutes := make([]routeStatusItem, len(routes))
889	copy(sortedRoutes, routes)
890	sort.SliceStable(sortedRoutes, func(i, j int) bool {
891		if sortedRoutes[i].Network != sortedRoutes[j].Network {
892			return sortedRoutes[i].Network < sortedRoutes[j].Network
893		}
894		if sortedRoutes[i].Domain != sortedRoutes[j].Domain {
895			return sortedRoutes[i].Domain < sortedRoutes[j].Domain
896		}
897		if sortedRoutes[i].Image != sortedRoutes[j].Image {
898			return sortedRoutes[i].Image < sortedRoutes[j].Image
899		}
900		if sortedRoutes[i].ContainerID != sortedRoutes[j].ContainerID {
901			return sortedRoutes[i].ContainerID < sortedRoutes[j].ContainerID
902		}
903		if sortedRoutes[i].ContainerStatus != sortedRoutes[j].ContainerStatus {
904			return sortedRoutes[i].ContainerStatus < sortedRoutes[j].ContainerStatus
905		}
906		return sortedRoutes[i].HTTPStatus < sortedRoutes[j].HTTPStatus
907	})
908
909	infos := make([]remote.RouteInfo, 0, len(routes))
910	itemsByDomain := make(map[string]routeStatusItem, len(routes))
911	for _, route := range sortedRoutes {
912		infos = append(infos, remote.RouteInfo{
913			Domain:          route.Domain,
914			Image:           route.Image,
915			ContainerID:     route.ContainerID,
916			ContainerStatus: route.ContainerStatus,
917			Network:         route.Network,
918			Attachments:     routeStatusAttachmentsToRemote(route.Attachments),
919		})
920		itemsByDomain[route.Domain] = route
921	}
922
923	groups, solo := groupRoutesByNetwork(infos)
924	tree := components.NewTree()
925
926	for _, group := range groups {
927		g := tree.AddGroup(group.name)
928		for _, route := range group.routes {
929			item := itemsByDomain[route.Domain]
930			node := g.AddNode(routeStatusTitle(item), item.Image)
931			addRouteStatusAttachmentChildren(node, item)
932		}
933	}
934
935	for _, route := range solo {
936		item := itemsByDomain[route.Domain]
937		node := tree.AddNode(routeStatusTitle(item), item.Image)
938		addRouteStatusAttachmentChildren(node, item)
939	}
940
941	return tree
942}
943
944func routeStatusTitle(route routeStatusItem) string {
945	containerStatus := route.ContainerStatus
946	if containerStatus == "" {
947		containerStatus = "unknown"
948	}
949
950	httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(&remote.RouteHealth{HTTPStatus: route.HTTPStatus, Error: route.HealthError}))
951	containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
952
953	return httpIcon + " " + containerIcon + " " + route.Domain
954}
955
956func addRouteStatusAttachmentChildren(node *components.Node, route routeStatusItem) {
957	for _, att := range route.Attachments {
958		status := att.Status
959		if status == "" {
960			status = "unknown"
961		}
962		attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(status))
963		node.AddChild(attIcon+" "+att.Name, att.Image)
964	}
965}
966
967func routeStatusAttachmentsToRemote(attachments []routeStatusAttachment) []remote.Attachment {
968	result := make([]remote.Attachment, 0, len(attachments))
969	for _, att := range attachments {
970		result = append(result, remote.Attachment{Name: att.Name, Image: att.Image, Status: att.Status})
971	}
972	return result
973}
974
975func newRoutesShowCmd() *cobra.Command {
976	var jsonOut bool
977
978	cmd := &cobra.Command{
979		Use:   "show <domain>",
980		Short: "Show details for a single route",
981		Long: `Display detailed information about a specific route including its image,
982container status, and health.
983
984Examples:
985  gordon routes show app.mydomain.com
986  gordon routes show app.mydomain.com --json
987  gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
988		Args: cobra.ExactArgs(1),
989		RunE: func(cmd *cobra.Command, args []string) error {
990			ctx := cmd.Context()
991			handle, err := resolveControlPlaneForRouteDomain(ctx, args[0])
992			if err != nil {
993				return err
994			}
995			defer handle.close()
996			return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
997		},
998	}
999
1000	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1001
1002	return cmd
1003}
1004
1005func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
1006	route, err := cp.GetRoute(ctx, routeDomain)
1007	if err != nil {
1008		if errors.Is(err, domain.ErrRouteNotFound) {
1009			return fmt.Errorf("route %q not found", routeDomain)
1010		}
1011		return fmt.Errorf("failed to get route: %w", err)
1012	}
1013
1014	health, err := cp.GetHealth(ctx)
1015	healthErr := ""
1016	if err != nil {
1017		healthErr = err.Error()
1018		health = nil
1019	}
1020	var routeHealth *remote.RouteHealth
1021	if health != nil {
1022		routeHealth = health[routeDomain]
1023	}
1024
1025	containerStatus := "unknown"
1026	httpStatus := 0
1027	if routeHealth != nil {
1028		if routeHealth.ContainerStatus != "" {
1029			containerStatus = routeHealth.ContainerStatus
1030		}
1031		httpStatus = routeHealth.HTTPStatus
1032		if healthErr == "" && routeHealth.Error != "" {
1033			healthErr = routeHealth.Error
1034		}
1035	}
1036
1037	if jsonOut {
1038		payload := map[string]any{
1039			"domain":           route.Domain,
1040			"image":            route.Image,
1041			"container_status": containerStatus,
1042			"http_status":      httpStatus,
1043		}
1044		if healthErr != "" {
1045			payload["health_error"] = healthErr
1046		}
1047		return writeJSON(out, payload)
1048	}
1049
1050	return writeRouteShowText(out, route.Domain, route.Image, containerStatus, httpStatus, healthErr)
1051}
1052
1053func writeRouteShowText(out io.Writer, domainName, image, containerStatus string, httpStatus int, healthErr string) error {
1054	if err := cliWriteLine(out, cliRenderTitle("Route: "+domainName)); err != nil {
1055		return err
1056	}
1057	if err := cliWriteLine(out, ""); err != nil {
1058		return err
1059	}
1060	if err := cliWriteLine(out, cliRenderMeta("Domain:", domainName)); err != nil {
1061		return err
1062	}
1063	if err := cliWriteLine(out, cliRenderMeta("Image:", image)); err != nil {
1064		return err
1065	}
1066	if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
1067		return err
1068	}
1069	if httpStatus > 0 {
1070		if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
1071			return err
1072		}
1073	}
1074	if healthErr != "" {
1075		if err := cliWriteLine(out, cliRenderWarning(healthErr)); err != nil {
1076			return err
1077		}
1078	}
1079
1080	return nil
1081}
1082
1083// newRoutesAddCmd creates the routes add command.
1084func newRoutesAddCmd() *cobra.Command {
1085	var image string
1086
1087	cmd := &cobra.Command{
1088		Use:   "add <domain> <image>",
1089		Short: "Create or update a route",
1090		Long: `Create or update a route mapping a domain to a container image.
1091
1092If the route already exists with the same image, this is a no-op.
1093If it exists with a different image, the image is updated.
1094
1095Examples:
1096  gordon routes add app.mydomain.com myapp:latest
1097  gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,
1098		Args: cobra.RangeArgs(1, 2),
1099		RunE: func(cmd *cobra.Command, args []string) error {
1100			ctx := context.Background()
1101			routeDomain := args[0]
1102
1103			// Resolve image from positional argument or flag
1104			hasPositionalImage := len(args) > 1
1105			hasFlagImage := image != ""
1106
1107			// Check for conflicts: both positional and flag provided
1108			if hasPositionalImage && hasFlagImage {
1109				return fmt.Errorf("error: cannot use both positional image argument and --image flag")
1110			}
1111
1112			// Check that image is provided via either method
1113			if !hasPositionalImage && !hasFlagImage {
1114				return fmt.Errorf("error: image is required (use positional argument or --image flag)")
1115			}
1116
1117			if hasPositionalImage {
1118				image = args[1]
1119			}
1120
1121			route := domain.Route{
1122				Domain: routeDomain,
1123				Image:  image,
1124			}
1125
1126			client, isRemote := GetRemoteClient()
1127			if isRemote {
1128				if err := client.AddRoute(ctx, route); err != nil {
1129					return fmt.Errorf("failed to add route: %w", err)
1130				}
1131			} else {
1132				local, err := GetLocalServices(configPath)
1133				if err != nil {
1134					return fmt.Errorf("failed to initialize local services: %w", err)
1135				}
1136				if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
1137					return fmt.Errorf("failed to add route: %w", err)
1138				}
1139			}
1140
1141			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))
1142			return nil
1143		},
1144	}
1145
1146	cmd.Flags().StringVarP(&image, "image", "i", "", "Container image")
1147
1148	return cmd
1149}
1150
1151// newRoutesRemoveCmd creates the routes remove command.
1152func newRoutesRemoveCmd() *cobra.Command {
1153	var force bool
1154
1155	cmd := &cobra.Command{
1156		Use:   "remove <domain>",
1157		Short: "Remove a route",
1158		Long: `Remove a route by its domain name.
1159
1160Examples:
1161  gordon routes remove app.mydomain.com
1162  gordon routes remove app.mydomain.com --force`,
1163		Args: cobra.ExactArgs(1),
1164		RunE: func(cmd *cobra.Command, args []string) error {
1165			ctx := cmd.Context()
1166			routeDomain := args[0]
1167
1168			// Confirm unless --force
1169			if !force {
1170				confirmed, err := components.RunConfirm(
1171					fmt.Sprintf("Remove route '%s'?", routeDomain),
1172					components.WithDescription("This will stop and remove the associated container."),
1173				)
1174				if err != nil {
1175					return err
1176				}
1177				if !confirmed {
1178					fmt.Println(styles.Theme.Muted.Render("Cancelled"))
1179					return nil
1180				}
1181			}
1182
1183			handle, err := resolveControlPlaneForRouteDomain(ctx, routeDomain)
1184			if err != nil {
1185				return err
1186			}
1187			defer handle.close()
1188			if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil {
1189				return fmt.Errorf("failed to remove route: %w", err)
1190			}
1191
1192			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
1193			return nil
1194		},
1195	}
1196
1197	cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
1198
1199	return cmd
1200}
1201
1202// newStatusCmd creates the status command.
1203func newStatusCmd() *cobra.Command {
1204	return &cobra.Command{
1205		Use:   "status",
1206		Short: "Show Gordon server status",
1207		RunE: func(cmd *cobra.Command, args []string) error {
1208			ctx := context.Background()
1209
1210			handle, err := resolveControlPlane(configPath)
1211			if err != nil {
1212				return err
1213			}
1214			defer handle.close()
1215
1216			status, err := handle.plane.GetStatus(ctx)
1217			if err != nil {
1218				return fmt.Errorf("failed to get status: %w", err)
1219			}
1220
1221			// Display status
1222			fmt.Println(styles.Theme.Title.Render("Gordon Status"))
1223			fmt.Println()
1224
1225			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)
1226			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
1227			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
1228			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
1229			fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
1230			fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
1231
1232			if len(status.ContainerStatus) > 0 {
1233				fmt.Println()
1234				fmt.Println(styles.Theme.Bold.Render("Container Status:"))
1235				for domain, containerStatus := range status.ContainerStatus {
1236					badge := components.ContainerStatusBadge(containerStatus)
1237					fmt.Printf("  %s: %s\n", domain, badge)
1238				}
1239			}
1240
1241			return nil
1242		},
1243	}
1244}

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