v3.3.1 31f1eec

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	"io"
9	"os"
10	"os/signal"
11	"syscall"
12
13	"github.com/spf13/cobra"
14
15	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
16)
17
18var (
19	// Version information (set at build time)
20	Version   = "dev"
21	Commit    = "unknown"
22	BuildDate = "unknown"
23	Dirty     = "unknown"
24
25	// Global flags for remote targeting
26	remoteFlag      string
27	tokenFlag       string
28	insecureTLSFlag bool
29)
30
31// Command group IDs
32const (
33	groupServer = "server"
34	groupManage = "manage"
35	groupClient = "client"
36)
37
38// NewRootCmd creates the root command for Gordon CLI.
39func NewRootCmd() *cobra.Command {
40	rootCmd := &cobra.Command{
41		Use: "gordon",
42		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
43			if tokenFlag == "" {
44				return nil
45			}
46			token, err := readProtectedSecretFile(tokenFlag)
47			if err != nil {
48				return fmt.Errorf("read remote token: %w", err)
49			}
50			tokenFlag = token
51			return nil
52		},
53		Short: "Gordon - A self-hosted container deployment platform",
54		Long: `Gordon is a self-hosted container deployment platform: a private
55container registry, an app runtime, and a reverse proxy.
56
57Applications are declared in standalone TOML files and managed with
58gordon apps; gordon images push transfers OCI content only and never deploys.
59
60Commands are organized by where they run:
61  Server-only:  Run on the machine hosting Gordon (serve, auth, ca install; ca export/info also accept --remote)
62  Management:   Work locally or remotely via --remote flag (apps, daemon, images, etc.)
63  Client-only:  CLI utilities that don't require a running Gordon server`,
64	}
65
66	// Define command groups (order matters for help output)
67	rootCmd.AddGroup(
68		&cobra.Group{ID: groupServer, Title: "Server Commands (local only):"},
69		&cobra.Group{ID: groupManage, Title: "Management Commands (local or --remote):"},
70		&cobra.Group{ID: groupClient, Title: "Client Commands:"},
71	)
72
73	// Add persistent flags for remote targeting
74	rootCmd.PersistentFlags().StringVarP(&remoteFlag, "remote", "r", "", "Remote name or URL (e.g., prod, https://gordon.mydomain.com)")
75	rootCmd.PersistentFlags().StringVar(&tokenFlag, "token-file", "", "Read remote authentication token from a mode 0600 file")
76	rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints")
77
78	// Server-only commands (must run on the Gordon host)
79	serveCmd := newServeCmd()
80	serveCmd.GroupID = groupServer
81	rootCmd.AddCommand(serveCmd)
82
83	authCmd := newAuthCmd()
84	authCmd.GroupID = groupServer
85	rootCmd.AddCommand(authCmd)
86
87	caCmd := newCACmd()
88	caCmd.GroupID = groupServer
89	rootCmd.AddCommand(caCmd)
90
91	for _, cmd := range []*cobra.Command{
92		newAppsCmd(),
93		newBackupCmd(),
94		newDaemonCmd(),
95		newImagesCmd(),
96		newVolumesCmd(),
97	} {
98		cmd.GroupID = groupManage
99		rootCmd.AddCommand(cmd)
100	}
101
102	// Client-only commands (no server needed)
103	remotesCmd := newRemotesCmd()
104	remotesCmd.GroupID = groupClient
105	rootCmd.AddCommand(remotesCmd)
106
107	versionCmd := newVersionCmd()
108	versionCmd.GroupID = groupClient
109	rootCmd.AddCommand(versionCmd)
110
111	// Put help and completion in the client group
112	rootCmd.SetHelpCommandGroupID(groupClient)
113	rootCmd.SetCompletionCommandGroupID(groupClient)
114
115	return rootCmd
116}
117
118// GetRemoteClient returns a remote client if targeting a remote instance,
119// or nil if running locally.
120func GetRemoteClient() (*remote.Client, bool, error) {
121	resolved, isRemote, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag)
122	if err != nil {
123		return nil, false, err
124	}
125	if !isRemote {
126		return nil, false, nil
127	}
128
129	opts := remoteClientOptions(resolved.Token, resolved.InsecureTLS)
130	client := remote.NewClient(resolved.URL, opts...)
131	return client, true, nil
132}
133
134func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption {
135	opts := make([]remote.ClientOption, 0, 2)
136	if token != "" {
137		opts = append(opts, remote.WithToken(token))
138	}
139	if insecureTLS {
140		opts = append(opts, remote.WithInsecureTLS(true))
141	}
142	return opts
143}
144
145// IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
146func IsRemoteMode() bool {
147	_, isRemote, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag)
148	return err == nil && isRemote
149}
150
151// newReloadCmd creates the reload command.
152func newReloadCmd() *cobra.Command {
153	return &cobra.Command{
154		Use:   "reload",
155		Short: "Reload installation configuration (never activates app state)",
156		Long: `Reloads installation-only settings (entrypoints, TLS, limits,
157external routes) after editing gordon.toml.
158
159Reload never activates pending app desired state, never re-resolves
160image tags, and never starts app workloads. Apps are managed with
161gordon apps (apply, deploy, lifecycle).`,
162		RunE: func(cmd *cobra.Command, args []string) error {
163			handle, err := resolveControlPlane(cliConfigPath)
164			if err != nil {
165				return err
166			}
167			defer handle.close()
168			if err := handle.plane.Reload(cmd.Context()); err != nil {
169				return fmt.Errorf("failed to reload: %w", err)
170			}
171			return cliWriteLine(cmd.OutOrStdout(), cliRenderSuccess("Configuration reloaded successfully"))
172		},
173	}
174}
175
176// newVersionCmd creates the version command.
177func newVersionCmd() *cobra.Command {
178	return &cobra.Command{
179		Use:   "version",
180		Short: "Print version information",
181		RunE: func(cmd *cobra.Command, args []string) error {
182			out := cmd.OutOrStdout()
183			if err := cliWriteLine(out, cliRenderTitle("Gordon "+Version)); err != nil {
184				return err
185			}
186			if err := cliWriteLine(out, cliRenderMeta("Commit:", Commit)); err != nil {
187				return err
188			}
189			if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil {
190				return err
191			}
192			if err := cliWriteLine(out, cliRenderMeta("Dirty:", Dirty)); err != nil {
193				return err
194			}
195			return nil
196		},
197	}
198}
199
200// cliConfigPath for local operations. If empty, config is auto-discovered
201// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
202var cliConfigPath string
203
204// newLogsCmd creates the logs command.
205func newLogsCmd() *cobra.Command {
206	var follow bool
207	var lines int
208	var logsConfigPath string
209
210	cmd := &cobra.Command{
211		Use:   "logs",
212		Short: "Show Gordon process logs",
213		Long: `Shows logs from the Gordon daemon process.
214
215Application workload output is read with gordon apps logs APP --service SVC,
216which resolves the app's active container through the daemon.
217
218Examples:
219  gordon daemon logs        # Gordon process logs
220  gordon daemon logs -f     # Follow process logs
221  gordon daemon logs -n 100 # Last 100 lines
222
223Remote mode:
224  gordon daemon logs --remote prod`,
225		Args: cobra.NoArgs,
226		RunE: func(cmd *cobra.Command, args []string) error {
227			return runLogs(cmd.Context(), logsConfigPath, follow, lines, cmd.OutOrStdout())
228		},
229	}
230
231	cmd.Flags().StringVarP(&logsConfigPath, "config", "c", "", "Path to config file")
232	cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
233	cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
234
235	return cmd
236}
237
238// runLogs handles the logs command logic.
239func runLogs(ctx context.Context, logsConfigPath string, follow bool, lines int, out io.Writer) error {
240	handle, err := resolveControlPlane(logsConfigPath)
241	if err != nil {
242		return err
243	}
244	defer handle.close()
245	return runProcessLogs(ctx, handle.plane, follow, lines, out)
246}
247
248func runProcessLogs(ctx context.Context, cp ControlPlane, follow bool, lines int, out io.Writer) error {
249	ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
250	defer cancel()
251
252	if follow {
253		ch, err := cp.StreamProcessLogs(ctx, lines)
254		if err != nil {
255			return fmt.Errorf("failed to stream process logs: %w", err)
256		}
257		for line := range ch {
258			if err := cliWriteLine(out, line); err != nil {
259				return err
260			}
261		}
262		return nil
263	}
264
265	logLines, err := cp.GetProcessLogs(ctx, lines)
266	if err != nil {
267		return fmt.Errorf("failed to get process logs: %w", err)
268	}
269	for _, line := range logLines {
270		if err := cliWriteLine(out, line); err != nil {
271			return err
272		}
273	}
274	return nil
275}
276
277// SetVersionInfo sets the version information for the CLI.
278func SetVersionInfo(version, commit, date, dirty string) {
279	Version = version
280	Commit = commit
281	BuildDate = date
282	Dirty = dirty
283}

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