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}