1package cli
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "sort"
8 "strings"
9
10 "github.com/spf13/cobra"
11
12 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
13 "github.com/bnema/gordon/pkg/validation"
14)
15
16var (
17 resolveControlPlaneFn = resolveControlPlane
18 determineVersionFn = determineVersion
19)
20
21// attachmentPushRequest holds all inputs for the attachments push command.
22type attachmentPushRequest struct {
23 ImageArg string
24 Tag string
25 Build buildConfig
26}
27
28func newAttachmentsPushCmd() *cobra.Command {
29 var (
30 build bool
31 platform string
32 tag string
33 dockerfile string
34 buildArgs []string
35 )
36
37 cmd := &cobra.Command{
38 Use: "push <image>",
39 Short: "Build, tag, and push an attachment image",
40 Args: cobra.ExactArgs(1),
41 RunE: func(cmd *cobra.Command, args []string) error {
42 return runAttachmentsPush(cmd.Context(), attachmentPushRequest{
43 ImageArg: args[0],
44 Tag: tag,
45 Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
46 }, cmd.OutOrStdout())
47 },
48 }
49
50 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
51 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
52 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
53 cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: CI tag ref or git describe)")
54 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
55
56 return cmd
57}
58
59func runAttachmentsPush(ctx context.Context, req attachmentPushRequest, out io.Writer) error {
60 dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
61 if err != nil {
62 return err
63 }
64
65 inferredRemote, err := inferRemoteForAttachmentImage(ctx, req.ImageArg)
66 if err != nil {
67 return err
68 }
69
70 var handle *controlPlaneHandle
71 if inferredRemote != nil {
72 handle = newRemoteControlPlaneHandle(inferredRemote)
73 if err := cliWritef(out, "Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")); err != nil {
74 return err
75 }
76 } else {
77 handle, err = resolveControlPlaneFn(configPath)
78 if err != nil {
79 return err
80 }
81 }
82 defer handle.close()
83
84 for _, ba := range req.Build.BuildArgs {
85 if err := validateBuildArg(ba); err != nil {
86 return err
87 }
88 }
89
90 registry, imageName, targets, err := resolveAttachmentImage(ctx, handle.plane, req.ImageArg)
91 if err != nil {
92 return err
93 }
94
95 version, err := resolveVersionWithFn(ctx, req.Tag)
96 if err != nil {
97 return err
98 }
99
100 img := imagePush{
101 Registry: registry,
102 ImageName: imageName,
103 Version: version,
104 }
105 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
106
107 var imageOps pushImageOps
108 if inferredRemote != nil {
109 imageOps, err = newImageOpsForResolvedRemote(inferredRemote)
110 } else {
111 imageOps, err = newImageOpsFn()
112 }
113 if err != nil {
114 return err
115 }
116
117 if err := printAttachmentPushInfo(out, req.ImageArg, targets, img.VersionRef, img.LatestRef, version); err != nil {
118 return err
119 }
120
121 build := buildConfig{
122 Enabled: req.Build.Enabled,
123 Platform: req.Build.Platform,
124 Dockerfile: dockerfile,
125 BuildArgs: req.Build.BuildArgs,
126 }
127
128 if err := performAttachmentPush(ctx, imageOps, build, img); err != nil {
129 return err
130 }
131
132 return cliWriteLine(out, styles.RenderSuccess("Push complete"))
133}
134
135func printAttachmentPushInfo(out io.Writer, imageArg string, targets []string, versionRef, latestRef, version string) error {
136 if err := cliWritef(out, "Attachment image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil {
137 return err
138 }
139 if err := cliWritef(out, "Targets: %s\n", styles.Theme.Bold.Render(formatAttachmentTargets(targets))); err != nil {
140 return err
141 }
142 if err := cliWritef(out, "Image: %s\n", styles.Theme.Bold.Render(versionRef)); err != nil {
143 return err
144 }
145 if version != "latest" {
146 if err := cliWritef(out, "Also: %s\n", styles.Theme.Bold.Render(latestRef)); err != nil {
147 return err
148 }
149 }
150 return nil
151}
152
153func performAttachmentPush(ctx context.Context, ops pushImageOps, build buildConfig, img imagePush) error {
154 if build.Enabled {
155 return buildAndPush(ctx, ops, build, img)
156 }
157 return tagAndPush(ctx, ops, img)
158}
159
160func resolveAttachmentImage(ctx context.Context, cp ControlPlane, imageArg string) (registry, imageName string, targets []string, err error) {
161 imageName = normalizeAttachmentImageName(imageArg)
162
163 targets, err = cp.FindAttachmentTargetsByImage(ctx, imageArg)
164 if err != nil {
165 return "", "", nil, fmt.Errorf("failed to find attachment targets for image %q: %w", imageArg, err)
166 }
167 if len(targets) == 0 {
168 return "", "", nil, fmt.Errorf("image %q is not configured as an attachment", imageArg)
169 }
170
171 status, err := cp.GetStatus(ctx)
172 if err != nil {
173 return "", "", nil, fmt.Errorf("failed to get status: %w", err)
174 }
175
176 return status.RegistryDomain, imageName, targets, nil
177}
178
179func normalizeAttachmentImageName(imageArg string) string {
180 name := imageArg
181 if idx := strings.Index(name, "@"); idx != -1 {
182 name = name[:idx]
183 }
184 if idx := strings.LastIndex(name, ":"); idx != -1 {
185 slashIdx := strings.LastIndex(name, "/")
186 if idx > slashIdx {
187 name = name[:idx]
188 }
189 }
190 parts := strings.SplitN(name, "/", 2)
191 if len(parts) == 2 {
192 host := parts[0]
193 if strings.ContainsAny(host, ".:") || host == "localhost" {
194 return parts[1]
195 }
196 }
197 return name
198}
199
200func formatAttachmentTargets(targets []string) string {
201 cloned := append([]string(nil), targets...)
202 sort.Strings(cloned)
203 return fmt.Sprintf("%v", cloned)
204}
205
206func resolveVersionWithFn(ctx context.Context, tag string) (string, error) {
207 version := determineVersionFn(ctx, tag)
208 if version != "latest" {
209 if err := validateVersionTag(version); err != nil {
210 return "", err
211 }
212 }
213 return version, nil
214}
215
216func validateVersionTag(version string) error {
217 if err := validation.ValidateReference(version); err != nil {
218 return fmt.Errorf("invalid version tag %q: %w", version, err)
219 }
220 return nil
221}