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