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 handle, err := resolveControlPlaneFn(configPath)
61 if err != nil {
62 return err
63 }
64 defer handle.close()
65
66 dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
67 if err != nil {
68 return err
69 }
70
71 for _, ba := range req.Build.BuildArgs {
72 if err := validateBuildArg(ba); err != nil {
73 return err
74 }
75 }
76
77 registry, imageName, targets, err := resolveAttachmentImage(ctx, handle.plane, req.ImageArg)
78 if err != nil {
79 return err
80 }
81
82 version, err := resolveVersionWithFn(ctx, req.Tag)
83 if err != nil {
84 return err
85 }
86
87 img := imagePush{
88 Registry: registry,
89 ImageName: imageName,
90 Version: version,
91 }
92 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
93
94 imageOps, err := newImageOpsFn()
95 if err != nil {
96 return err
97 }
98
99 if err := printAttachmentPushInfo(out, req.ImageArg, targets, img.VersionRef, img.LatestRef, version); err != nil {
100 return err
101 }
102
103 build := buildConfig{
104 Enabled: req.Build.Enabled,
105 Platform: req.Build.Platform,
106 Dockerfile: dockerfile,
107 BuildArgs: req.Build.BuildArgs,
108 }
109
110 if err := performAttachmentPush(ctx, imageOps, build, img); err != nil {
111 return err
112 }
113
114 return cliWriteLine(out, styles.RenderSuccess("Push complete"))
115}
116
117func printAttachmentPushInfo(out io.Writer, imageArg string, targets []string, versionRef, latestRef, version string) error {
118 if err := cliWritef(out, "Attachment image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil {
119 return err
120 }
121 if err := cliWritef(out, "Targets: %s\n", styles.Theme.Bold.Render(formatAttachmentTargets(targets))); err != nil {
122 return err
123 }
124 if err := cliWritef(out, "Image: %s\n", styles.Theme.Bold.Render(versionRef)); err != nil {
125 return err
126 }
127 if version != "latest" {
128 if err := cliWritef(out, "Also: %s\n", styles.Theme.Bold.Render(latestRef)); err != nil {
129 return err
130 }
131 }
132 return nil
133}
134
135func performAttachmentPush(ctx context.Context, ops pushImageOps, build buildConfig, img imagePush) error {
136 if build.Enabled {
137 return buildAndPush(ctx, ops, build, img)
138 }
139 return tagAndPush(ctx, ops, img)
140}
141
142func resolveAttachmentImage(ctx context.Context, cp ControlPlane, imageArg string) (registry, imageName string, targets []string, err error) {
143 imageName = normalizeAttachmentImageName(imageArg)
144
145 targets, err = cp.FindAttachmentTargetsByImage(ctx, imageArg)
146 if err != nil {
147 return "", "", nil, fmt.Errorf("failed to find attachment targets for image %q: %w", imageArg, err)
148 }
149 if len(targets) == 0 {
150 return "", "", nil, fmt.Errorf("image %q is not configured as an attachment", imageArg)
151 }
152
153 status, err := cp.GetStatus(ctx)
154 if err != nil {
155 return "", "", nil, fmt.Errorf("failed to get status: %w", err)
156 }
157
158 return status.RegistryDomain, imageName, targets, nil
159}
160
161func normalizeAttachmentImageName(imageArg string) string {
162 name := imageArg
163 if idx := strings.Index(name, "@"); idx != -1 {
164 name = name[:idx]
165 }
166 if idx := strings.LastIndex(name, ":"); idx != -1 {
167 slashIdx := strings.LastIndex(name, "/")
168 if idx > slashIdx {
169 name = name[:idx]
170 }
171 }
172 parts := strings.SplitN(name, "/", 2)
173 if len(parts) == 2 {
174 host := parts[0]
175 if strings.ContainsAny(host, ".:") || host == "localhost" {
176 return parts[1]
177 }
178 }
179 return name
180}
181
182func formatAttachmentTargets(targets []string) string {
183 cloned := append([]string(nil), targets...)
184 sort.Strings(cloned)
185 return fmt.Sprintf("%v", cloned)
186}
187
188func resolveVersionWithFn(ctx context.Context, tag string) (string, error) {
189 version := determineVersionFn(ctx, tag)
190 if version != "latest" {
191 if err := validateVersionTag(version); err != nil {
192 return "", err
193 }
194 }
195 return version, nil
196}
197
198func validateVersionTag(version string) error {
199 if err := validation.ValidateReference(version); err != nil {
200 return fmt.Errorf("invalid version tag %q: %w", version, err)
201 }
202 return nil
203}