1package cli
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "strings"
8
9 "gordon/internal/adapters/in/cli/remote"
10 "gordon/internal/adapters/in/cli/ui/components"
11 "gordon/internal/adapters/in/cli/ui/styles"
12 "gordon/internal/domain"
13
14 "github.com/spf13/cobra"
15)
16
17// configPath for local operations. If empty, config is auto-discovered
18// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
19var configPath string
20
21// truncateImage shortens long image references for display.
22// For digests (image@sha256:...), shows first 12 chars of digest.
23// For tags, truncates to maxLen with ellipsis if needed.
24func truncateImage(image string, maxLen int) string {
25 // Handle digest references: image@sha256:abc123...
26 if idx := strings.Index(image, "@sha256:"); idx != -1 {
27 name := image[:idx]
28 digest := image[idx+8:] // Skip "@sha256:"
29 if len(digest) > 12 {
30 digest = digest[:12]
31 }
32 short := fmt.Sprintf("%s@sha256:%s…", name, digest)
33 if len(short) > maxLen {
34 // Truncate name if still too long
35 available := maxLen - len("@sha256:") - 12 - 1 // 1 for ellipsis
36 if available > 3 {
37 name = name[:available-1] + "…"
38 }
39 short = fmt.Sprintf("%s@sha256:%s…", name, digest)
40 }
41 return short
42 }
43
44 // Regular tag: truncate if needed
45 if len(image) > maxLen {
46 return image[:maxLen-1] + "…"
47 }
48 return image
49}
50
51// newRoutesCmd creates the routes command group.
52func newRoutesCmd() *cobra.Command {
53 cmd := &cobra.Command{
54 Use: "routes",
55 Short: "Manage routes",
56 Long: `Manage Gordon routes. Routes map domains to container images.
57
58When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
59these commands operate on the remote server. Otherwise, they require access to
60the local Gordon configuration.`,
61 }
62
63 cmd.AddCommand(newRoutesListCmd())
64 cmd.AddCommand(newRoutesAddCmd())
65 cmd.AddCommand(newRoutesRemoveCmd())
66
67 return cmd
68}
69
70// formatHTTPStatus formats the HTTP status for display.
71func formatHTTPStatus(health *remote.RouteHealth) string {
72 if health == nil {
73 return styles.Theme.Muted.Render("-")
74 }
75 if health.HTTPStatus == 0 {
76 if health.Error != "" {
77 return styles.Theme.BadgeError.Render("err")
78 }
79 return styles.Theme.Muted.Render("-")
80 }
81 status := fmt.Sprintf("%d", health.HTTPStatus)
82 if health.ResponseTimeMs > 0 {
83 status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs)
84 }
85 if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
86 return styles.Theme.BadgeSuccess.Render(status)
87 }
88 return styles.Theme.BadgeError.Render(status)
89}
90
91// newRoutesListCmd creates the routes list command.
92func newRoutesListCmd() *cobra.Command {
93 return &cobra.Command{
94 Use: "list",
95 Short: "List all routes",
96 RunE: func(cmd *cobra.Command, args []string) error {
97 ctx := context.Background()
98
99 client, isRemote := GetRemoteClient()
100 if isRemote {
101 return runRoutesListRemote(ctx, client)
102 }
103 return runRoutesListLocal(ctx, configPath)
104 },
105 }
106}
107
108// runRoutesListRemote lists routes from a remote Gordon instance.
109func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
110 routes, err := client.ListRoutes(ctx)
111 if err != nil {
112 return fmt.Errorf("failed to list routes: %w", err)
113 }
114
115 if len(routes) == 0 {
116 fmt.Println(styles.Theme.Muted.Render("No routes configured"))
117 return nil
118 }
119
120 // Get health status for each route (includes container status and HTTP probe)
121 health, _ := client.GetHealth(ctx)
122 if health == nil {
123 health = make(map[string]*remote.RouteHealth)
124 }
125
126 // Build table rows
127 const imageColWidth = 35
128 rows := make([][]string, len(routes))
129 for i, route := range routes {
130 routeHealth := health[route.Domain]
131
132 // Container status column
133 containerStatus := "unknown"
134 if routeHealth != nil {
135 containerStatus = routeHealth.ContainerStatus
136 }
137 containerBadge := components.ContainerStatusBadge(containerStatus)
138
139 // HTTP status column
140 httpStatus := formatHTTPStatus(routeHealth)
141
142 displayImage := truncateImage(route.Image, imageColWidth)
143 rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}
144 }
145
146 // Render table
147 table := components.NewTable(
148 components.WithColumns([]components.TableColumn{
149 {Title: "Domain", Width: 25},
150 {Title: "Image", Width: imageColWidth},
151 {Title: "Container", Width: 12},
152 {Title: "HTTP", Width: 14},
153 }),
154 components.WithRows(rows),
155 )
156
157 fmt.Println(styles.Theme.Title.Render("Routes"))
158 fmt.Println()
159 fmt.Println(table.View())
160
161 return nil
162}
163
164// runRoutesListLocal lists routes from local configuration.
165func runRoutesListLocal(ctx context.Context, cfgPath string) error {
166 local, err := GetLocalServices(cfgPath)
167 if err != nil {
168 return fmt.Errorf("failed to initialize local services: %w", err)
169 }
170
171 routes := local.GetConfigService().GetRoutes(ctx)
172
173 if len(routes) == 0 {
174 fmt.Println(styles.Theme.Muted.Render("No routes configured"))
175 return nil
176 }
177
178 // Build table rows (no health info in local mode)
179 const imageColWidth = 45
180 rows := make([][]string, len(routes))
181 for i, route := range routes {
182 displayImage := truncateImage(route.Image, imageColWidth)
183 rows[i] = []string{route.Domain, displayImage}
184 }
185
186 // Render table
187 table := components.NewTable(
188 components.WithColumns([]components.TableColumn{
189 {Title: "Domain", Width: 30},
190 {Title: "Image", Width: imageColWidth},
191 }),
192 components.WithRows(rows),
193 )
194
195 fmt.Println(styles.Theme.Title.Render("Routes (local)"))
196 fmt.Println()
197 fmt.Println(table.View())
198
199 return nil
200}
201
202// newRoutesAddCmd creates the routes add command.
203func newRoutesAddCmd() *cobra.Command {
204 var image string
205
206 cmd := &cobra.Command{
207 Use: "add <domain>",
208 Short: "Add a new route",
209 Long: `Add a new route mapping a domain to a container image.
210
211Examples:
212 gordon routes add app.mydomain.com --image myapp:latest
213 gordon --remote https://gordon.mydomain.com routes add api.mydomain.com --image api:v2`,
214 Args: cobra.ExactArgs(1),
215 RunE: func(cmd *cobra.Command, args []string) error {
216 ctx := context.Background()
217 routeDomain := args[0]
218
219 if image == "" {
220 fmt.Println(styles.RenderError("--image flag is required"))
221 return nil
222 }
223
224 route := domain.Route{
225 Domain: routeDomain,
226 Image: image,
227 }
228
229 client, isRemote := GetRemoteClient()
230 if isRemote {
231 if err := client.AddRoute(ctx, route); err != nil {
232 return fmt.Errorf("failed to add route: %w", err)
233 }
234 } else {
235 local, err := GetLocalServices(configPath)
236 if err != nil {
237 return fmt.Errorf("failed to initialize local services: %w", err)
238 }
239 if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
240 return fmt.Errorf("failed to add route: %w", err)
241 }
242 }
243
244 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))
245 return nil
246 },
247 }
248
249 cmd.Flags().StringVarP(&image, "image", "i", "", "Container image (required)")
250 _ = cmd.MarkFlagRequired("image")
251
252 return cmd
253}
254
255// newRoutesRemoveCmd creates the routes remove command.
256func newRoutesRemoveCmd() *cobra.Command {
257 var force bool
258
259 cmd := &cobra.Command{
260 Use: "remove <domain>",
261 Short: "Remove a route",
262 Long: `Remove a route by its domain name.
263
264Examples:
265 gordon routes remove app.mydomain.com
266 gordon routes remove app.mydomain.com --force`,
267 Args: cobra.ExactArgs(1),
268 RunE: func(cmd *cobra.Command, args []string) error {
269 ctx := context.Background()
270 routeDomain := args[0]
271
272 // Confirm unless --force
273 if !force {
274 confirmed, err := components.RunConfirm(
275 fmt.Sprintf("Remove route '%s'?", routeDomain),
276 components.WithDescription("This will stop and remove the associated container."),
277 )
278 if err != nil {
279 return err
280 }
281 if !confirmed {
282 fmt.Println(styles.Theme.Muted.Render("Cancelled"))
283 return nil
284 }
285 }
286
287 client, isRemote := GetRemoteClient()
288 if isRemote {
289 if err := client.RemoveRoute(ctx, routeDomain); err != nil {
290 return fmt.Errorf("failed to remove route: %w", err)
291 }
292 } else {
293 local, err := GetLocalServices(configPath)
294 if err != nil {
295 return fmt.Errorf("failed to initialize local services: %w", err)
296 }
297 if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil {
298 return fmt.Errorf("failed to remove route: %w", err)
299 }
300 }
301
302 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
303 return nil
304 },
305 }
306
307 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
308
309 return cmd
310}
311
312// newStatusCmd creates the status command.
313func newStatusCmd() *cobra.Command {
314 return &cobra.Command{
315 Use: "status",
316 Short: "Show Gordon server status",
317 RunE: func(cmd *cobra.Command, args []string) error {
318 ctx := context.Background()
319
320 client, isRemote := GetRemoteClient()
321 if !isRemote {
322 fmt.Println(styles.RenderError("status command requires --remote flag or GORDON_REMOTE env var"))
323 os.Exit(1)
324 }
325
326 status, err := client.GetStatus(ctx)
327 if err != nil {
328 return fmt.Errorf("failed to get status: %w", err)
329 }
330
331 // Display status
332 fmt.Println(styles.Theme.Title.Render("Gordon Status"))
333 fmt.Println()
334
335 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)
336 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
337 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
338 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
339 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
340 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
341
342 if len(status.ContainerStatus) > 0 {
343 fmt.Println()
344 fmt.Println(styles.Theme.Bold.Render("Container Status:"))
345 for domain, containerStatus := range status.ContainerStatus {
346 badge := components.ContainerStatusBadge(containerStatus)
347 fmt.Printf(" %s: %s\n", domain, badge)
348 }
349 }
350
351 return nil
352 },
353 }
354}