v2.14.0 5e11a00

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	// Client-only commands (no server needed)
125	remotesCmd := newRemotesCmd()
126	remotesCmd.GroupID = groupClient
127	rootCmd.AddCommand(remotesCmd)
128
129	versionCmd := newVersionCmd()
130	versionCmd.GroupID = groupClient
131	rootCmd.AddCommand(versionCmd)
132
133	// Put help and completion in the client group
134	rootCmd.SetHelpCommandGroupID(groupClient)
135	rootCmd.SetCompletionCommandGroupID(groupClient)
136
137	return rootCmd
138}
139
140// GetRemoteClient returns a remote client if targeting a remote instance,
141// or nil if running locally.
142func GetRemoteClient() (*remote.Client, bool) {
143	url, token, insecureTLS, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
144	if !isRemote {
145		return nil, false
146	}
147
148	client := remote.NewClient(url, remoteClientOptions(token, insecureTLS)...)
149	return client, true
150}
151
152func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption {
153	opts := make([]remote.ClientOption, 0, 2)
154	if token != "" {
155		opts = append(opts, remote.WithToken(token))
156	}
157	if insecureTLS {
158		opts = append(opts, remote.WithInsecureTLS(true))
159	}
160	return opts
161}
162
163// IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
164func IsRemoteMode() bool {
165	_, _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
166	return isRemote
167}
168
169// newReloadCmd creates the reload command.
170func newReloadCmd() *cobra.Command {
171	return &cobra.Command{
172		Use:   "reload",
173		Short: "Start containers for configured routes",
174		Long: `Starts containers for routes defined in config.toml that don't have
175a running container. Running containers are never restarted to ensure 100% uptime.
176
177Use this command after editing config.toml to add new routes, or after pushing
178images to the registry when the route was not yet configured.`,
179		RunE: func(cmd *cobra.Command, args []string) error {
180			client, isRemote := GetRemoteClient()
181			if isRemote {
182				return runReloadRemote(cmd.Context(), client)
183			}
184			return runReload()
185		},
186	}
187}
188
189// newVersionCmd creates the version command.
190func newVersionCmd() *cobra.Command {
191	return &cobra.Command{
192		Use:   "version",
193		Short: "Print version information",
194		Run: func(cmd *cobra.Command, args []string) {
195			cmd.Printf("Gordon %s\n", Version)
196			cmd.Printf("Commit: %s\n", Commit)
197			cmd.Printf("Build Date: %s\n", BuildDate)
198		},
199	}
200}
201
202// runReload sends SIGUSR1 to the running Gordon process.
203func runReload() error {
204	return app.SendReloadSignal()
205}
206
207// runReloadRemote triggers a reload on a remote Gordon instance.
208func runReloadRemote(ctx context.Context, client *remote.Client) error {
209	if err := client.Reload(ctx); err != nil {
210		if shouldFallbackToLocal(err) {
211			localErr := runReload()
212			if localErr == nil {
213				fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err)
214				fmt.Println("Configuration reloaded successfully")
215				return nil
216			}
217			return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)
218		}
219		return fmt.Errorf("failed to reload: %w", err)
220	}
221	fmt.Println("Configuration reloaded successfully")
222	return nil
223}
224
225// newLogsCmd creates the logs command.
226func newLogsCmd() *cobra.Command {
227	var follow bool
228	var lines int
229	var logsConfigPath string
230
231	cmd := &cobra.Command{
232		Use:   "logs [domain]",
233		Short: "Show logs (Gordon process or container)",
234		Long: `Shows logs from the Gordon process or a specific container.
235
236Without a domain argument, shows Gordon process logs.
237With a domain argument, shows container logs for that domain.
238
239Examples:
240  gordon logs                    # Gordon process logs
241  gordon logs -f                 # Follow process logs
242  gordon logs myapp.local        # Container logs for myapp.local
243  gordon logs myapp.local -f     # Follow container logs
244
245Remote mode:
246  gordon logs --remote https://gordon.mydomain.com --token $TOKEN
247  gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`,
248		Args: cobra.MaximumNArgs(1),
249		RunE: func(cmd *cobra.Command, args []string) error {
250			logDomain := ""
251			if len(args) > 0 {
252				logDomain = args[0]
253			}
254			return runLogs(logsConfigPath, logDomain, follow, lines)
255		},
256	}
257
258	cmd.Flags().StringVarP(&logsConfigPath, "config", "c", "", "Path to config file")
259	cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
260	cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
261
262	return cmd
263}
264
265// runLogs handles the logs command logic.
266func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {
267	client, isRemote := GetRemoteClient()
268	if isRemote {
269		return runLogsRemote(client, logDomain, follow, lines)
270	}
271	return runLogsLocal(logsConfigPath, logDomain, follow, lines)
272}
273
274// runLogsRemote fetches logs from a remote Gordon instance.
275func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error {
276	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
277	defer cancel()
278
279	if follow {
280		return streamLogsRemote(ctx, client, logDomain, lines)
281	}
282
283	if logDomain == "" {
284		// Process logs
285		logLines, err := client.GetProcessLogs(ctx, lines)
286		if err != nil {
287			return fmt.Errorf("failed to get process logs: %w", err)
288		}
289		for _, line := range logLines {
290			fmt.Println(line)
291		}
292	} else {
293		// Container logs
294		logLines, err := client.GetContainerLogs(ctx, logDomain, lines)
295		if err != nil {
296			return fmt.Errorf("failed to get container logs: %w", err)
297		}
298		for _, line := range logLines {
299			fmt.Println(line)
300		}
301	}
302	return nil
303}
304
305// streamLogsRemote streams logs from a remote Gordon instance.
306func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int) error {
307	var ch <-chan string
308	var err error
309
310	if logDomain == "" {
311		ch, err = client.StreamProcessLogs(ctx, lines)
312	} else {
313		ch, err = client.StreamContainerLogs(ctx, logDomain, lines)
314	}
315	if err != nil {
316		return fmt.Errorf("failed to stream logs: %w", err)
317	}
318
319	for line := range ch {
320		fmt.Println(line)
321	}
322	return nil
323}
324
325// runLogsLocal shows logs from a local Gordon instance.
326func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
327	if logDomain == "" {
328		// Process logs - use existing app.ShowLogs
329		return app.ShowLogs(logsConfigPath, follow, lines)
330	}
331
332	// Container logs - use local services
333	return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines)
334}
335
336// showContainerLogsLocal shows container logs using local Docker access.
337func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
338	// For local container logs, we need Docker access which requires
339	// the runtime to be initialized. For now, suggest using remote mode
340	// or direct docker logs command.
341	fmt.Printf("Container logs for %s\n", logDomain)
342	fmt.Println("To view container logs locally, use:")
343	fmt.Printf("  docker logs --tail %d %s\n", lines, logDomain)
344	if follow {
345		fmt.Printf("  docker logs -f --tail %d %s\n", lines, logDomain)
346	}
347	fmt.Println("\nOr use remote mode to access logs via the admin API.")
348	return nil
349}
350
351// SetVersionInfo sets the version information for the CLI.
352func SetVersionInfo(version, commit, date string) {
353	Version = version
354	Commit = commit
355	BuildDate = date
356}

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