1package cli
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "os"
8 "sort"
9 "strings"
10
11 "github.com/spf13/cobra"
12 "golang.org/x/mod/semver"
13
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 gordon rollback list myapp.example.com --remote ...`,
32 Args: cobra.ExactArgs(1),
33 RunE: func(cmd *cobra.Command, args []string) error {
34 return runRollback(cmd.Context(), args[0], targetTag)
35 },
36 }
37
38 cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")
39 cmd.AddCommand(newRollbackListCmd())
40
41 return cmd
42}
43
44func newRollbackListCmd() *cobra.Command {
45 var jsonOut bool
46
47 cmd := &cobra.Command{
48 Use: "list <domain>",
49 Short: "List available rollback tags",
50 Args: cobra.ExactArgs(1),
51 RunE: func(cmd *cobra.Command, args []string) error {
52 return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut)
53 },
54 }
55
56 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
57
58 return cmd
59}
60
61func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
62 handle, err := resolveControlPlane(configPath)
63 if err != nil {
64 return err
65 }
66 defer handle.close()
67
68 route, err := handle.plane.GetRoute(ctx, rollbackDomain)
69 if err != nil {
70 return fmt.Errorf("failed to get route: %w", err)
71 }
72
73 _, imageName, currentTag := parseImageRef(route.Image)
74 if imageName == "" {
75 return fmt.Errorf("cannot parse image name from route: %s", route.Image)
76 }
77
78 tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
79 if err != nil {
80 return err
81 }
82
83 selectedTag, err := selectTag(targetTag, tags, currentTag, rollbackDomain)
84 if err != nil || selectedTag == "" {
85 return err
86 }
87
88 if selectedTag == currentTag {
89 fmt.Println(styles.RenderWarning(fmt.Sprintf("Already running %s", selectedTag)))
90 return nil
91 }
92
93 return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)
94}
95
96func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string, jsonOut bool) error {
97 handle, err := resolveControlPlane(configPath)
98 if err != nil {
99 return err
100 }
101 defer handle.close()
102
103 route, err := handle.plane.GetRoute(ctx, rollbackDomain)
104 if err != nil {
105 return fmt.Errorf("failed to get route: %w", err)
106 }
107
108 _, imageName, currentTag := parseImageRef(route.Image)
109 if imageName == "" {
110 return fmt.Errorf("cannot parse image name from route: %s", route.Image)
111 }
112
113 tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
114 if err != nil {
115 return err
116 }
117
118 return printRollbackTags(w, rollbackDomain, currentTag, tags, jsonOut)
119}
120
121func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string, jsonOut bool) error {
122 if jsonOut {
123 return writeJSON(w, map[string]any{
124 "domain": rollbackDomain,
125 "current_tag": currentTag,
126 "tags": tags,
127 })
128 }
129
130 if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil {
131 return err
132 }
133 for _, tag := range tags {
134 suffix := ""
135 if tag == currentTag {
136 suffix = " (current)"
137 }
138 if _, err := fmt.Fprintf(w, "- %s%s\n", tag, suffix); err != nil {
139 return err
140 }
141 }
142 return nil
143}
144
145func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) {
146 tags, err := cp.ListTags(ctx, imageName)
147 if err != nil {
148 return nil, fmt.Errorf("failed to list tags: %w", err)
149 }
150 if len(tags) == 0 {
151 return nil, fmt.Errorf("no tags found for %s", imageName)
152 }
153
154 return sortSemverTags(tags), nil
155}
156
157// sortSemverTags normalizes, classifies, and sorts tags.
158// Semver tags (with or without 'v' prefix) are sorted in descending order first,
159// followed by non-semver tags in descending lexicographic order.
160func sortSemverTags(tags []string) []string {
161 var semverTags, otherTags []string
162 for _, tag := range tags {
163 sv := tag
164 if !strings.HasPrefix(sv, "v") {
165 sv = "v" + sv
166 }
167 if semver.IsValid(sv) {
168 semverTags = append(semverTags, tag)
169 } else {
170 otherTags = append(otherTags, tag)
171 }
172 }
173
174 // Sort semver tags in descending order (latest first)
175 sort.Slice(semverTags, func(i, j int) bool {
176 si, sj := semverTags[i], semverTags[j]
177 if !strings.HasPrefix(si, "v") {
178 si = "v" + si
179 }
180 if !strings.HasPrefix(sj, "v") {
181 sj = "v" + sj
182 }
183 return semver.Compare(si, sj) > 0
184 })
185
186 // Sort non-semver tags in descending lexicographic order
187 sort.Slice(otherTags, func(i, j int) bool {
188 return otherTags[i] > otherTags[j]
189 })
190
191 // Combine: semver tags first, then other tags
192 return append(semverTags, otherTags...)
193}
194
195func selectTag(targetTag string, tags []string, currentTag, rollbackDomain string) (string, error) {
196 if targetTag != "" {
197 if !validateTagExists(targetTag, tags) {
198 return "", fmt.Errorf("tag %s not found. Available: %s", targetTag, strings.Join(tags, ", "))
199 }
200 return targetTag, nil
201 }
202
203 selectedTag, err := components.RunSelector(
204 fmt.Sprintf("Select version for %s:", rollbackDomain),
205 tags,
206 currentTag,
207 )
208 if err != nil {
209 return "", err
210 }
211 if selectedTag == "" {
212 fmt.Println("Cancelled.")
213 }
214 return selectedTag, nil
215}
216
217func validateTagExists(targetTag string, tags []string) bool {
218 for _, t := range tags {
219 if t == targetTag {
220 return true
221 }
222 }
223 return false
224}
225
226func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route, rollbackDomain, imageName, selectedTag string) error {
227 registry, _, _ := parseImageRef(route.Image)
228 oldImage := route.Image
229 route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)
230
231 fmt.Printf("Rolling back to %s...\n", styles.Theme.Bold.Render(selectedTag))
232
233 if err := cp.UpdateRoute(ctx, *route); err != nil {
234 return fmt.Errorf("failed to update route: %w", err)
235 }
236
237 result, err := cp.Deploy(ctx, rollbackDomain)
238 if err != nil {
239 // Attempt to revert route to previous image
240 route.Image = oldImage
241 if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {
242 fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
243 return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err)
244 }
245 return fmt.Errorf("failed to deploy (route reverted): %w", err)
246 }
247 containerID := shortContainerID(result.ContainerID)
248 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Rolled back %s to %s (container: %s)", rollbackDomain, selectedTag, containerID)))
249 return nil
250}