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)
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}