v2.16.0 ebd2ab3

rollback.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close