v2.11.0 dca6537

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		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}

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