v2.14.0 5e11a00

push.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close