v2.10.2 705579a

rollback.go RawHistoryFolder

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}

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