v2.30.0-pre4 15d9fe3

routes.go RawHistoryFolder

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

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 8

Tags 106

↑↓ moveenter openesc close