1package cli
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "os/exec"
8 "regexp"
9 "strings"
10
11 "github.com/spf13/cobra"
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/pkg/validation"
17)
18
19func newPushCmd() *cobra.Command {
20 var (
21 noDeploy bool
22 noConfirm bool
23 build bool
24 platform string
25 tag string
26 dockerfile string
27 buildArgs []string
28 )
29
30 cmd := &cobra.Command{
31 Use: "push <domain>",
32 Short: "Tag, push, and optionally deploy an image",
33 Long: `Tags a local image for the Gordon registry and pushes it.
34Uses git tags for versioning. Optionally triggers deployment after push.
35
36With --build, builds the image first using docker buildx.
37
38Examples:
39 gordon push myapp.example.com --remote ...
40 gordon push myapp.example.com --build --remote ...
41 gordon push myapp.example.com --tag v1.2.0 --no-deploy --remote ...
42 gordon push myapp.example.com --build --build-arg CGO_ENABLED=0 --remote ...`,
43 Args: cobra.ExactArgs(1),
44 RunE: func(cmd *cobra.Command, args []string) error {
45 return runPush(cmd.Context(), args[0], tag, build, platform, dockerfile, buildArgs, noDeploy, noConfirm)
46 },
47 }
48
49 cmd.Flags().BoolVar(&noDeploy, "no-deploy", false, "Push only, don't trigger deploy")
50 cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt")
51 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
52 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
53 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
54 cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)")
55 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
56
57 return cmd
58}
59
60func resolveDockerfile(dockerfile string, build bool) (string, error) {
61 if dockerfile != "" && !build {
62 return "", fmt.Errorf("--file can only be used with --build")
63 }
64 if dockerfile == "" {
65 return "Dockerfile", nil
66 }
67 return dockerfile, nil
68}
69
70func resolveImageRefs(registry, imageName, version string) (versionRef, latestRef string) {
71 versionRef = fmt.Sprintf("%s/%s:%s", registry, imageName, version)
72 latestRef = fmt.Sprintf("%s/%s:latest", registry, imageName)
73 return versionRef, latestRef
74}
75
76func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error {
77 client, isRemote := GetRemoteClient()
78 if !isRemote {
79 return fmt.Errorf("push requires remote mode")
80 }
81
82 dockerfile, err := resolveDockerfile(dockerfile, build)
83 if err != nil {
84 return err
85 }
86
87 route, err := client.GetRoute(ctx, pushDomain)
88 if err != nil {
89 return fmt.Errorf("failed to get route: %w", err)
90 }
91
92 registry, imageName, _ := parseImageRef(route.Image)
93 if registry == "" || imageName == "" {
94 return fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
95 }
96
97 version := determineVersion(ctx, tag)
98 if version != "latest" {
99 if err := validation.ValidateReference(version); err != nil {
100 return fmt.Errorf("invalid version tag %q: %w", version, err)
101 }
102 }
103
104 for _, ba := range buildArgs {
105 if err := validateBuildArg(ba); err != nil {
106 return err
107 }
108 }
109
110 versionRef, latestRef := resolveImageRefs(registry, imageName, version)
111
112 fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef))
113 if version != "latest" {
114 fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(latestRef))
115 }
116
117 if build {
118 if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil {
119 return err
120 }
121 } else {
122 if err := tagAndPush(ctx, registry, imageName, version, versionRef, latestRef); err != nil {
123 return err
124 }
125 }
126
127 fmt.Println(styles.RenderSuccess("Push complete"))
128
129 if !noDeploy {
130 return deployAfterPush(ctx, client, pushDomain, noConfirm)
131 }
132
133 return nil
134}
135
136func determineVersion(ctx context.Context, tag string) string {
137 version := tag
138 if version == "" {
139 version = getGitVersion(ctx)
140 }
141 if version == "" {
142 version = "latest"
143 }
144 return version
145}
146
147func buildAndPush(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) error {
148 if _, err := os.Stat(dockerfile); os.IsNotExist(err) {
149 return fmt.Errorf("dockerfile not found: %s", dockerfile)
150 }
151
152 // Build and load into local daemon (NOT --push).
153 // BuildKit's --push uses monolithic blob uploads that exceed
154 // Cloudflare's 100MB per-request limit. Loading locally then
155 // using docker push gives us chunked uploads (~5MB per request).
156 fmt.Println("\nBuilding image...")
157 buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204
158 buildCmd.Stdout = os.Stdout
159 buildCmd.Stderr = os.Stderr
160 if err := buildCmd.Run(); err != nil {
161 return fmt.Errorf("docker buildx build failed: %w", err)
162 }
163
164 fmt.Println("Pushing...")
165 if err := dockerPush(ctx, latestRef); err != nil {
166 return fmt.Errorf("failed to push %s: %w", latestRef, err)
167 }
168 if version != "latest" {
169 if err := dockerPush(ctx, versionRef); err != nil {
170 return fmt.Errorf("failed to push %s: %w", versionRef, err)
171 }
172 }
173
174 return nil
175}
176
177// buildImageArgs constructs the docker buildx build arguments.
178// Uses --load instead of --push so the image is loaded into the local
179// daemon, allowing docker push to handle the upload with chunked requests.
180func buildImageArgs(version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string {
181 args := []string{
182 "buildx", "build",
183 "--platform", platform,
184 "-f", dockerfile,
185 "-t", latestRef,
186 "--build-arg", "VERSION=" + version,
187 }
188 if version != "latest" {
189 args = append(args, "-t", versionRef)
190 }
191 for _, ba := range buildArgs {
192 args = append(args, "--build-arg", ba)
193 }
194 args = append(args, "--load", ".")
195 return args
196}
197
198func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, latestRef string) error {
199 localImage := fmt.Sprintf("%s/%s", registry, imageName)
200
201 fmt.Println("\nChecking local image...")
202 if !dockerImageExists(ctx, localImage) {
203 return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
204 }
205
206 fmt.Println("Tagging...")
207 if err := dockerTag(ctx, localImage, versionRef); err != nil {
208 return fmt.Errorf("failed to tag %s: %w", versionRef, err)
209 }
210 if version != "latest" {
211 if err := dockerTag(ctx, localImage, latestRef); err != nil {
212 return fmt.Errorf("failed to tag %s: %w", latestRef, err)
213 }
214 }
215
216 fmt.Println("Pushing...")
217 if err := dockerPush(ctx, versionRef); err != nil {
218 return fmt.Errorf("failed to push %s: %w", versionRef, err)
219 }
220 if version != "latest" {
221 if err := dockerPush(ctx, latestRef); err != nil {
222 return fmt.Errorf("failed to push %s: %w", latestRef, err)
223 }
224 }
225 return nil
226}
227
228func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain string, noConfirm bool) error {
229 if !noConfirm {
230 confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())
231 if err != nil {
232 return err
233 }
234 if !confirmed {
235 return nil
236 }
237 }
238
239 result, err := client.Deploy(ctx, pushDomain)
240 if err != nil {
241 return fmt.Errorf("failed to deploy: %w", err)
242 }
243 containerID := result.ContainerID
244 if len(containerID) > 12 {
245 containerID = containerID[:12]
246 }
247 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID)))
248 return nil
249}
250
251// parseImageRef splits "registry/name:tag" into components.
252func parseImageRef(image string) (registry, name, tag string) {
253 // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"
254 parts := strings.SplitN(image, "/", 2)
255 if len(parts) != 2 {
256 return "", "", ""
257 }
258 registry = parts[0]
259 nameTag := parts[1]
260 if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
261 name = nameTag[:idx]
262 tag = nameTag[idx+1:]
263 } else {
264 name = nameTag
265 tag = "latest"
266 }
267 return
268}
269
270// getGitVersion returns the latest git tag, or empty string if unavailable.
271func getGitVersion(ctx context.Context) string {
272 out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--abbrev=0").Output()
273 if err != nil {
274 return ""
275 }
276 return strings.TrimSpace(string(out))
277}
278
279func dockerTag(ctx context.Context, src, dst string) error {
280 cmd := exec.CommandContext(ctx, "docker", "tag", src, dst)
281 cmd.Stdout = os.Stdout
282 cmd.Stderr = os.Stderr
283 return cmd.Run()
284}
285
286func dockerPush(ctx context.Context, ref string) error {
287 cmd := exec.CommandContext(ctx, "docker", "push", ref)
288 cmd.Stdout = os.Stdout
289 cmd.Stderr = os.Stderr
290 return cmd.Run()
291}
292
293func dockerImageExists(ctx context.Context, image string) bool {
294 cmd := exec.CommandContext(ctx, "docker", "image", "inspect", image) // #nosec G204
295 cmd.Stdout = nil
296 cmd.Stderr = nil
297 return cmd.Run() == nil
298}
299
300// buildArgPattern matches KEY=VALUE where KEY starts with a letter or underscore.
301var buildArgPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*=.*$`)
302
303func validateBuildArg(arg string) error {
304 if !buildArgPattern.MatchString(arg) {
305 return fmt.Errorf("invalid build arg %q: must match KEY=VALUE where KEY is [a-zA-Z_][a-zA-Z0-9_]*", arg)
306 }
307 return nil
308}