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