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