v2.31.0-dev22 8d342c4

pin.go RawHistoryFolder

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

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