v2.31.0-dev4 f8fb41e

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().StringVarP(&remoteFlag, "remote", "r", "", "Remote name or URL (e.g., prod, 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	caCmd := newCACmd()
80	caCmd.GroupID = groupServer
81	rootCmd.AddCommand(caCmd)
82
83	// Management commands (work locally or via --remote)
84	routesCmd := newRoutesCmd()
85	routesCmd.GroupID = groupManage
86	rootCmd.AddCommand(routesCmd)
87
88	attachmentsCmd := newAttachmentsCmd()
89	attachmentsCmd.GroupID = groupManage
90	rootCmd.AddCommand(attachmentsCmd)
91
92	secretsCmd := newSecretsCmd()
93	secretsCmd.GroupID = groupManage
94	rootCmd.AddCommand(secretsCmd)
95
96	deployCmd := newDeployCmd()
97	deployCmd.GroupID = groupManage
98	rootCmd.AddCommand(deployCmd)
99
100	restartCmd := newRestartCmd()
101	restartCmd.GroupID = groupManage
102	rootCmd.AddCommand(restartCmd)
103
104	pushCmd := newPushCmd()
105	pushCmd.GroupID = groupManage
106	rootCmd.AddCommand(pushCmd)
107
108	rollbackCmd := newRollbackCmd()
109	rollbackCmd.GroupID = groupManage
110	rootCmd.AddCommand(rollbackCmd)
111
112	reloadCmd := newReloadCmd()
113	reloadCmd.GroupID = groupManage
114	rootCmd.AddCommand(reloadCmd)
115
116	logsCmd := newLogsCmd()
117	logsCmd.GroupID = groupManage
118	rootCmd.AddCommand(logsCmd)
119
120	statusCmd := newStatusCmd()
121	statusCmd.GroupID = groupManage
122	rootCmd.AddCommand(statusCmd)
123
124	backupCmd := newBackupCmd()
125	backupCmd.GroupID = groupManage
126	rootCmd.AddCommand(backupCmd)
127
128	imagesCmd := newImagesCmd()
129	imagesCmd.GroupID = groupManage
130	rootCmd.AddCommand(imagesCmd)
131
132	bootstrapCmd := newBootstrapCmd()
133	bootstrapCmd.GroupID = groupManage
134	rootCmd.AddCommand(bootstrapCmd)
135
136	configCmd := newConfigCmd()
137	configCmd.GroupID = groupManage
138	rootCmd.AddCommand(configCmd)
139
140	networksCmd := newNetworksCmd()
141	networksCmd.GroupID = groupManage
142	rootCmd.AddCommand(networksCmd)
143
144	volumesCmd := newVolumesCmd()
145	volumesCmd.GroupID = groupManage
146	rootCmd.AddCommand(volumesCmd)
147
148	autorouteCmd := newAutorouteCmd()
149	autorouteCmd.GroupID = groupManage
150	rootCmd.AddCommand(autorouteCmd)
151
152	previewCmd := newPreviewCmd()
153	previewCmd.GroupID = groupManage
154	rootCmd.AddCommand(previewCmd)
155
156	// Client-only commands (no server needed)
157	remotesCmd := newRemotesCmd()
158	remotesCmd.GroupID = groupClient
159	rootCmd.AddCommand(remotesCmd)
160
161	versionCmd := newVersionCmd()
162	versionCmd.GroupID = groupClient
163	rootCmd.AddCommand(versionCmd)
164
165	// Put help and completion in the client group
166	rootCmd.SetHelpCommandGroupID(groupClient)
167	rootCmd.SetCompletionCommandGroupID(groupClient)
168
169	return rootCmd
170}
171
172// GetRemoteClient returns a remote client if targeting a remote instance,
173// or nil if running locally.
174func GetRemoteClient() (*remote.Client, bool) {
175	resolved, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)
176	if !isRemote {
177		return nil, false
178	}
179
180	opts := remoteClientOptions(resolved.Token, resolved.InsecureTLS)
181	client := remote.NewClient(resolved.URL, opts...)
182	return client, true
183}
184
185func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption {
186	opts := make([]remote.ClientOption, 0, 2)
187	if token != "" {
188		opts = append(opts, remote.WithToken(token))
189	}
190	if insecureTLS {
191		opts = append(opts, remote.WithInsecureTLS(true))
192	}
193	return opts
194}
195
196// IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
197func IsRemoteMode() bool {
198	_, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)
199	return isRemote
200}
201
202// newReloadCmd creates the reload command.
203func newReloadCmd() *cobra.Command {
204	return &cobra.Command{
205		Use:   "reload",
206		Short: "Start containers for configured routes",
207		Long: `Starts containers for routes defined in config.toml that don't have
208a running container. Running containers are never restarted to ensure 100% uptime.
209
210Use this command after editing config.toml to add new routes, or after pushing
211images to the registry when the route was not yet configured.`,
212		RunE: func(cmd *cobra.Command, args []string) error {
213			client, isRemote := GetRemoteClient()
214			if isRemote {
215				return runReloadRemote(cmd.Context(), client)
216			}
217			return runReload()
218		},
219	}
220}
221
222// newVersionCmd creates the version command.
223func newVersionCmd() *cobra.Command {
224	return &cobra.Command{
225		Use:   "version",
226		Short: "Print version information",
227		RunE: func(cmd *cobra.Command, args []string) error {
228			out := cmd.OutOrStdout()
229			if err := cliWriteLine(out, cliRenderTitle("Gordon "+Version)); err != nil {
230				return err
231			}
232			if err := cliWriteLine(out, cliRenderMeta("Commit:", Commit)); err != nil {
233				return err
234			}
235			if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil {
236				return err
237			}
238			return nil
239		},
240	}
241}
242
243// runReload sends SIGUSR1 to the running Gordon process.
244func runReload() error {
245	return app.SendReloadSignal()
246}
247
248// runReloadRemote triggers a reload on a remote Gordon instance.
249func runReloadRemote(ctx context.Context, client *remote.Client) error {
250	if err := client.Reload(ctx); err != nil {
251		if shouldFallbackToLocal(err) {
252			localErr := runReload()
253			if localErr == nil {
254				if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil {
255					return writeErr
256				}
257				if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil {
258					return writeErr
259				}
260				return nil
261			}
262			return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)
263		}
264		return fmt.Errorf("failed to reload: %w", err)
265	}
266	if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil {
267		return err
268	}
269	return nil
270}
271
272// newLogsCmd creates the logs command.
273func newLogsCmd() *cobra.Command {
274	var follow bool
275	var lines int
276	var logsConfigPath string
277
278	cmd := &cobra.Command{
279		Use:   "logs [domain]",
280		Short: "Show logs (Gordon process or container)",
281		Long: `Shows logs from the Gordon process or a specific container.
282
283Without a domain argument, shows Gordon process logs.
284With a domain argument, shows container logs for that domain.
285
286Examples:
287  gordon logs                    # Gordon process logs
288  gordon logs -f                 # Follow process logs
289  gordon logs myapp.local        # Container logs for myapp.local
290  gordon logs myapp.local -f     # Follow container logs
291
292Remote mode:
293  gordon logs --remote https://gordon.mydomain.com --token $TOKEN
294  gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`,
295		Args: cobra.MaximumNArgs(1),
296		RunE: func(cmd *cobra.Command, args []string) error {
297			logDomain := ""
298			if len(args) > 0 {
299				logDomain = args[0]
300			}
301			return runLogs(logsConfigPath, logDomain, follow, lines)
302		},
303	}
304
305	cmd.Flags().StringVarP(&logsConfigPath, "config", "c", "", "Path to config file")
306	cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
307	cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
308
309	return cmd
310}
311
312// runLogs handles the logs command logic.
313func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {
314	client, isRemote := GetRemoteClient()
315	if isRemote {
316		return runLogsRemote(client, logDomain, follow, lines)
317	}
318	return runLogsLocal(logsConfigPath, logDomain, follow, lines)
319}
320
321// runLogsRemote fetches logs from a remote Gordon instance.
322func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error {
323	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
324	defer cancel()
325
326	if follow {
327		return streamLogsRemote(ctx, client, logDomain, lines)
328	}
329
330	if logDomain == "" {
331		// Process logs
332		logLines, err := client.GetProcessLogs(ctx, lines)
333		if err != nil {
334			return fmt.Errorf("failed to get process logs: %w", err)
335		}
336		for _, line := range logLines {
337			if err := cliWriteLine(os.Stdout, line); err != nil {
338				return err
339			}
340		}
341	} else {
342		// Container logs
343		logLines, err := client.GetContainerLogs(ctx, logDomain, lines)
344		if err != nil {
345			return fmt.Errorf("failed to get container logs: %w", err)
346		}
347		for _, line := range logLines {
348			if err := cliWriteLine(os.Stdout, line); err != nil {
349				return err
350			}
351		}
352	}
353	return nil
354}
355
356// streamLogsRemote streams logs from a remote Gordon instance.
357func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int) error {
358	var ch <-chan string
359	var err error
360
361	if logDomain == "" {
362		ch, err = client.StreamProcessLogs(ctx, lines)
363	} else {
364		ch, err = client.StreamContainerLogs(ctx, logDomain, lines)
365	}
366	if err != nil {
367		return fmt.Errorf("failed to stream logs: %w", err)
368	}
369
370	for line := range ch {
371		if err := cliWriteLine(os.Stdout, line); err != nil {
372			return err
373		}
374	}
375	return nil
376}
377
378// runLogsLocal shows logs from a local Gordon instance.
379func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
380	if logDomain == "" {
381		// Process logs - use existing app.ShowLogs
382		return app.ShowLogs(logsConfigPath, follow, lines)
383	}
384
385	// Container logs - use local services
386	return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines)
387}
388
389// showContainerLogsLocal shows container logs using local Docker access.
390func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
391	// For local container logs, we need Docker access which requires
392	// the runtime to be initialized. For now, suggest using remote mode
393	// or direct docker logs command.
394	if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil {
395		return err
396	}
397	if err := cliWriteLine(os.Stdout, cliRenderInfo("To view container logs locally, use:")); err != nil {
398		return err
399	}
400	if err := cliWritef(os.Stdout, "  docker logs --tail %d %s\n", lines, logDomain); err != nil {
401		return err
402	}
403	if follow {
404		if err := cliWritef(os.Stdout, "  docker logs -f --tail %d %s\n", lines, logDomain); err != nil {
405			return err
406		}
407	}
408	if err := cliWriteLine(os.Stdout, ""); err != nil {
409		return err
410	}
411	if err := cliWriteLine(os.Stdout, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil {
412		return err
413	}
414	return nil
415}
416
417// SetVersionInfo sets the version information for the CLI.
418func SetVersionInfo(version, commit, date string) {
419	Version = version
420	Commit = commit
421	BuildDate = date
422}

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