v2.16.0 ebd2ab3

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

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