1package cli
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "sort"
8 "strings"
9
10 "github.com/spf13/cobra"
11 "golang.org/x/mod/semver"
12
13 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19func newRollbackCmd() *cobra.Command {
20 var targetTag string
21
22 cmd := &cobra.Command{
23 Use: "rollback <domain>",
24 Short: "Roll back to a previous image version",
25 Long: `Lists available image tags for a domain and deploys the selected version.
26Tags are read from the Gordon registry.
27
28Examples:
29 gordon rollback myapp.example.com --remote ...
30 gordon rollback myapp.example.com --tag v1.0.0 --remote ...`,
31 Args: cobra.ExactArgs(1),
32 RunE: func(cmd *cobra.Command, args []string) error {
33 return runRollback(cmd.Context(), args[0], targetTag)
34 },
35 }
36
37 cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")
38
39 return cmd
40}
41
42func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
43 client, isRemote := GetRemoteClient()
44 if !isRemote {
45 return fmt.Errorf("rollback requires remote mode")
46 }
47
48 route, err := client.GetRoute(ctx, rollbackDomain)
49 if err != nil {
50 return fmt.Errorf("failed to get route: %w", err)
51 }
52
53 _, imageName, currentTag := parseImageRef(route.Image)
54 if imageName == "" {
55 return fmt.Errorf("cannot parse image name from route: %s", route.Image)
56 }
57
58 tags, err := fetchAndSortTags(ctx, client, imageName)
59 if err != nil {
60 return err
61 }
62
63 selectedTag, err := selectTag(targetTag, tags, currentTag, rollbackDomain)
64 if err != nil || selectedTag == "" {
65 return err
66 }
67
68 if selectedTag == currentTag {
69 fmt.Println(styles.RenderWarning(fmt.Sprintf("Already running %s", selectedTag)))
70 return nil
71 }
72
73 return deploySelectedTag(ctx, client, route, rollbackDomain, imageName, selectedTag)
74}
75
76func fetchAndSortTags(ctx context.Context, client *remote.Client, imageName string) ([]string, error) {
77 tags, err := client.ListTags(ctx, imageName)
78 if err != nil {
79 return nil, fmt.Errorf("failed to list tags: %w", err)
80 }
81 if len(tags) == 0 {
82 return nil, fmt.Errorf("no tags found for %s", imageName)
83 }
84
85 return sortSemverTags(tags), nil
86}
87
88// sortSemverTags normalizes, classifies, and sorts tags.
89// Semver tags (with or without 'v' prefix) are sorted in descending order first,
90// followed by non-semver tags in descending lexicographic order.
91func sortSemverTags(tags []string) []string {
92 var semverTags, otherTags []string
93 for _, tag := range tags {
94 sv := tag
95 if !strings.HasPrefix(sv, "v") {
96 sv = "v" + sv
97 }
98 if semver.IsValid(sv) {
99 semverTags = append(semverTags, tag)
100 } else {
101 otherTags = append(otherTags, tag)
102 }
103 }
104
105 // Sort semver tags in descending order (latest first)
106 sort.Slice(semverTags, func(i, j int) bool {
107 si, sj := semverTags[i], semverTags[j]
108 if !strings.HasPrefix(si, "v") {
109 si = "v" + si
110 }
111 if !strings.HasPrefix(sj, "v") {
112 sj = "v" + sj
113 }
114 return semver.Compare(si, sj) > 0
115 })
116
117 // Sort non-semver tags in descending lexicographic order
118 sort.Slice(otherTags, func(i, j int) bool {
119 return otherTags[i] > otherTags[j]
120 })
121
122 // Combine: semver tags first, then other tags
123 return append(semverTags, otherTags...)
124}
125
126func selectTag(targetTag string, tags []string, currentTag, rollbackDomain string) (string, error) {
127 if targetTag != "" {
128 if !validateTagExists(targetTag, tags) {
129 return "", fmt.Errorf("tag %s not found. Available: %s", targetTag, strings.Join(tags, ", "))
130 }
131 return targetTag, nil
132 }
133
134 selectedTag, err := components.RunSelector(
135 fmt.Sprintf("Select version for %s:", rollbackDomain),
136 tags,
137 currentTag,
138 )
139 if err != nil {
140 return "", err
141 }
142 if selectedTag == "" {
143 fmt.Println("Cancelled.")
144 }
145 return selectedTag, nil
146}
147
148func validateTagExists(targetTag string, tags []string) bool {
149 for _, t := range tags {
150 if t == targetTag {
151 return true
152 }
153 }
154 return false
155}
156
157func deploySelectedTag(ctx context.Context, client *remote.Client, route *domain.Route, rollbackDomain, imageName, selectedTag string) error {
158 registry, _, _ := parseImageRef(route.Image)
159 oldImage := route.Image
160 route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)
161
162 fmt.Printf("Rolling back to %s...\n", styles.Theme.Bold.Render(selectedTag))
163
164 if err := client.UpdateRoute(ctx, *route); err != nil {
165 return fmt.Errorf("failed to update route: %w", err)
166 }
167
168 result, err := client.Deploy(ctx, rollbackDomain)
169 if err != nil {
170 // Attempt to revert route to previous image
171 route.Image = oldImage
172 if revertErr := client.UpdateRoute(ctx, *route); revertErr != nil {
173 fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
174 return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err)
175 }
176 return fmt.Errorf("failed to deploy (route reverted): %w", err)
177 }
178 containerID := result.ContainerID
179 if len(containerID) > 12 {
180 containerID = containerID[:12]
181 }
182 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Rolled back %s to %s (container: %s)", rollbackDomain, selectedTag, containerID)))
183 return nil
184}