v2.31.0-dev5 4162c86

routes.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"io"
8	"sort"
9	"strings"
10
11	"github.com/spf13/cobra"
12
13	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
14	"github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15	"github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
16	"github.com/bnema/gordon/internal/domain"
17)
18
19// configPath for local operations. If empty, config is auto-discovered
20// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
21var configPath string
22
23// truncateImage shortens long image references for display.
24// For digests (image@sha256:...), shows first 12 chars of digest.
25// For tags, truncates to maxLen with ellipsis if needed.
26func truncateImage(image string, maxLen int) string {
27	// Handle digest references: image@sha256:abc123...
28	if maxLen <= 0 {
29		return ""
30	}
31
32	if idx := strings.Index(image, "@sha256:"); idx != -1 {
33		name := image[:idx]
34		digest := image[idx+8:] // Skip "@sha256:"
35		if len(digest) > 12 {
36			digest = digest[:12]
37		}
38		short := fmt.Sprintf("%s@sha256:%s", name, digest)
39		if len(short) <= maxLen {
40			return short
41		}
42		// Truncate from the end to maintain valid reference format
43		if maxLen <= 3 {
44			return short[:maxLen]
45		}
46		return short[:maxLen-3] + "..."
47	}
48
49	// Regular tag: truncate if needed
50	if len(image) <= maxLen {
51		return image
52	}
53	if maxLen <= 3 {
54		return image[:maxLen]
55	}
56	return image[:maxLen-3] + "..."
57}
58
59const networkPrefix = "gordon-"
60
61// httpHealthToStatus maps an HTTP health probe result to a components.Status.
62func httpHealthToStatus(health *remote.RouteHealth) components.Status {
63	if health == nil {
64		return components.StatusUnknown
65	}
66	if health.HTTPStatus == 0 {
67		if health.Error != "" {
68			return components.StatusError
69		}
70		return components.StatusUnknown
71	}
72	if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
73		return components.StatusSuccess
74	}
75	return components.StatusError
76}
77
78// stripNetworkPrefix removes the "gordon-" prefix from a network name for display.
79func stripNetworkPrefix(network string) string {
80	return strings.TrimPrefix(network, networkPrefix)
81}
82
83// networkGroup holds routes that share a network.
84type networkGroup struct {
85	name   string
86	routes []remote.RouteInfo
87}
88
89// groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
90// and solo routes. Both are sorted alphabetically by domain.
91func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
92	byNetwork := make(map[string][]remote.RouteInfo)
93	var networkOrder []string
94	for _, route := range routes {
95		if _, seen := byNetwork[route.Network]; !seen {
96			networkOrder = append(networkOrder, route.Network)
97		}
98		byNetwork[route.Network] = append(byNetwork[route.Network], route)
99	}
100
101	var groups []networkGroup
102	var solo []remote.RouteInfo
103
104	for _, net := range networkOrder {
105		members := byNetwork[net]
106		if len(members) >= 2 {
107			sort.Slice(members, func(i, j int) bool {
108				return members[i].Domain < members[j].Domain
109			})
110			groups = append(groups, networkGroup{
111				name:   stripNetworkPrefix(net),
112				routes: members,
113			})
114		} else {
115			solo = append(solo, members[0])
116		}
117	}
118
119	sort.Slice(solo, func(i, j int) bool {
120		return solo[i].Domain < solo[j].Domain
121	})
122
123	return groups, solo
124}
125
126// newRoutesCmd creates the routes command group.
127func newRoutesCmd() *cobra.Command {
128	cmd := &cobra.Command{
129		Use:   "routes",
130		Short: "Manage routes",
131		Long: `Manage Gordon routes. Routes map domains to container images.
132
133When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
134these commands operate on the remote server. Otherwise, they require access to
135the local Gordon configuration.`,
136	}
137
138	cmd.AddCommand(newRoutesListCmd())
139
140	// "status" is an alias for "list"
141	statusCmd := newRoutesListCmd()
142	statusCmd.Use = "status"
143	statusCmd.Short = "Show status of all routes (alias for list)"
144	cmd.AddCommand(statusCmd)
145
146	cmd.AddCommand(newRoutesShowCmd())
147	cmd.AddCommand(newRoutesAddCmd())
148	cmd.AddCommand(newRoutesRemoveCmd())
149
150	return cmd
151}
152
153// newRoutesListCmd creates the routes list command.
154func newRoutesListCmd() *cobra.Command {
155	var jsonOut bool
156
157	cmd := &cobra.Command{
158		Use:   "list",
159		Short: "List all routes",
160		RunE: func(cmd *cobra.Command, args []string) error {
161			ctx := cmd.Context()
162
163			client, isRemote := GetRemoteClient()
164			if isRemote {
165				return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout())
166			}
167			return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout())
168		},
169	}
170
171	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
172
173	return cmd
174}
175
176func newRoutesShowCmd() *cobra.Command {
177	var jsonOut bool
178
179	cmd := &cobra.Command{
180		Use:   "show <domain>",
181		Short: "Show details for a single route",
182		Long: `Display detailed information about a specific route including its image,
183container status, and health.
184
185Examples:
186  gordon routes show app.mydomain.com
187  gordon routes show app.mydomain.com --json
188  gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
189		Args: cobra.ExactArgs(1),
190		RunE: func(cmd *cobra.Command, args []string) error {
191			ctx := cmd.Context()
192			handle, err := resolveControlPlane(configPath)
193			if err != nil {
194				return err
195			}
196			defer handle.close()
197			return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
198		},
199	}
200
201	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
202
203	return cmd
204}
205
206func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
207	route, err := cp.GetRoute(ctx, routeDomain)
208	if err != nil {
209		if errors.Is(err, domain.ErrRouteNotFound) {
210			return fmt.Errorf("route %q not found", routeDomain)
211		}
212		return fmt.Errorf("failed to get route: %w", err)
213	}
214
215	health, _ := cp.GetHealth(ctx)
216	var routeHealth *remote.RouteHealth
217	if health != nil {
218		routeHealth = health[routeDomain]
219	}
220
221	containerStatus := "unknown"
222	httpStatus := 0
223	if routeHealth != nil {
224		containerStatus = routeHealth.ContainerStatus
225		httpStatus = routeHealth.HTTPStatus
226	}
227
228	if jsonOut {
229		payload := map[string]any{
230			"domain":           route.Domain,
231			"image":            route.Image,
232			"container_status": containerStatus,
233			"http_status":      httpStatus,
234		}
235		return writeJSON(out, payload)
236	}
237
238	if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil {
239		return err
240	}
241	if err := cliWriteLine(out, ""); err != nil {
242		return err
243	}
244	if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil {
245		return err
246	}
247	if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil {
248		return err
249	}
250	if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
251		return err
252	}
253	if httpStatus > 0 {
254		if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
255			return err
256		}
257	}
258
259	return nil
260}
261
262// runRoutesListRemote lists routes from a remote Gordon instance.
263func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error {
264	routes, err := client.ListRoutesWithDetails(ctx)
265	if err != nil {
266		return fmt.Errorf("failed to list routes: %w", err)
267	}
268
269	health, _ := client.GetHealth(ctx)
270	if health == nil {
271		health = make(map[string]*remote.RouteHealth)
272	}
273
274	if jsonOut {
275		return routesListJSON(out, routes, health)
276	}
277
278	if len(routes) == 0 {
279		_, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))
280		return nil
281	}
282
283	groups, solo := groupRoutesByNetwork(routes)
284
285	type sortableItem struct {
286		sortKey string
287		group   *networkGroup
288		route   *remote.RouteInfo
289	}
290
291	var items []sortableItem
292	for i := range groups {
293		items = append(items, sortableItem{
294			sortKey: groups[i].routes[0].Domain,
295			group:   &groups[i],
296		})
297	}
298	for i := range solo {
299		items = append(items, sortableItem{
300			sortKey: solo[i].Domain,
301			route:   &solo[i],
302		})
303	}
304	sort.Slice(items, func(i, j int) bool {
305		return items[i].sortKey < items[j].sortKey
306	})
307
308	tree := components.NewTree()
309
310	for _, item := range items {
311		if item.group != nil {
312			g := tree.AddGroup(item.group.name)
313			for _, route := range item.group.routes {
314				title := routeTitle(route, health)
315				node := g.AddNode(title, route.Image)
316				addAttachmentChildren(node, route)
317			}
318		} else {
319			title := routeTitle(*item.route, health)
320			node := tree.AddNode(title, item.route.Image)
321			addAttachmentChildren(node, *item.route)
322		}
323	}
324
325	if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
326		return err
327	}
328	if err := cliWriteLine(out, ""); err != nil {
329		return err
330	}
331	return cliWriteLine(out, tree.Render())
332}
333
334// routeTitle builds the pre-styled title line for a route node.
335func routeTitle(route remote.RouteInfo, health map[string]*remote.RouteHealth) string {
336	containerStatus := route.ContainerStatus
337	if containerStatus == "" {
338		if h := health[route.Domain]; h != nil {
339			containerStatus = h.ContainerStatus
340		} else {
341			containerStatus = "unknown"
342		}
343	}
344
345	httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(health[route.Domain]))
346	containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
347
348	return httpIcon + " " + containerIcon + " " + route.Domain
349}
350
351// addAttachmentChildren adds attachment nodes as children of a route node.
352func addAttachmentChildren(node *components.Node, route remote.RouteInfo) {
353	for _, att := range route.Attachments {
354		attStatus := att.Status
355		if attStatus == "" {
356			attStatus = "unknown"
357		}
358		attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(attStatus))
359		node.AddChild(attIcon+" "+att.Name, att.Image)
360	}
361}
362
363func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {
364	payload := make([]map[string]any, 0, len(routes))
365	for _, route := range routes {
366		routeHealth := health[route.Domain]
367		containerStatus := route.ContainerStatus
368		if containerStatus == "" {
369			if routeHealth != nil {
370				containerStatus = routeHealth.ContainerStatus
371			} else {
372				containerStatus = "unknown"
373			}
374		}
375
376		httpStatus := 0
377		if routeHealth != nil {
378			httpStatus = routeHealth.HTTPStatus
379		}
380
381		attachments := make([]map[string]string, 0, len(route.Attachments))
382		for _, attachment := range route.Attachments {
383			attachments = append(attachments, map[string]string{
384				"name":   attachment.Name,
385				"image":  attachment.Image,
386				"status": attachment.Status,
387			})
388		}
389
390		payload = append(payload, map[string]any{
391			"domain":           route.Domain,
392			"image":            route.Image,
393			"container_status": containerStatus,
394			"network":          route.Network,
395			"http_status":      httpStatus,
396			"attachments":      attachments,
397		})
398	}
399
400	return writeJSON(out, payload)
401}
402
403// runRoutesListLocal lists routes from local configuration.
404func runRoutesListLocal(ctx context.Context, cfgPath string, jsonOut bool, out io.Writer) error {
405	local, err := GetLocalServices(cfgPath)
406	if err != nil {
407		return fmt.Errorf("failed to initialize local services: %w", err)
408	}
409
410	routes := local.GetConfigService().GetRoutes(ctx)
411
412	if jsonOut {
413		payload := make([]map[string]string, 0, len(routes))
414		for _, route := range routes {
415			payload = append(payload, map[string]string{
416				"domain": route.Domain,
417				"image":  route.Image,
418			})
419		}
420		return writeJSON(out, payload)
421	}
422
423	if len(routes) == 0 {
424		_, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))
425		return nil
426	}
427
428	// Build table rows (no health info in local mode)
429	const imageColWidth = 45
430	rows := make([][]string, len(routes))
431	for i, route := range routes {
432		displayImage := truncateImage(route.Image, imageColWidth)
433		rows[i] = []string{route.Domain, displayImage}
434	}
435
436	// Render table
437	table := components.NewTable(
438		components.WithColumns([]components.TableColumn{
439			{Title: "Domain", Width: 30},
440			{Title: "Image", Width: imageColWidth},
441		}),
442		components.WithRows(rows),
443	)
444
445	_, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes (local)"))
446	_, _ = fmt.Fprintln(out)
447	_, _ = fmt.Fprintln(out, table.View())
448
449	return nil
450}
451
452// newRoutesAddCmd creates the routes add command.
453func newRoutesAddCmd() *cobra.Command {
454	var image string
455
456	cmd := &cobra.Command{
457		Use:   "add <domain> <image>",
458		Short: "Create or update a route",
459		Long: `Create or update a route mapping a domain to a container image.
460
461If the route already exists with the same image, this is a no-op.
462If it exists with a different image, the image is updated.
463
464Examples:
465  gordon routes add app.mydomain.com myapp:latest
466  gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,
467		Args: cobra.RangeArgs(1, 2),
468		RunE: func(cmd *cobra.Command, args []string) error {
469			ctx := context.Background()
470			routeDomain := args[0]
471
472			// Resolve image from positional argument or flag
473			hasPositionalImage := len(args) > 1
474			hasFlagImage := image != ""
475
476			// Check for conflicts: both positional and flag provided
477			if hasPositionalImage && hasFlagImage {
478				return fmt.Errorf("error: cannot use both positional image argument and --image flag")
479			}
480
481			// Check that image is provided via either method
482			if !hasPositionalImage && !hasFlagImage {
483				return fmt.Errorf("error: image is required (use positional argument or --image flag)")
484			}
485
486			if hasPositionalImage {
487				image = args[1]
488			}
489
490			route := domain.Route{
491				Domain: routeDomain,
492				Image:  image,
493			}
494
495			client, isRemote := GetRemoteClient()
496			if isRemote {
497				if err := client.AddRoute(ctx, route); err != nil {
498					return fmt.Errorf("failed to add route: %w", err)
499				}
500			} else {
501				local, err := GetLocalServices(configPath)
502				if err != nil {
503					return fmt.Errorf("failed to initialize local services: %w", err)
504				}
505				if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
506					return fmt.Errorf("failed to add route: %w", err)
507				}
508			}
509
510			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))
511			return nil
512		},
513	}
514
515	cmd.Flags().StringVarP(&image, "image", "i", "", "Container image")
516
517	return cmd
518}
519
520// newRoutesRemoveCmd creates the routes remove command.
521func newRoutesRemoveCmd() *cobra.Command {
522	var force bool
523
524	cmd := &cobra.Command{
525		Use:   "remove <domain>",
526		Short: "Remove a route",
527		Long: `Remove a route by its domain name.
528
529Examples:
530  gordon routes remove app.mydomain.com
531  gordon routes remove app.mydomain.com --force`,
532		Args: cobra.ExactArgs(1),
533		RunE: func(cmd *cobra.Command, args []string) error {
534			ctx := context.Background()
535			routeDomain := args[0]
536
537			// Confirm unless --force
538			if !force {
539				confirmed, err := components.RunConfirm(
540					fmt.Sprintf("Remove route '%s'?", routeDomain),
541					components.WithDescription("This will stop and remove the associated container."),
542				)
543				if err != nil {
544					return err
545				}
546				if !confirmed {
547					fmt.Println(styles.Theme.Muted.Render("Cancelled"))
548					return nil
549				}
550			}
551
552			client, isRemote := GetRemoteClient()
553			if isRemote {
554				if err := client.RemoveRoute(ctx, routeDomain); err != nil {
555					return fmt.Errorf("failed to remove route: %w", err)
556				}
557			} else {
558				local, err := GetLocalServices(configPath)
559				if err != nil {
560					return fmt.Errorf("failed to initialize local services: %w", err)
561				}
562				if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil {
563					return fmt.Errorf("failed to remove route: %w", err)
564				}
565			}
566
567			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
568			return nil
569		},
570	}
571
572	cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
573
574	return cmd
575}
576
577// newStatusCmd creates the status command.
578func newStatusCmd() *cobra.Command {
579	return &cobra.Command{
580		Use:   "status",
581		Short: "Show Gordon server status",
582		RunE: func(cmd *cobra.Command, args []string) error {
583			ctx := context.Background()
584
585			handle, err := resolveControlPlane(configPath)
586			if err != nil {
587				return err
588			}
589			defer handle.close()
590
591			status, err := handle.plane.GetStatus(ctx)
592			if err != nil {
593				return fmt.Errorf("failed to get status: %w", err)
594			}
595
596			// Display status
597			fmt.Println(styles.Theme.Title.Render("Gordon Status"))
598			fmt.Println()
599
600			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)
601			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
602			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
603			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
604			fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
605			fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
606
607			if len(status.ContainerStatus) > 0 {
608				fmt.Println()
609				fmt.Println(styles.Theme.Bold.Render("Container Status:"))
610				for domain, containerStatus := range status.ContainerStatus {
611					badge := components.ContainerStatusBadge(containerStatus)
612					fmt.Printf("  %s: %s\n", domain, badge)
613				}
614			}
615
616			return nil
617		},
618	}
619}

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