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}