1package cli
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "sort"
10 "strconv"
11 "strings"
12 "sync"
13
14 "github.com/spf13/cobra"
15
16 "github.com/bnema/gordon/internal/adapters/dto"
17 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
18 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
19 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
20 "github.com/bnema/gordon/internal/domain"
21)
22
23// configPath for local operations. If empty, config is auto-discovered
24// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
25var configPath string
26
27// truncateImage shortens long image references for display.
28// For digests (image@sha256:...), shows first 12 chars of digest.
29// For tags, truncates to maxLen with ellipsis if needed.
30func truncateImage(image string, maxLen int) string {
31 // Handle digest references: image@sha256:abc123...
32 if maxLen <= 0 {
33 return ""
34 }
35
36 if name, digest, ok := strings.Cut(image, "@sha256:"); ok {
37 if len(digest) > 12 {
38 digest = digest[:12]
39 }
40 short := fmt.Sprintf("%s@sha256:%s", name, digest)
41 if len(short) <= maxLen {
42 return short
43 }
44 // Truncate from the end to maintain valid reference format
45 if maxLen <= 3 {
46 return short[:maxLen]
47 }
48 return short[:maxLen-3] + "..."
49 }
50
51 // Regular tag: truncate if needed
52 if len(image) <= maxLen {
53 return image
54 }
55 if maxLen <= 3 {
56 return image[:maxLen]
57 }
58 return image[:maxLen-3] + "..."
59}
60
61const networkPrefix = "gordon-"
62
63// httpHealthToStatus maps an HTTP health probe result to a components.Status.
64func httpHealthToStatus(health *remote.RouteHealth) components.Status {
65 if health == nil {
66 return components.StatusUnknown
67 }
68 if health.HTTPStatus == 0 {
69 if health.Error != "" {
70 return components.StatusError
71 }
72 return components.StatusUnknown
73 }
74 if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
75 return components.StatusSuccess
76 }
77 return components.StatusError
78}
79
80// stripNetworkPrefix removes the "gordon-" prefix from a network name for display.
81func stripNetworkPrefix(network string) string {
82 return strings.TrimPrefix(network, networkPrefix)
83}
84
85// networkGroup holds routes that share a network.
86type networkGroup struct {
87 name string
88 routes []remote.RouteInfo
89}
90
91type routeListItem struct {
92 Domain string `json:"domain"`
93 Image string `json:"image"`
94}
95
96type routeListSection struct {
97 Kind string `json:"kind"`
98 Name string `json:"name"`
99 URL string `json:"url,omitempty"`
100 Error string `json:"error,omitempty"`
101 Routes []routeListItem `json:"routes,omitempty"`
102}
103
104type routeStatusAttachment struct {
105 Name string `json:"name"`
106 Image string `json:"image"`
107 Status string `json:"status"`
108}
109
110type routeStatusItem struct {
111 Domain string `json:"domain"`
112 Image string `json:"image"`
113 ContainerID string `json:"container_id,omitempty"`
114 ContainerStatus string `json:"container_status"`
115 HTTPStatus int `json:"http_status"`
116 HealthError string `json:"health_error,omitempty"`
117 Network string `json:"network"`
118 Attachments []routeStatusAttachment `json:"attachments,omitempty"`
119}
120
121type routeStatusSection struct {
122 Kind string `json:"kind"`
123 Name string `json:"name"`
124 URL string `json:"url,omitempty"`
125 Error string `json:"error,omitempty"`
126 Routes []routeStatusItem `json:"routes,omitempty"`
127}
128
129type routeDiagnosis struct {
130 Domain string `json:"domain"`
131 Configured bool `json:"configured"`
132 Route *domain.Route `json:"route,omitempty"`
133 Runtime *remote.RouteInfo `json:"runtime,omitempty"`
134 Health *remote.RouteHealth `json:"health,omitempty"`
135 Volumes []dto.Volume `json:"volumes,omitempty"`
136 OrphanedAttachments []domain.CleanupAttachment `json:"orphaned_attachments,omitempty"`
137 Warnings []string `json:"warnings,omitempty"`
138 Hints []string `json:"hints,omitempty"`
139}
140
141type routesListDeps struct {
142 explicitRemote func() (*remote.ResolvedRemote, bool, error)
143 loadLocal func(context.Context, string) (routeListSection, error)
144 listRemotes func() (map[string]remote.RemoteEntry, string, error)
145 loadRemote func(context.Context, string, remote.RemoteEntry) (routeListSection, error)
146}
147
148type routesStatusDeps struct {
149 explicitRemote func() (*remote.ResolvedRemote, bool, error)
150 loadLocal func(context.Context, string) (routeStatusSection, error)
151 listRemotes func() (map[string]remote.RemoteEntry, string, error)
152 loadRemote func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error)
153}
154
155// groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
156// and solo routes. Both are sorted alphabetically by domain.
157func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
158 byNetwork := make(map[string][]remote.RouteInfo)
159 var networkOrder []string
160 for _, route := range routes {
161 if _, seen := byNetwork[route.Network]; !seen {
162 networkOrder = append(networkOrder, route.Network)
163 }
164 byNetwork[route.Network] = append(byNetwork[route.Network], route)
165 }
166
167 var groups []networkGroup
168 var solo []remote.RouteInfo
169
170 for _, net := range networkOrder {
171 members := byNetwork[net]
172 if len(members) >= 2 {
173 sort.Slice(members, func(i, j int) bool {
174 return members[i].Domain < members[j].Domain
175 })
176 groups = append(groups, networkGroup{
177 name: stripNetworkPrefix(net),
178 routes: members,
179 })
180 } else {
181 solo = append(solo, members[0])
182 }
183 }
184
185 sort.Slice(solo, func(i, j int) bool {
186 return solo[i].Domain < solo[j].Domain
187 })
188
189 return groups, solo
190}
191
192// newRoutesCmd creates the routes command group.
193func newRoutesCmd() *cobra.Command {
194 cmd := &cobra.Command{
195 Use: "routes",
196 Short: "Manage routes",
197 Long: `Manage Gordon routes. Routes map domains to container images.
198
199When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
200these commands operate on the remote server. Otherwise, they require access to
201the local Gordon configuration.`,
202 }
203
204 cmd.AddCommand(newRoutesListCmd())
205 cmd.AddCommand(newRoutesStatusCmd())
206
207 cmd.AddCommand(newRoutesShowCmd())
208 cmd.AddCommand(newRoutesDiagnoseCmd())
209 cmd.AddCommand(newRoutesPurgeCmd())
210 cmd.AddCommand(newRoutesAddCmd())
211 cmd.AddCommand(newRoutesRemoveCmd())
212
213 return cmd
214}
215
216// newRoutesListCmd creates the routes list command.
217func newRoutesListCmd() *cobra.Command {
218 var jsonOut bool
219
220 cmd := &cobra.Command{
221 Use: "list",
222 Short: "List all routes",
223 RunE: func(cmd *cobra.Command, args []string) error {
224 sections, err := collectRoutesListSections(cmd.Context(), configPath, routesListDeps{})
225 if err != nil {
226 return err
227 }
228
229 if jsonOut {
230 return writeJSON(cmd.OutOrStdout(), sections)
231 }
232
233 return renderRoutesListSections(cmd.OutOrStdout(), sections)
234 },
235 }
236
237 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
238
239 return cmd
240}
241
242func newRoutesStatusCmd() *cobra.Command {
243 var jsonOut bool
244
245 cmd := &cobra.Command{
246 Use: "status",
247 Short: "Show detailed route status",
248 RunE: func(cmd *cobra.Command, args []string) error {
249 sections, err := collectRoutesStatusSections(cmd.Context(), configPath, routesStatusDeps{})
250 if err != nil {
251 return err
252 }
253
254 if jsonOut {
255 return writeJSON(cmd.OutOrStdout(), sections)
256 }
257
258 return renderRoutesStatusSections(cmd.OutOrStdout(), sections)
259 },
260 }
261
262 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
263
264 return cmd
265}
266
267func collectRoutesListSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
268 if deps.explicitRemote == nil {
269 deps.explicitRemote = resolveRoutesExplicitRemote
270 }
271 if deps.loadLocal == nil {
272 deps.loadLocal = loadRoutesListLocalSection
273 }
274 if deps.listRemotes == nil {
275 deps.listRemotes = remote.ListRemotes
276 }
277 if deps.loadRemote == nil {
278 deps.loadRemote = loadRoutesListRemoteSection
279 }
280
281 if resolved, ok, err := deps.explicitRemote(); err != nil {
282 return nil, err
283 } else if ok {
284 section := loadRoutesListExplicitRemoteSection(ctx, deps, resolved)
285 return []routeListSection{section}, nil
286 }
287 if target, ok := resolveRoutesExplicitTarget(); ok {
288 return []routeListSection{{
289 Kind: "remote",
290 Name: target,
291 Error: unresolvedRoutesTargetError(target),
292 }}, nil
293 }
294
295 return collectRoutesListAggregateSections(ctx, cfgPath, deps)
296}
297
298func loadRoutesListExplicitRemoteSection(ctx context.Context, deps routesListDeps, resolved *remote.ResolvedRemote) routeListSection {
299 section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
300 if err != nil && section.Error == "" {
301 section.Error = err.Error()
302 }
303 return normalizeRouteListRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
304}
305
306func collectRoutesListAggregateSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
307
308 type localResult struct {
309 section routeListSection
310 err error
311 }
312 type remotesResult struct {
313 entries map[string]remote.RemoteEntry
314 err error
315 }
316
317 localCh := make(chan localResult, 1)
318 remotesCh := make(chan remotesResult, 1)
319
320 go func() {
321 section, err := deps.loadLocal(ctx, cfgPath)
322 localCh <- localResult{section: section, err: err}
323 }()
324 go func() {
325 entries, _, err := deps.listRemotes()
326 remotesCh <- remotesResult{entries: entries, err: err}
327 }()
328
329 localRes := <-localCh
330 remotes := <-remotesCh
331 local := localRes.section
332 if err := localRes.err; err != nil {
333 if local.Error == "" {
334 local.Error = err.Error()
335 }
336 }
337
338 sections := make([]routeListSection, 0, 1)
339 sections = append(sections, normalizeRouteListLocalSection(local))
340
341 if remotes.err != nil {
342 sections = append(sections, routeListSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
343 return sections, nil
344 }
345
346 names := sortedRemoteNames(remotes.entries)
347 remoteSections := make([]routeListSection, len(names))
348 var wg sync.WaitGroup
349 for i, name := range names {
350 entry := remotes.entries[name]
351 wg.Add(1)
352 go func(idx int, remoteName string, remoteEntry remote.RemoteEntry) {
353 defer wg.Done()
354 section, err := deps.loadRemote(ctx, remoteName, remoteEntry)
355 if err != nil && section.Error == "" {
356 section.Error = err.Error()
357 }
358 remoteSections[idx] = normalizeRouteListRemoteSection(section, remoteName, remoteEntry)
359 }(i, name, entry)
360 }
361 wg.Wait()
362
363 sections = append(sections, remoteSections...)
364 return sections, nil
365}
366
367func normalizeRouteListLocalSection(section routeListSection) routeListSection {
368 if section.Kind == "" {
369 section.Kind = "local"
370 }
371 if section.Name == "" {
372 section.Name = "local"
373 }
374 return section
375}
376
377func normalizeRouteListRemoteSection(section routeListSection, name string, entry remote.RemoteEntry) routeListSection {
378 if section.Kind == "" {
379 section.Kind = "remote"
380 }
381 if section.Name == "" {
382 section.Name = name
383 }
384 if section.URL == "" {
385 section.URL = entry.URL
386 }
387 return section
388}
389
390func collectRoutesStatusSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
391 if deps.explicitRemote == nil {
392 deps.explicitRemote = resolveRoutesExplicitRemote
393 }
394 if deps.loadLocal == nil {
395 deps.loadLocal = loadRoutesStatusLocalSection
396 }
397 if deps.listRemotes == nil {
398 deps.listRemotes = remote.ListRemotes
399 }
400 if deps.loadRemote == nil {
401 deps.loadRemote = loadRoutesStatusRemoteSection
402 }
403
404 if resolved, ok, err := deps.explicitRemote(); err != nil {
405 return nil, err
406 } else if ok {
407 section := loadRoutesStatusExplicitRemoteSection(ctx, deps, resolved)
408 return []routeStatusSection{section}, nil
409 }
410 if target, ok := resolveRoutesExplicitTarget(); ok {
411 return []routeStatusSection{{
412 Kind: "remote",
413 Name: target,
414 Error: unresolvedRoutesTargetError(target),
415 }}, nil
416 }
417
418 return collectRoutesStatusAggregateSections(ctx, cfgPath, deps)
419}
420
421func loadRoutesStatusExplicitRemoteSection(ctx context.Context, deps routesStatusDeps, resolved *remote.ResolvedRemote) routeStatusSection {
422 section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
423 if err != nil && section.Error == "" {
424 section.Error = err.Error()
425 }
426 return normalizeRouteStatusRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
427}
428
429func collectRoutesStatusAggregateSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
430
431 type localResult struct {
432 section routeStatusSection
433 err error
434 }
435 type remotesResult struct {
436 entries map[string]remote.RemoteEntry
437 err error
438 }
439
440 localCh := make(chan localResult, 1)
441 remotesCh := make(chan remotesResult, 1)
442
443 go func() {
444 section, err := deps.loadLocal(ctx, cfgPath)
445 localCh <- localResult{section: section, err: err}
446 }()
447 go func() {
448 entries, _, err := deps.listRemotes()
449 remotesCh <- remotesResult{entries: entries, err: err}
450 }()
451
452 localRes := <-localCh
453 remotes := <-remotesCh
454 local := localRes.section
455 if err := localRes.err; err != nil {
456 if local.Error == "" {
457 local.Error = err.Error()
458 }
459 }
460
461 sections := make([]routeStatusSection, 0, 1)
462 sections = append(sections, normalizeRouteStatusLocalSection(local))
463
464 if remotes.err != nil {
465 sections = append(sections, routeStatusSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
466 return sections, nil
467 }
468
469 names := sortedRemoteNames(remotes.entries)
470 remoteSections := make([]routeStatusSection, len(names))
471 var wg sync.WaitGroup
472 for i, name := range names {
473 entry := remotes.entries[name]
474 wg.Add(1)
475 go func(idx int, remoteName string, remoteEntry remote.RemoteEntry) {
476 defer wg.Done()
477 section, err := deps.loadRemote(ctx, remoteName, remoteEntry)
478 if err != nil && section.Error == "" {
479 section.Error = err.Error()
480 }
481 remoteSections[idx] = normalizeRouteStatusRemoteSection(section, remoteName, remoteEntry)
482 }(i, name, entry)
483 }
484 wg.Wait()
485
486 sections = append(sections, remoteSections...)
487 return sections, nil
488}
489
490func normalizeRouteStatusLocalSection(section routeStatusSection) routeStatusSection {
491 if section.Kind == "" {
492 section.Kind = "local"
493 }
494 if section.Name == "" {
495 section.Name = "local"
496 }
497 return section
498}
499
500func normalizeRouteStatusRemoteSection(section routeStatusSection, name string, entry remote.RemoteEntry) routeStatusSection {
501 if section.Kind == "" {
502 section.Kind = "remote"
503 }
504 if section.Name == "" {
505 section.Name = name
506 }
507 if section.URL == "" {
508 section.URL = entry.URL
509 }
510 return section
511}
512
513func renderRoutesListSections(out io.Writer, sections []routeListSection) error {
514 sections = renderedRouteListSections(sections)
515
516 if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
517 return err
518 }
519 if err := cliWriteLine(out, ""); err != nil {
520 return err
521 }
522
523 for i, section := range sections {
524 if i > 0 {
525 if err := cliWriteLine(out, ""); err != nil {
526 return err
527 }
528 }
529 if err := renderRoutesListSection(out, section); err != nil {
530 return err
531 }
532 }
533
534 return nil
535}
536
537func renderRoutesListSection(out io.Writer, section routeListSection) error {
538 if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
539 return err
540 }
541
542 if len(section.Routes) > 0 {
543 const imageColWidth = 45
544 rows := make([][]string, 0, len(section.Routes))
545 for _, route := range section.Routes {
546 rows = append(rows, []string{route.Domain, truncateImage(route.Image, imageColWidth)})
547 }
548
549 table := components.NewTable(
550 components.WithColumns([]components.TableColumn{
551 {Title: "Domain", Width: 30},
552 {Title: "Image", Width: imageColWidth},
553 }),
554 components.WithRows(rows),
555 )
556
557 if err := cliWriteLine(out, table.View()); err != nil {
558 return err
559 }
560 }
561
562 if section.Error != "" {
563 return cliWriteLine(out, cliRenderWarning(section.Error))
564 }
565
566 if len(section.Routes) == 0 {
567 return cliWriteLine(out, cliRenderMuted("No routes configured"))
568 }
569
570 return nil
571}
572
573func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error {
574 sections = renderedRouteStatusSections(sections)
575
576 if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil {
577 return err
578 }
579 if err := cliWriteLine(out, ""); err != nil {
580 return err
581 }
582
583 for i, section := range sections {
584 if i > 0 {
585 if err := cliWriteLine(out, ""); err != nil {
586 return err
587 }
588 }
589 if err := renderRoutesStatusSection(out, section); err != nil {
590 return err
591 }
592 }
593
594 return nil
595}
596
597func renderedRouteListSections(sections []routeListSection) []routeListSection {
598 if !shouldHideLocalRouteListSection(sections) {
599 return sections
600 }
601 return sections[1:]
602}
603
604func shouldHideLocalRouteListSection(sections []routeListSection) bool {
605 if len(sections) < 2 {
606 return false
607 }
608 local := sections[0]
609 if local.Kind != "local" || local.Error == "" {
610 return false
611 }
612 for _, section := range sections[1:] {
613 if section.Kind == "remote" && section.Error == "" {
614 return true
615 }
616 }
617 return false
618}
619
620func renderedRouteStatusSections(sections []routeStatusSection) []routeStatusSection {
621 if !shouldHideLocalRouteStatusSection(sections) {
622 return sections
623 }
624 return sections[1:]
625}
626
627func shouldHideLocalRouteStatusSection(sections []routeStatusSection) bool {
628 if len(sections) < 2 {
629 return false
630 }
631 local := sections[0]
632 if local.Kind != "local" || local.Error == "" {
633 return false
634 }
635 for _, section := range sections[1:] {
636 if section.Kind == "remote" && section.Error == "" {
637 return true
638 }
639 }
640 return false
641}
642
643func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error {
644 if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
645 return err
646 }
647
648 if len(section.Routes) > 0 {
649 tree := buildRouteStatusTree(section.Routes)
650 if err := cliWriteLine(out, strings.TrimSuffix(tree.Render(), "\n")); err != nil {
651 return err
652 }
653 }
654
655 if section.Error != "" {
656 return cliWriteLine(out, cliRenderWarning(section.Error))
657 }
658
659 if len(section.Routes) == 0 {
660 return cliWriteLine(out, cliRenderMuted("No routes configured"))
661 }
662
663 return nil
664}
665
666func routeSectionHeading(kind, name string) string {
667 switch kind {
668 case "local":
669 return "Local"
670 case "remote":
671 if name != "" {
672 return "Remote: " + name
673 }
674 return "Remote"
675 default:
676 if name != "" {
677 return capitalizeKind(kind) + ": " + name
678 }
679 return capitalizeKind(kind)
680 }
681}
682
683func capitalizeKind(kind string) string {
684 if kind == "" {
685 return ""
686 }
687 if len(kind) == 1 {
688 return strings.ToUpper(kind)
689 }
690 return strings.ToUpper(kind[:1]) + kind[1:]
691}
692
693func unresolvedRoutesTargetError(target string) string {
694 return fmt.Sprintf("could not resolve remote target %q", target)
695}
696
697func resolveRoutesExplicitRemote() (*remote.ResolvedRemote, bool, error) {
698 target, ok := resolveRoutesExplicitTarget()
699 if !ok {
700 return nil, false, nil
701 }
702
703 if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
704 return &remote.ResolvedRemote{
705 URL: target,
706 Token: resolveRoutesTokenForTarget("", remote.RemoteEntry{}),
707 InsecureTLS: resolveRoutesInsecureForTarget("", remote.RemoteEntry{}),
708 }, true, nil
709 }
710
711 remotes, err := remote.LoadRemotes("")
712 if err != nil {
713 return nil, false, err
714 }
715
716 if remotes != nil {
717 if entry, found := remotes.Remotes[target]; found {
718 return &remote.ResolvedRemote{
719 Name: target,
720 URL: entry.URL,
721 Token: resolveRoutesTokenForTarget(target, entry),
722 InsecureTLS: resolveRoutesInsecureForTarget(target, entry),
723 }, true, nil
724 }
725 }
726
727 return nil, false, nil
728}
729
730func resolveRoutesExplicitTarget() (string, bool) {
731 if target := strings.TrimSpace(remoteFlag); target != "" {
732 return target, true
733 }
734 if target := strings.TrimSpace(os.Getenv("GORDON_REMOTE")); target != "" {
735 return target, true
736 }
737 return "", false
738}
739
740func resolveRoutesTokenForTarget(name string, entry remote.RemoteEntry) string {
741 if token := strings.TrimSpace(tokenFlag); token != "" {
742 return token
743 }
744 if token := strings.TrimSpace(os.Getenv("GORDON_TOKEN")); token != "" {
745 return token
746 }
747 if name != "" {
748 return remote.ResolveTokenForRemote(name, entry)
749 }
750 return ""
751}
752
753func resolveRoutesInsecureForTarget(name string, entry remote.RemoteEntry) bool {
754 if insecureTLSFlag {
755 return true
756 }
757 if env := strings.TrimSpace(os.Getenv("GORDON_INSECURE")); env != "" {
758 if value, err := strconv.ParseBool(env); err == nil {
759 return value
760 }
761 }
762 if name != "" {
763 return entry.InsecureTLS
764 }
765 return false
766}
767
768func newRoutesTargetClient(name string, entry remote.RemoteEntry) *remote.Client {
769 return remote.NewClient(entry.URL, remoteClientOptions(resolveRoutesTokenForTarget(name, entry), resolveRoutesInsecureForTarget(name, entry))...)
770}
771
772func loadRoutesListLocalSection(ctx context.Context, cfgPath string) (routeListSection, error) {
773 local, err := GetLocalServices(cfgPath)
774 if err != nil {
775 return routeListSection{Kind: "local", Name: "local", Error: err.Error()}, nil
776 }
777
778 routes := local.GetConfigService().GetRoutes(ctx)
779 section := routeListSection{Kind: "local", Name: "local", Routes: make([]routeListItem, 0, len(routes))}
780 for _, route := range routes {
781 section.Routes = append(section.Routes, routeListItem{Domain: route.Domain, Image: route.Image})
782 }
783
784 return section, nil
785}
786
787func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
788 section := routeListSection{Kind: "remote", Name: name, URL: entry.URL}
789 client := newRoutesTargetClient(name, entry)
790 routes, err := client.ListRoutesWithDetails(ctx)
791 if err != nil {
792 section.Error = err.Error()
793 return section, nil
794 }
795
796 section.Routes = routeListItemsFromInfos(routes)
797 return section, nil
798}
799
800func loadRoutesStatusLocalSection(ctx context.Context, cfgPath string) (routeStatusSection, error) {
801 section := routeStatusSection{Kind: "local", Name: "local"}
802 handle, err := resolveLocalControlPlane(cfgPath)
803 if err != nil {
804 section.Error = err.Error()
805 return section, nil
806 }
807 defer handle.close()
808
809 routes, err := handle.plane.ListRoutesWithDetails(ctx)
810 if err != nil {
811 section.Error = err.Error()
812 return section, nil
813 }
814
815 health, err := handle.plane.GetHealth(ctx)
816 if err != nil {
817 section.Error = err.Error()
818 health = nil
819 }
820
821 section.Routes = routeStatusItemsFromInfos(routes, health)
822 return section, nil
823}
824
825func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeStatusSection, error) {
826 section := routeStatusSection{Kind: "remote", Name: name, URL: entry.URL}
827 client := newRoutesTargetClient(name, entry)
828 cp := NewRemoteControlPlane(client)
829
830 routes, err := cp.ListRoutesWithDetails(ctx)
831 if err != nil {
832 section.Error = err.Error()
833 return section, nil
834 }
835
836 health, err := cp.GetHealth(ctx)
837 if err != nil {
838 section.Error = err.Error()
839 health = nil
840 }
841
842 section.Routes = routeStatusItemsFromInfos(routes, health)
843 return section, nil
844}
845
846func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem {
847 items := make([]routeStatusItem, 0, len(routes))
848 for _, route := range routes {
849 items = append(items, routeStatusItemFromInfo(route, health[route.Domain]))
850 }
851 return items
852}
853
854func routeStatusItemFromInfo(route remote.RouteInfo, routeHealth *remote.RouteHealth) routeStatusItem {
855 item := routeStatusItem{
856 Domain: route.Domain,
857 Image: route.Image,
858 ContainerID: route.ContainerID,
859 ContainerStatus: route.ContainerStatus,
860 Network: route.Network,
861 }
862 if item.ContainerStatus == "" && routeHealth != nil {
863 item.ContainerStatus = routeHealth.ContainerStatus
864 item.HTTPStatus = routeHealth.HTTPStatus
865 item.HealthError = routeHealth.Error
866 }
867 if item.ContainerStatus == "" {
868 item.ContainerStatus = "unknown"
869 }
870 if item.HTTPStatus == 0 && routeHealth != nil {
871 item.HTTPStatus = routeHealth.HTTPStatus
872 item.HealthError = routeHealth.Error
873 }
874 if item.HealthError == "" && routeHealth != nil {
875 item.HealthError = routeHealth.Error
876 }
877 item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments))
878 for _, attachment := range route.Attachments {
879 status := attachment.Status
880 if status == "" {
881 status = "unknown"
882 }
883 item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status})
884 }
885 return item
886}
887
888func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem {
889 items := make([]routeListItem, 0, len(routes))
890 for _, route := range routes {
891 items = append(items, routeListItem{Domain: route.Domain, Image: route.Image})
892 }
893 return items
894}
895
896func buildRouteStatusTree(routes []routeStatusItem) *components.Tree {
897 sortedRoutes := make([]routeStatusItem, len(routes))
898 copy(sortedRoutes, routes)
899 sort.SliceStable(sortedRoutes, func(i, j int) bool {
900 if sortedRoutes[i].Network != sortedRoutes[j].Network {
901 return sortedRoutes[i].Network < sortedRoutes[j].Network
902 }
903 if sortedRoutes[i].Domain != sortedRoutes[j].Domain {
904 return sortedRoutes[i].Domain < sortedRoutes[j].Domain
905 }
906 if sortedRoutes[i].Image != sortedRoutes[j].Image {
907 return sortedRoutes[i].Image < sortedRoutes[j].Image
908 }
909 if sortedRoutes[i].ContainerID != sortedRoutes[j].ContainerID {
910 return sortedRoutes[i].ContainerID < sortedRoutes[j].ContainerID
911 }
912 if sortedRoutes[i].ContainerStatus != sortedRoutes[j].ContainerStatus {
913 return sortedRoutes[i].ContainerStatus < sortedRoutes[j].ContainerStatus
914 }
915 return sortedRoutes[i].HTTPStatus < sortedRoutes[j].HTTPStatus
916 })
917
918 infos := make([]remote.RouteInfo, 0, len(routes))
919 itemsByDomain := make(map[string]routeStatusItem, len(routes))
920 for _, route := range sortedRoutes {
921 infos = append(infos, remote.RouteInfo{
922 Domain: route.Domain,
923 Image: route.Image,
924 ContainerID: route.ContainerID,
925 ContainerStatus: route.ContainerStatus,
926 Network: route.Network,
927 Attachments: routeStatusAttachmentsToRemote(route.Attachments),
928 })
929 itemsByDomain[route.Domain] = route
930 }
931
932 groups, solo := groupRoutesByNetwork(infos)
933 tree := components.NewTree()
934
935 for _, group := range groups {
936 g := tree.AddGroup(group.name)
937 for _, route := range group.routes {
938 item := itemsByDomain[route.Domain]
939 node := g.AddNode(routeStatusTitle(item), item.Image)
940 addRouteStatusAttachmentChildren(node, item)
941 }
942 }
943
944 for _, route := range solo {
945 item := itemsByDomain[route.Domain]
946 node := tree.AddNode(routeStatusTitle(item), item.Image)
947 addRouteStatusAttachmentChildren(node, item)
948 }
949
950 return tree
951}
952
953func routeStatusTitle(route routeStatusItem) string {
954 containerStatus := route.ContainerStatus
955 if containerStatus == "" {
956 containerStatus = "unknown"
957 }
958
959 httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(&remote.RouteHealth{HTTPStatus: route.HTTPStatus, Error: route.HealthError}))
960 containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
961
962 return httpIcon + " " + containerIcon + " " + route.Domain
963}
964
965func addRouteStatusAttachmentChildren(node *components.Node, route routeStatusItem) {
966 for _, att := range route.Attachments {
967 status := att.Status
968 if status == "" {
969 status = "unknown"
970 }
971 attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(status))
972 node.AddChild(attIcon+" "+att.Name, att.Image)
973 }
974}
975
976func routeStatusAttachmentsToRemote(attachments []routeStatusAttachment) []remote.Attachment {
977 result := make([]remote.Attachment, 0, len(attachments))
978 for _, att := range attachments {
979 result = append(result, remote.Attachment{Name: att.Name, Image: att.Image, Status: att.Status})
980 }
981 return result
982}
983
984func newRoutesShowCmd() *cobra.Command {
985 var jsonOut bool
986
987 cmd := &cobra.Command{
988 Use: "show <domain>",
989 Short: "Show details for a single route",
990 Long: `Display detailed information about a specific route including its image,
991container status, and health.
992
993Examples:
994 gordon routes show app.mydomain.com
995 gordon routes show app.mydomain.com --json
996 gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
997 Args: cobra.ExactArgs(1),
998 RunE: func(cmd *cobra.Command, args []string) error {
999 ctx := cmd.Context()
1000 handle, err := resolveControlPlaneForRouteDomain(ctx, args[0])
1001 if err != nil {
1002 return err
1003 }
1004 defer handle.close()
1005 return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
1006 },
1007 }
1008
1009 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1010
1011 return cmd
1012}
1013
1014func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
1015 route, err := cp.GetRoute(ctx, routeDomain)
1016 if err != nil {
1017 if errors.Is(err, domain.ErrRouteNotFound) {
1018 return fmt.Errorf("route %q not found", routeDomain)
1019 }
1020 return fmt.Errorf("failed to get route: %w", err)
1021 }
1022
1023 health, err := cp.GetHealth(ctx)
1024 healthErr := ""
1025 if err != nil {
1026 healthErr = err.Error()
1027 health = nil
1028 }
1029 var routeHealth *remote.RouteHealth
1030 if health != nil {
1031 routeHealth = health[routeDomain]
1032 }
1033
1034 containerStatus := "unknown"
1035 httpStatus := 0
1036 if routeHealth != nil {
1037 if routeHealth.ContainerStatus != "" {
1038 containerStatus = routeHealth.ContainerStatus
1039 }
1040 httpStatus = routeHealth.HTTPStatus
1041 if healthErr == "" && routeHealth.Error != "" {
1042 healthErr = routeHealth.Error
1043 }
1044 }
1045
1046 if jsonOut {
1047 payload := map[string]any{
1048 "domain": route.Domain,
1049 "image": route.Image,
1050 "container_status": containerStatus,
1051 "http_status": httpStatus,
1052 }
1053 if healthErr != "" {
1054 payload["health_error"] = healthErr
1055 }
1056 return writeJSON(out, payload)
1057 }
1058
1059 return writeRouteShowText(out, route.Domain, route.Image, containerStatus, httpStatus, healthErr)
1060}
1061
1062func writeRouteShowText(out io.Writer, domainName, image, containerStatus string, httpStatus int, healthErr string) error {
1063 if err := cliWriteLine(out, cliRenderTitle("Route: "+domainName)); err != nil {
1064 return err
1065 }
1066 if err := cliWriteLine(out, ""); err != nil {
1067 return err
1068 }
1069 if err := cliWriteLine(out, cliRenderMeta("Domain:", domainName)); err != nil {
1070 return err
1071 }
1072 if err := cliWriteLine(out, cliRenderMeta("Image:", image)); err != nil {
1073 return err
1074 }
1075 if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
1076 return err
1077 }
1078 if httpStatus > 0 {
1079 if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
1080 return err
1081 }
1082 }
1083 if healthErr != "" {
1084 if err := cliWriteLine(out, cliRenderWarning(healthErr)); err != nil {
1085 return err
1086 }
1087 }
1088
1089 return nil
1090}
1091
1092// newRoutesDiagnoseCmd creates the routes diagnose command.
1093func newRoutesDiagnoseCmd() *cobra.Command {
1094 var jsonOut bool
1095 cmd := &cobra.Command{
1096 Use: "diagnose <domain>",
1097 Short: "Diagnose route configuration, runtime state, and cleanup leftovers",
1098 Args: cobra.ExactArgs(1),
1099 RunE: func(cmd *cobra.Command, args []string) error {
1100 return runRouteDiagnosis(cmd.Context(), args[0], cmd.OutOrStdout(), jsonOut)
1101 },
1102 }
1103 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1104 return cmd
1105}
1106
1107func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, jsonOut bool) error {
1108 handle, err := resolveControlPlaneForRouteDomain(ctx, domainName)
1109 if err != nil {
1110 return err
1111 }
1112 defer handle.close()
1113 diag, err := buildRouteDiagnosis(ctx, handle.plane, domainName)
1114 if err != nil {
1115 return err
1116 }
1117 if jsonOut {
1118 return writeJSON(out, diag)
1119 }
1120 return writeRouteDiagnosisText(out, diag)
1121}
1122
1123func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) {
1124 diag := &routeDiagnosis{Domain: domainName}
1125 loadRouteDiagnosisConfig(ctx, cp, domainName, diag)
1126 loadRouteDiagnosisRuntime(ctx, cp, domainName, diag)
1127 loadRouteDiagnosisHealth(ctx, cp, domainName, diag)
1128 loadRouteDiagnosisVolumes(ctx, cp, domainName, diag)
1129 loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag)
1130 finalizeRouteDiagnosis(diag)
1131 return diag, nil
1132}
1133
1134func loadRouteDiagnosisConfig(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1135 route, err := cp.GetRoute(ctx, domainName)
1136 if err == nil && route != nil {
1137 diag.Configured = true
1138 diag.Route = route
1139 return
1140 }
1141 if err != nil && !errors.Is(err, domain.ErrRouteNotFound) {
1142 diag.Warnings = append(diag.Warnings, "failed to load route config: "+err.Error())
1143 }
1144}
1145
1146func loadRouteDiagnosisRuntime(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1147 routes, err := cp.ListRoutesWithDetails(ctx)
1148 if err != nil {
1149 diag.Warnings = append(diag.Warnings, "failed to load route runtime details: "+err.Error())
1150 return
1151 }
1152 for _, info := range routes {
1153 if strings.EqualFold(info.Domain, domainName) {
1154 copy := info
1155 diag.Runtime = ©
1156 return
1157 }
1158 }
1159}
1160
1161func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1162 if health, err := cp.GetHealth(ctx); err == nil && health != nil {
1163 diag.Health = health[domainName]
1164 }
1165}
1166
1167func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1168 if volumes, err := cp.ListVolumes(ctx); err == nil {
1169 diag.Volumes = volumesForRouteDiagnosis(volumes, domainName)
1170 }
1171}
1172
1173func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1174 lister, ok := cp.(interface {
1175 ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)
1176 })
1177 if !ok {
1178 return
1179 }
1180 if attachments, err := lister.ListOrphanedAttachments(ctx); err == nil {
1181 diag.OrphanedAttachments = attachmentsForRouteDiagnosis(attachments, domainName)
1182 }
1183}
1184
1185func finalizeRouteDiagnosis(diag *routeDiagnosis) {
1186 if !diag.Configured && diag.Runtime != nil {
1187 diag.Warnings = append(diag.Warnings, "route is not configured but runtime state still exists")
1188 }
1189 if len(diag.Volumes) > 0 {
1190 diag.Hints = append(diag.Hints, "persistent volumes are preserved by default")
1191 }
1192 if len(diag.OrphanedAttachments) > 0 {
1193 diag.Hints = append(diag.Hints, "run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes")
1194 }
1195}
1196
1197func volumesForRouteDiagnosis(volumes []dto.Volume, domainName string) []dto.Volume {
1198 needle := strings.ReplaceAll(strings.ToLower(domainName), ".", "-")
1199 var out []dto.Volume
1200 for _, volume := range volumes {
1201 if strings.Contains(strings.ToLower(volume.Name), needle) {
1202 out = append(out, volume)
1203 }
1204 }
1205 return out
1206}
1207
1208func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment {
1209 var out []domain.CleanupAttachment
1210 for _, attachment := range attachments {
1211 if strings.EqualFold(attachment.Owner, domainName) {
1212 out = append(out, attachment)
1213 }
1214 }
1215 return out
1216}
1217
1218func writeRouteDiagnosisText(out io.Writer, diag *routeDiagnosis) error {
1219 if err := cliWriteLine(out, cliRenderTitle("Route diagnosis: "+diag.Domain)); err != nil {
1220 return err
1221 }
1222 if err := writeRouteDiagnosisSummary(out, diag); err != nil {
1223 return err
1224 }
1225 if err := writeRouteDiagnosisLeftovers(out, diag); err != nil {
1226 return err
1227 }
1228 return writeCleanupMessages(out, diag.Warnings, diag.Hints)
1229}
1230
1231func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error {
1232 if diag.Configured && diag.Route != nil {
1233 if err := cliWriteLine(out, cliRenderMeta("Image:", diag.Route.Image)); err != nil {
1234 return err
1235 }
1236 } else if err := cliWriteLine(out, cliRenderWarning("Route is not configured")); err != nil {
1237 return err
1238 }
1239 if diag.Runtime == nil {
1240 return nil
1241 }
1242 return cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID)))
1243}
1244
1245func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error {
1246 for _, volume := range diag.Volumes {
1247 if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil {
1248 return err
1249 }
1250 }
1251 for _, attachment := range diag.OrphanedAttachments {
1252 if err := cliWriteLine(out, cliRenderWarning("Orphaned attachment: "+attachment.Name)); err != nil {
1253 return err
1254 }
1255 }
1256 return nil
1257}
1258
1259func newRoutesPurgeCmd() *cobra.Command {
1260 var jsonOut bool
1261 var force bool
1262 var includeAttachments bool
1263 var includeVolumes bool
1264 cmd := &cobra.Command{
1265 Use: "purge <domain>",
1266 Short: "Review or explicitly purge retained route resources",
1267 Args: cobra.ExactArgs(1),
1268 RunE: func(cmd *cobra.Command, args []string) error {
1269 return runRoutePurgeCommand(cmd.Context(), args[0], cmd.OutOrStdout(), routePurgeOptions{Force: force, Attachments: includeAttachments, Volumes: includeVolumes}, jsonOut)
1270 },
1271 }
1272 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1273 cmd.Flags().BoolVar(&force, "force", false, "Execute purge actions; default is dry-run")
1274 cmd.Flags().BoolVar(&includeAttachments, "attachments", false, "Include orphaned attachment containers")
1275 cmd.Flags().BoolVar(&includeVolumes, "volumes", false, "Include preserved volumes in the purge plan")
1276 return cmd
1277}
1278
1279type routePurgeOptions struct {
1280 Force bool
1281 Attachments bool
1282 Volumes bool
1283}
1284
1285func runRoutePurgeCommand(ctx context.Context, domainName string, out io.Writer, opts routePurgeOptions, jsonOut bool) error {
1286 handle, err := resolveControlPlaneForRouteDomain(ctx, domainName)
1287 if err != nil {
1288 return err
1289 }
1290 defer handle.close()
1291 report, err := runRoutePurge(ctx, handle.plane, domainName, opts)
1292 if err != nil {
1293 return err
1294 }
1295 if jsonOut {
1296 return writeJSON(out, report)
1297 }
1298 return writeRoutePurgeText(out, report, opts.Force)
1299}
1300
1301func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts routePurgeOptions) (*domain.CleanupReport, error) {
1302 diag, err := buildRouteDiagnosis(ctx, cp, domainName)
1303 if err != nil {
1304 return nil, err
1305 }
1306 report := &domain.CleanupReport{Domain: domainName}
1307 if diag.Runtime != nil && diag.Runtime.ContainerID != "" {
1308 report.OrphanedEntities = append(report.OrphanedEntities, domain.CleanupOrphanedEntity{Kind: "route_container", ID: diag.Runtime.ContainerID, Status: diag.Runtime.ContainerStatus, Reason: "route runtime state matched purge target"})
1309 }
1310 if opts.Volumes {
1311 for _, volume := range diag.Volumes {
1312 report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})
1313 }
1314 }
1315 if !opts.Attachments || !opts.Force {
1316 report.PreservedAttachments = append(report.PreservedAttachments, diag.OrphanedAttachments...)
1317 }
1318 if opts.Attachments && opts.Force {
1319 cleaner, ok := cp.(interface {
1320 CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error)
1321 })
1322 if !ok {
1323 return nil, fmt.Errorf("attachment purge is unavailable")
1324 }
1325 attachmentReport, err := cleaner.CleanupOrphanedAttachments(ctx, domainName, true)
1326 if err != nil {
1327 return nil, err
1328 }
1329 report.RemovedContainers = append(report.RemovedContainers, attachmentReport.RemovedContainers...)
1330 report.PreservedVolumes = append(report.PreservedVolumes, attachmentReport.PreservedVolumes...)
1331 report.PreservedAttachments = append(report.PreservedAttachments, attachmentReport.PreservedAttachments...)
1332 report.OrphanedEntities = append(report.OrphanedEntities, attachmentReport.OrphanedEntities...)
1333 report.Warnings = append(report.Warnings, attachmentReport.Warnings...)
1334 report.Hints = append(report.Hints, attachmentReport.Hints...)
1335 report.PartialFailures = append(report.PartialFailures, attachmentReport.PartialFailures...)
1336 }
1337 if opts.Volumes {
1338 report.Warnings = append(report.Warnings, "volume purge is not executed yet; preserved volumes are reported for manual review")
1339 }
1340 if !opts.Force {
1341 report.Hints = append(report.Hints, "dry-run only; add --force with explicit category flags to execute supported purge actions")
1342 }
1343 return report, nil
1344}
1345
1346func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error {
1347 mode := "Purge dry-run"
1348 if force {
1349 mode = "Purge completed"
1350 }
1351 if err := cliWriteLine(out, cliRenderTitle(mode+": "+report.Domain)); err != nil {
1352 return err
1353 }
1354 if err := writePreservedAttachments(out, report.PreservedAttachments); err != nil {
1355 return err
1356 }
1357 if err := writeRemovedContainers(out, report.RemovedContainers); err != nil {
1358 return err
1359 }
1360 for _, entity := range report.OrphanedEntities {
1361 if err := cliWriteLine(out, cliRenderWarning("Purge candidate: "+entity.Kind+" "+entity.ID)); err != nil {
1362 return err
1363 }
1364 }
1365 for _, volume := range report.PreservedVolumes {
1366 if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil {
1367 return err
1368 }
1369 }
1370 return writeCleanupMessages(out, report.Warnings, report.Hints)
1371}
1372
1373func newRoutesAddCmd() *cobra.Command {
1374 var image string
1375
1376 cmd := &cobra.Command{
1377 Use: "add <domain> <image>",
1378 Short: "Create or update a route",
1379 Long: `Create or update a route mapping a domain to a container image.
1380
1381If the route already exists with the same image, this is a no-op.
1382If it exists with a different image, the image is updated.
1383
1384Examples:
1385 gordon routes add app.mydomain.com myapp:latest
1386 gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,
1387 Args: cobra.RangeArgs(1, 2),
1388 RunE: func(cmd *cobra.Command, args []string) error {
1389 ctx := context.Background()
1390 routeDomain := args[0]
1391
1392 // Resolve image from positional argument or flag
1393 hasPositionalImage := len(args) > 1
1394 hasFlagImage := image != ""
1395
1396 // Check for conflicts: both positional and flag provided
1397 if hasPositionalImage && hasFlagImage {
1398 return fmt.Errorf("error: cannot use both positional image argument and --image flag")
1399 }
1400
1401 // Check that image is provided via either method
1402 if !hasPositionalImage && !hasFlagImage {
1403 return fmt.Errorf("error: image is required (use positional argument or --image flag)")
1404 }
1405
1406 if hasPositionalImage {
1407 image = args[1]
1408 }
1409
1410 route := domain.Route{
1411 Domain: routeDomain,
1412 Image: image,
1413 }
1414
1415 client, isRemote := GetRemoteClient()
1416 if isRemote {
1417 if err := client.AddRoute(ctx, route); err != nil {
1418 return fmt.Errorf("failed to add route: %w", err)
1419 }
1420 } else {
1421 local, err := GetLocalServices(configPath)
1422 if err != nil {
1423 return fmt.Errorf("failed to initialize local services: %w", err)
1424 }
1425 if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
1426 return fmt.Errorf("failed to add route: %w", err)
1427 }
1428 }
1429
1430 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))
1431 return nil
1432 },
1433 }
1434
1435 cmd.Flags().StringVarP(&image, "image", "i", "", "Container image")
1436
1437 return cmd
1438}
1439
1440// newRoutesRemoveCmd creates the routes remove command.
1441func newRoutesRemoveCmd() *cobra.Command {
1442 var force bool
1443
1444 cmd := &cobra.Command{
1445 Use: "remove <domain>",
1446 Short: "Remove a route",
1447 Long: `Remove a route by its domain name.
1448
1449Examples:
1450 gordon routes remove app.mydomain.com
1451 gordon routes remove app.mydomain.com --force`,
1452 Args: cobra.ExactArgs(1),
1453 RunE: func(cmd *cobra.Command, args []string) error {
1454 ctx := cmd.Context()
1455 routeDomain := args[0]
1456
1457 // Confirm unless --force
1458 if !force {
1459 confirmed, err := components.RunConfirm(
1460 fmt.Sprintf("Remove route '%s'?", routeDomain),
1461 components.WithDescription("This will stop and remove the associated container."),
1462 )
1463 if err != nil {
1464 return err
1465 }
1466 if !confirmed {
1467 fmt.Println(styles.Theme.Muted.Render("Cancelled"))
1468 return nil
1469 }
1470 }
1471
1472 handle, err := resolveControlPlaneForRouteDomain(ctx, routeDomain)
1473 if err != nil {
1474 return err
1475 }
1476 defer handle.close()
1477 var cleanup *dto.CleanupReport
1478 if remover, ok := handle.plane.(interface {
1479 RemoveRouteWithCleanup(context.Context, string) (*dto.RouteDeleteResponse, error)
1480 }); ok {
1481 resp, err := remover.RemoveRouteWithCleanup(ctx, routeDomain)
1482 if err != nil {
1483 return fmt.Errorf("failed to remove route: %w", err)
1484 }
1485 if resp != nil {
1486 cleanup = resp.Cleanup
1487 }
1488 } else if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil {
1489 return fmt.Errorf("failed to remove route: %w", err)
1490 }
1491
1492 return writeRouteRemoveText(cmd.OutOrStdout(), routeDomain, cleanup)
1493 },
1494 }
1495
1496 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
1497
1498 return cmd
1499}
1500
1501func writeRouteRemoveText(out io.Writer, routeDomain string, cleanup *dto.CleanupReport) error {
1502 if err := cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))); err != nil {
1503 return err
1504 }
1505 if cleanup == nil {
1506 return nil
1507 }
1508 cleanupReport := cleanupReportDTOToDomain(cleanup)
1509 if err := writeRemovedContainers(out, cleanupReport.RemovedContainers); err != nil {
1510 return err
1511 }
1512 if err := writePreservedAttachments(out, cleanupReport.PreservedAttachments); err != nil {
1513 return err
1514 }
1515 return writeCleanupMessages(out, cleanupReport.Warnings, cleanupReport.Hints)
1516}
1517
1518func cleanupReportDTOToDomain(cleanup *dto.CleanupReport) *domain.CleanupReport {
1519 if cleanup == nil {
1520 return nil
1521 }
1522 out := &domain.CleanupReport{
1523 Domain: cleanup.Domain,
1524 Warnings: append([]string(nil), cleanup.Warnings...),
1525 Hints: append([]string(nil), cleanup.Hints...),
1526 }
1527 for _, item := range cleanup.RemovedContainers {
1528 out.RemovedContainers = append(out.RemovedContainers, domain.CleanupContainer(item))
1529 }
1530 for _, item := range cleanup.PreservedAttachments {
1531 out.PreservedAttachments = append(out.PreservedAttachments, domain.CleanupAttachment(item))
1532 }
1533 return out
1534}
1535
1536func writeRemovedContainers(out io.Writer, containers []domain.CleanupContainer) error {
1537 if len(containers) == 0 {
1538 return nil
1539 }
1540 if err := cliWriteLine(out, ""); err != nil {
1541 return err
1542 }
1543 if err := cliWriteLine(out, styles.Theme.Bold.Render("Removed containers:")); err != nil {
1544 return err
1545 }
1546 for _, c := range containers {
1547 label := c.Name
1548 if label == "" {
1549 label = c.ID
1550 }
1551 if id := shortContainerID(c.ID); id != "" && id != label {
1552 label = fmt.Sprintf("%s (%s)", label, id)
1553 }
1554 if err := cliWriteLine(out, " "+label); err != nil {
1555 return err
1556 }
1557 }
1558 return nil
1559}
1560
1561func writePreservedAttachments(out io.Writer, attachments []domain.CleanupAttachment) error {
1562 if len(attachments) == 0 {
1563 return nil
1564 }
1565 if err := cliWriteLine(out, ""); err != nil {
1566 return err
1567 }
1568 if err := cliWriteLine(out, styles.Theme.Bold.Render("Preserved attachments:")); err != nil {
1569 return err
1570 }
1571 for _, a := range attachments {
1572 label := a.Name
1573 if label == "" {
1574 label = a.ContainerID
1575 }
1576 if a.Status != "" {
1577 label = fmt.Sprintf("%s (%s)", label, a.Status)
1578 }
1579 if err := cliWriteLine(out, " "+label); err != nil {
1580 return err
1581 }
1582 }
1583 return nil
1584}
1585
1586func writeCleanupMessages(out io.Writer, warnings, hints []string) error {
1587 for _, warning := range warnings {
1588 if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil {
1589 return err
1590 }
1591 }
1592 for _, hint := range hints {
1593 if err := cliWriteLine(out, cliRenderMuted("Hint: "+hint)); err != nil {
1594 return err
1595 }
1596 }
1597 return nil
1598}
1599
1600// newStatusCmd creates the status command.
1601func newStatusCmd() *cobra.Command {
1602 return &cobra.Command{
1603 Use: "status",
1604 Short: "Show Gordon server status",
1605 RunE: func(cmd *cobra.Command, args []string) error {
1606 ctx := context.Background()
1607
1608 handle, err := resolveControlPlane(configPath)
1609 if err != nil {
1610 return err
1611 }
1612 defer handle.close()
1613
1614 status, err := handle.plane.GetStatus(ctx)
1615 if err != nil {
1616 return fmt.Errorf("failed to get status: %w", err)
1617 }
1618
1619 // Display status
1620 fmt.Println(styles.Theme.Title.Render("Gordon Status"))
1621 fmt.Println()
1622
1623 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)
1624 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
1625 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
1626 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
1627 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
1628 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
1629
1630 if len(status.ContainerStatus) > 0 {
1631 fmt.Println()
1632 fmt.Println(styles.Theme.Bold.Render("Container Status:"))
1633 for domain, containerStatus := range status.ContainerStatus {
1634 badge := components.ContainerStatusBadge(containerStatus)
1635 fmt.Printf(" %s: %s\n", domain, badge)
1636 }
1637 }
1638
1639 return nil
1640 },
1641 }
1642}