v2.8.0 6aced72

root.go RawHistoryFolder

1// Package cli implements the CLI adapter for Gordon.
2// This package provides Cobra commands that delegate to the app layer.
3package cli
4
5import (
6	"context"
7	"fmt"
8
9	"github.com/spf13/cobra"
10
11	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
12	"github.com/bnema/gordon/internal/app"
13)
14
15var (
16	// Version information (set at build time)
17	Version   = "dev"
18	Commit    = "unknown"
19	BuildDate = "unknown"
20
21	// Global flags for remote targeting
22	remoteFlag string
23	tokenFlag  string
24)
25
26// Command group IDs
27const (
28	groupServer = "server"
29	groupManage = "manage"
30	groupClient = "client"
31)
32
33// NewRootCmd creates the root command for Gordon CLI.
34func NewRootCmd() *cobra.Command {
35	rootCmd := &cobra.Command{
36		Use:   "gordon",
37		Short: "Gordon - A lightweight container deployment platform",
38		Long: `Gordon is a self-contained container deployment platform that combines
39a Docker registry with automatic container deployment capabilities.
40
41It listens for image pushes and automatically deploys containers based on
42configuration rules, making it ideal for single-server deployments.
43
44Commands are organized by where they run:
45  Server-only:  Run on the machine hosting Gordon (serve, auth, reload)
46  Management:   Work locally or remotely via --remote flag (routes, secrets, etc.)
47  Client-only:  CLI utilities that don't require a running Gordon server`,
48	}
49
50	// Define command groups (order matters for help output)
51	rootCmd.AddGroup(
52		&cobra.Group{ID: groupServer, Title: "Server Commands (local only):"},
53		&cobra.Group{ID: groupManage, Title: "Management Commands (local or --remote):"},
54		&cobra.Group{ID: groupClient, Title: "Client Commands:"},
55	)
56
57	// Add persistent flags for remote targeting
58	rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)")
59	rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote")
60
61	// Server-only commands (must run on the Gordon host)
62	serveCmd := newServeCmd()
63	serveCmd.GroupID = groupServer
64	rootCmd.AddCommand(serveCmd)
65
66	startCmd := newStartCmd() // Deprecated alias for serve
67	startCmd.GroupID = groupServer
68	rootCmd.AddCommand(startCmd)
69
70	authCmd := newAuthCmd()
71	authCmd.GroupID = groupServer
72	rootCmd.AddCommand(authCmd)
73
74	// Management commands (work locally or via --remote)
75	routesCmd := newRoutesCmd()
76	routesCmd.GroupID = groupManage
77	rootCmd.AddCommand(routesCmd)
78
79	attachmentsCmd := newAttachmentsCmd()
80	attachmentsCmd.GroupID = groupManage
81	rootCmd.AddCommand(attachmentsCmd)
82
83	secretsCmd := newSecretsCmd()
84	secretsCmd.GroupID = groupManage
85	rootCmd.AddCommand(secretsCmd)
86
87	deployCmd := newDeployCmd()
88	deployCmd.GroupID = groupManage
89	rootCmd.AddCommand(deployCmd)
90
91	reloadCmd := newReloadCmd()
92	reloadCmd.GroupID = groupManage
93	rootCmd.AddCommand(reloadCmd)
94
95	logsCmd := newLogsCmd()
96	logsCmd.GroupID = groupManage
97	rootCmd.AddCommand(logsCmd)
98
99	statusCmd := newStatusCmd()
100	statusCmd.GroupID = groupManage
101	rootCmd.AddCommand(statusCmd)
102
103	// Client-only commands (no server needed)
104	remotesCmd := newRemotesCmd()
105	remotesCmd.GroupID = groupClient
106	rootCmd.AddCommand(remotesCmd)
107
108	versionCmd := newVersionCmd()
109	versionCmd.GroupID = groupClient
110	rootCmd.AddCommand(versionCmd)
111
112	// Put help and completion in the client group
113	rootCmd.SetHelpCommandGroupID(groupClient)
114	rootCmd.SetCompletionCommandGroupID(groupClient)
115
116	return rootCmd
117}
118
119// GetRemoteClient returns a remote client if targeting a remote instance,
120// or nil if running locally.
121func GetRemoteClient() (*remote.Client, bool) {
122	url, token, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag)
123	if !isRemote {
124		return nil, false
125	}
126
127	client := remote.NewClient(url, remote.WithToken(token))
128	return client, true
129}
130
131// IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
132func IsRemoteMode() bool {
133	_, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag)
134	return isRemote
135}
136
137// newReloadCmd creates the reload command.
138func newReloadCmd() *cobra.Command {
139	return &cobra.Command{
140		Use:   "reload",
141		Short: "Start containers for configured routes",
142		Long: `Starts containers for routes defined in config.toml that don't have
143a running container. Running containers are never restarted to ensure 100% uptime.
144
145Use this command after editing config.toml to add new routes, or after pushing
146images to the registry when the route was not yet configured.`,
147		RunE: func(cmd *cobra.Command, args []string) error {
148			client, isRemote := GetRemoteClient()
149			if isRemote {
150				return runReloadRemote(cmd.Context(), client)
151			}
152			return runReload()
153		},
154	}
155}
156
157// newVersionCmd creates the version command.
158func newVersionCmd() *cobra.Command {
159	return &cobra.Command{
160		Use:   "version",
161		Short: "Print version information",
162		Run: func(cmd *cobra.Command, args []string) {
163			cmd.Printf("Gordon %s\n", Version)
164			cmd.Printf("Commit: %s\n", Commit)
165			cmd.Printf("Build Date: %s\n", BuildDate)
166		},
167	}
168}
169
170// runReload sends SIGUSR1 to the running Gordon process.
171func runReload() error {
172	return app.SendReloadSignal()
173}
174
175// runReloadRemote triggers a reload on a remote Gordon instance.
176func runReloadRemote(ctx context.Context, client *remote.Client) error {
177	if err := client.Reload(ctx); err != nil {
178		return fmt.Errorf("failed to reload: %w", err)
179	}
180	fmt.Println("Configuration reloaded successfully")
181	return nil
182}
183
184// newLogsCmd creates the logs command.
185func newLogsCmd() *cobra.Command {
186	var follow bool
187	var lines int
188	var logsConfigPath string
189
190	cmd := &cobra.Command{
191		Use:   "logs [domain]",
192		Short: "Show logs (Gordon process or container)",
193		Long: `Shows logs from the Gordon process or a specific container.
194
195Without a domain argument, shows Gordon process logs.
196With a domain argument, shows container logs for that domain.
197
198Examples:
199  gordon logs                    # Gordon process logs
200  gordon logs -f                 # Follow process logs
201  gordon logs myapp.local        # Container logs for myapp.local
202  gordon logs myapp.local -f     # Follow container logs
203
204Remote mode:
205  gordon logs --remote https://gordon.mydomain.com --token $TOKEN
206  gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`,
207		Args: cobra.MaximumNArgs(1),
208		RunE: func(cmd *cobra.Command, args []string) error {
209			logDomain := ""
210			if len(args) > 0 {
211				logDomain = args[0]
212			}
213			return runLogs(logsConfigPath, logDomain, follow, lines)
214		},
215	}
216
217	cmd.Flags().StringVarP(&logsConfigPath, "config", "c", "", "Path to config file")
218	cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
219	cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
220
221	return cmd
222}
223
224// runLogs handles the logs command logic.
225func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {
226	client, isRemote := GetRemoteClient()
227	if isRemote {
228		return runLogsRemote(client, logDomain, follow, lines)
229	}
230	return runLogsLocal(logsConfigPath, logDomain, follow, lines)
231}
232
233// runLogsRemote fetches logs from a remote Gordon instance.
234func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error {
235	ctx := context.Background()
236
237	if follow {
238		return streamLogsRemote(ctx, client, logDomain, lines)
239	}
240
241	if logDomain == "" {
242		// Process logs
243		logLines, err := client.GetProcessLogs(ctx, lines)
244		if err != nil {
245			return fmt.Errorf("failed to get process logs: %w", err)
246		}
247		for _, line := range logLines {
248			fmt.Println(line)
249		}
250	} else {
251		// Container logs
252		logLines, err := client.GetContainerLogs(ctx, logDomain, lines)
253		if err != nil {
254			return fmt.Errorf("failed to get container logs: %w", err)
255		}
256		for _, line := range logLines {
257			fmt.Println(line)
258		}
259	}
260	return nil
261}
262
263// streamLogsRemote streams logs from a remote Gordon instance.
264func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int) error {
265	var ch <-chan string
266	var err error
267
268	if logDomain == "" {
269		ch, err = client.StreamProcessLogs(ctx, lines)
270	} else {
271		ch, err = client.StreamContainerLogs(ctx, logDomain, lines)
272	}
273	if err != nil {
274		return fmt.Errorf("failed to stream logs: %w", err)
275	}
276
277	for line := range ch {
278		fmt.Println(line)
279	}
280	return nil
281}
282
283// runLogsLocal shows logs from a local Gordon instance.
284func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
285	if logDomain == "" {
286		// Process logs - use existing app.ShowLogs
287		return app.ShowLogs(logsConfigPath, follow, lines)
288	}
289
290	// Container logs - use local services
291	return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines)
292}
293
294// showContainerLogsLocal shows container logs using local Docker access.
295func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
296	// For local container logs, we need Docker access which requires
297	// the runtime to be initialized. For now, suggest using remote mode
298	// or direct docker logs command.
299	fmt.Printf("Container logs for %s\n", logDomain)
300	fmt.Println("To view container logs locally, use:")
301	fmt.Printf("  docker logs --tail %d %s\n", lines, logDomain)
302	if follow {
303		fmt.Printf("  docker logs -f --tail %d %s\n", lines, logDomain)
304	}
305	fmt.Println("\nOr use remote mode to access logs via the admin API.")
306	return nil
307}
308
309// SetVersionInfo sets the version information for the CLI.
310func SetVersionInfo(version, commit, date string) {
311	Version = version
312	Commit = commit
313	BuildDate = date
314}

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