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