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