v3.1.0 d676137

push.go RawHistoryFolder

1package cli
2
3import (
4	"bufio"
5	"context"
6	"errors"
7	"fmt"
8	"io"
9	"os"
10	"os/exec"
11	"path/filepath"
12	"regexp"
13	"strings"
14	"time"
15
16	"github.com/spf13/cobra"
17
18	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
19	"github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
20	"github.com/bnema/gordon/internal/domain"
21	"github.com/bnema/gordon/pkg/validation"
22)
23
24var newImageOpsFn = newImageOpsFromFlags
25
26// buildConfig holds image build settings for push commands.
27type buildConfig struct {
28	Enabled    bool
29	Platform   string
30	Dockerfile string
31	BuildArgs  []string
32}
33
34// imagePush holds resolved image identity for push operations.
35type imagePush struct {
36	Registry   string
37	ImageName  string
38	SourceRef  string
39	Version    string
40	VersionRef string
41	LatestRef  string
42}
43
44// pushRequest holds all inputs for the push command.
45type pushRequest struct {
46	ImageArg string
47	Tag      string
48	Build    buildConfig
49}
50
51func newPushCmd() *cobra.Command {
52	var (
53		build      bool
54		platform   string
55		tag        string
56		dockerfile string
57		buildArgs  []string
58	)
59
60	cmd := &cobra.Command{
61		Use:   "push [image]",
62		Short: "Tag and push an image to the Gordon registry",
63		Long: `Tags a local image for the Gordon registry and pushes it.
64Uses git tags for versioning. Push transfers OCI content only: it never
65deploys. Deploy separately with ` + "`gordon apps deploy`" + ` after applying
66the manifest that references the pushed tag.
67
68Examples:
69  gordon images push myapp --build --remote ...
70  gordon images push myapp:v1.2.3 --tag v1.2.3 --remote ...
71  gordon images push registry.example.com/myapp:v1.2.3 --build --remote ...
72  gordon images push --build --build-arg CGO_ENABLED=0 --remote ...`,
73		Args: cobra.MaximumNArgs(1),
74		RunE: func(cmd *cobra.Command, args []string) error {
75			var imageArg string
76			if len(args) > 0 {
77				imageArg = args[0]
78			}
79			return runPush(cmd.Context(), cmd.OutOrStdout(), pushRequest{
80				ImageArg: imageArg,
81				Tag:      tag,
82				Build:    buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
83			})
84		},
85	}
86
87	cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
88	cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
89	cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
90	cmd.Flags().StringVar(&tag, "tag", "", "Override pushed version tag (default: CI tag ref or git describe)")
91	cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
92
93	return cmd
94}
95
96func resolveDockerfile(dockerfile string, build bool) (string, error) {
97	if dockerfile != "" && !build {
98		return "", fmt.Errorf("--file can only be used with --build")
99	}
100	if dockerfile == "" {
101		return "Dockerfile", nil
102	}
103	return dockerfile, nil
104}
105
106func resolveImageRefs(registry, imageName, version string) (versionRef, latestRef string) {
107	versionRef = fmt.Sprintf("%s/%s:%s", registry, imageName, version)
108	latestRef = fmt.Sprintf("%s/%s:latest", registry, imageName)
109	return versionRef, latestRef
110}
111
112type pushArgKind string
113
114const (
115	pushArgKindImage        pushArgKind = "image"
116	pushArgKindLegacyDomain pushArgKind = "legacy-domain"
117)
118
119type classifiedPushArg struct {
120	kind         pushArgKind
121	sourceRef    string
122	repository   string
123	legacyDomain string
124}
125
126func classifyPushArgument(arg string) classifiedPushArg {
127	if looksLikeLegacyDomain(arg) {
128		return classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: arg}
129	}
130
131	_, repository, _ := parseImageRef(arg)
132	if repository == "" {
133		repository, _ = validation.ParseImageReference(arg)
134	}
135	return classifiedPushArg{kind: pushArgKindImage, sourceRef: arg, repository: repository}
136}
137
138func looksLikeLegacyDomain(arg string) bool {
139	if arg == "" || strings.Contains(arg, "/") {
140		return false
141	}
142	host := arg
143	if idx := strings.Index(host, ":"); idx != -1 {
144		host = host[:idx]
145	}
146	return strings.Contains(host, ".")
147}
148
149// resolveImageTarget determines the registry and image name from the input.
150// Push transfers OCI content only and never triggers deploys or route
151// lookups. The deploy target comes from the app manifest applied
152// separately via `gordon apps apply`.
153func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) {
154	if imageArg == "" {
155		imageArg, err = detectImageName(dockerfile)
156		if err != nil {
157			return "", "", "", err
158		}
159		if err := cliWritef(out, "Detected image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil {
160			return "", "", "", err
161		}
162	}
163	if looksLikeLegacyDomain(imageArg) {
164		return "", "", "", fmt.Errorf("domain-style push targets are retired: push an image name, then `gordon apps apply` (got %q)", imageArg)
165	}
166	classified := classifyPushArgument(imageArg)
167	registry, imageName, _ = parseImageRef(classified.sourceRef)
168	if registry == "" || imageName == "" {
169		return "", "", "", fmt.Errorf("cannot parse registry/image from %q", imageArg)
170	}
171	return registry, imageName, classified.sourceRef, nil
172}
173
174// resolveVersion determines and validates the version tag.
175func resolveVersion(ctx context.Context, tag string) (string, error) {
176	version := determineVersion(ctx, tag)
177	if version != "latest" {
178		if err := validation.ValidateReference(version); err != nil {
179			return "", fmt.Errorf("invalid version tag %q: %w", version, err)
180		}
181	}
182	return version, nil
183}
184
185func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
186	dockerfile, inferredRemote, handle, err := resolvePushTarget(ctx, out, req)
187	if err != nil {
188		return err
189	}
190	defer handle.close()
191
192	registry, imageName, sourceRef, err := resolveImageTarget(out, req.ImageArg, dockerfile)
193	if err != nil {
194		return err
195	}
196
197	version, err := resolveVersion(ctx, req.Tag)
198	if err != nil {
199		return err
200	}
201	if err := validateBuildArgsList(req.Build.BuildArgs); err != nil {
202		return err
203	}
204
205	img := imagePush{Registry: registry, ImageName: imageName, SourceRef: sourceRef, Version: version}
206	img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
207
208	imageOps, err := newPushImageOps(inferredRemote)
209	if err != nil {
210		return err
211	}
212
213	if err := cliWritef(out, "Image:  %s\n", styles.Theme.Bold.Render(img.VersionRef)); err != nil {
214		return err
215	}
216	if version != "latest" {
217		if err := cliWritef(out, "Also:   %s\n", styles.Theme.Bold.Render(img.LatestRef)); err != nil {
218			return err
219		}
220	}
221	build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs}
222	if err := pushResolvedImage(ctx, out, imageOps, build, img); err != nil {
223		return err
224	}
225
226	if err := cliWriteLine(out, styles.RenderSuccess("Push complete")); err != nil {
227		return err
228	}
229	return nil
230}
231
232func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (dockerfile string, inferredRemote *remote.ResolvedRemote, handle *controlPlaneHandle, err error) {
233	dockerfile, err = resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
234	if err != nil {
235		return "", nil, nil, err
236	}
237	inferredRemote, err = inferPushRemote(ctx, req.ImageArg, "", dockerfile)
238	if err != nil {
239		return "", nil, nil, err
240	}
241	if inferredRemote != nil {
242		handle = newRemoteControlPlaneHandle(inferredRemote)
243		if err := cliWritef(out, "Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")); err != nil {
244			return "", nil, nil, err
245		}
246		return dockerfile, inferredRemote, handle, nil
247	}
248	handle, err = resolveControlPlane(cliConfigPath)
249	if err != nil {
250		return "", nil, nil, err
251	}
252	return dockerfile, nil, handle, nil
253}
254
255func validateBuildArgsList(buildArgs []string) error {
256	for _, ba := range buildArgs {
257		if err := validateBuildArg(ba); err != nil {
258			return err
259		}
260	}
261	return nil
262}
263
264func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error) {
265	if inferredRemote != nil {
266		return newImageOpsForResolvedRemote(inferredRemote)
267	}
268	return newImageOpsFn()
269}
270
271func pushResolvedImage(ctx context.Context, out io.Writer, imageOps pushImageOps, build buildConfig, img imagePush) error {
272	if build.Enabled {
273		return buildAndPush(ctx, out, imageOps, build, img)
274	}
275	return tagAndPush(ctx, out, imageOps, img)
276}
277
278// detectImageName resolves the image name from Dockerfile labels or the
279// current directory name.
280func detectImageName(dockerfile string) (string, error) {
281	// Try Dockerfile labels
282	labels := parseDockerfileLabels(dockerfile)
283	if d := labels[domain.LabelDomain]; d != "" {
284		// Strip the registry prefix if it looks like a full image ref
285		_, name, _ := parseImageRef(d)
286		if name != "" {
287			return name, nil
288		}
289		return d, nil
290	}
291
292	// Fall back to current directory name
293	cwd, err := os.Getwd()
294	if err != nil {
295		return "", fmt.Errorf("cannot detect image name: %w", err)
296	}
297
298	dirName := filepath.Base(cwd)
299	if dirName == "." || dirName == "/" {
300		return "", fmt.Errorf("cannot detect image name from current directory; provide an image name")
301	}
302
303	return dirName, nil
304}
305
306// parseDockerfileLabels extracts LABEL instructions from a Dockerfile.
307// Returns a map of label key -> value. Only parses gordon.* labels.
308func parseDockerfileLabels(dockerfile string) map[string]string {
309	labels := make(map[string]string)
310
311	f, err := os.Open(dockerfile)
312	if err != nil {
313		return labels
314	}
315	defer f.Close()
316
317	scanner := bufio.NewScanner(f)
318	for scanner.Scan() {
319		line := strings.TrimSpace(scanner.Text())
320
321		// Skip comments and empty lines
322		if line == "" || strings.HasPrefix(line, "#") {
323			continue
324		}
325
326		// Match LABEL instructions
327		if !strings.HasPrefix(strings.ToUpper(line), "LABEL ") {
328			continue
329		}
330
331		// Parse "LABEL key=value" or "LABEL key=\"value\""
332		labelContent := strings.TrimSpace(line[6:])
333		for _, pair := range splitLabelPairs(labelContent) {
334			key, value, ok := parseLabelPair(pair)
335			if ok && strings.HasPrefix(key, "gordon.") {
336				labels[key] = value
337			}
338		}
339	}
340
341	return labels
342}
343
344// splitLabelPairs splits LABEL content into key=value pairs,
345// handling quoted values that may contain spaces.
346func splitLabelPairs(content string) []string {
347	var pairs []string
348	var current strings.Builder
349	inQuote := false
350	quoteChar := byte(0)
351
352	for i := 0; i < len(content); i++ {
353		ch := content[i]
354		if inQuote {
355			current.WriteByte(ch)
356			if ch == quoteChar {
357				inQuote = false
358			}
359		} else if ch == '"' || ch == '\'' {
360			inQuote = true
361			quoteChar = ch
362			current.WriteByte(ch)
363		} else if ch == ' ' || ch == '\t' {
364			if current.Len() > 0 {
365				pairs = append(pairs, current.String())
366				current.Reset()
367			}
368		} else {
369			current.WriteByte(ch)
370		}
371	}
372	if current.Len() > 0 {
373		pairs = append(pairs, current.String())
374	}
375
376	return pairs
377}
378
379// parseLabelPair parses a single "key=value" or "key=\"value\"" pair.
380func parseLabelPair(pair string) (key, value string, ok bool) {
381	key, value, ok = strings.Cut(pair, "=")
382	if !ok {
383		return "", "", false
384	}
385	// Strip surrounding quotes
386	value = strings.Trim(value, "\"'")
387	return key, value, true
388}
389
390func determineVersion(ctx context.Context, tag string) string {
391	version := tag
392	if version == "" {
393		version = versionFromTagRefs(os.Getenv)
394	}
395	if version == "" {
396		version = getGitVersion(ctx)
397	}
398	if version == "" {
399		version = "latest"
400	}
401	return version
402}
403
404func versionFromTagRefs(getenv func(string) string) string {
405	if ref := strings.TrimSpace(getenv("GITHUB_REF")); ref != "" {
406		if tag := parseTagRef(ref); tag != "" {
407			return tag
408		}
409	}
410
411	if strings.TrimSpace(getenv("GITHUB_REF_TYPE")) == "tag" {
412		if tag := strings.TrimSpace(getenv("GITHUB_REF_NAME")); tag != "" {
413			return tag
414		}
415	}
416
417	if tag := strings.TrimSpace(getenv("CI_COMMIT_TAG")); tag != "" {
418		return tag
419	}
420
421	if ref := strings.TrimSpace(getenv("BUILD_SOURCEBRANCH")); ref != "" {
422		if tag := parseTagRef(ref); tag != "" {
423			return tag
424		}
425	}
426
427	return ""
428}
429
430func parseTagRef(ref string) string {
431	ref = strings.TrimSpace(strings.TrimSuffix(ref, "^{}"))
432	if !strings.HasPrefix(ref, "refs/tags/") {
433		return ""
434	}
435	tag := strings.TrimPrefix(ref, "refs/tags/")
436	if tag == "" {
437		return ""
438	}
439	return tag
440}
441
442func buildAndPush(ctx context.Context, out io.Writer, ops pushImageOps, build buildConfig, img imagePush) error {
443	if _, err := os.Stat(build.Dockerfile); os.IsNotExist(err) {
444		return fmt.Errorf("dockerfile not found: %s", build.Dockerfile)
445	}
446
447	// Build and load into local daemon (NOT --push).
448	// The native registry push client handles chunked uploads to stay
449	// within Cloudflare's 100MB per-request limit.
450	if err := cliWriteLine(out, "\nBuilding image..."); err != nil {
451		return err
452	}
453	if err := ops.Build(ctx, buildImageArgs(ctx, img.Version, build.Platform, build.Dockerfile, build.BuildArgs, img.VersionRef, img.LatestRef)); err != nil {
454		return err
455	}
456
457	if err := cliWriteLine(out, "Pushing..."); err != nil {
458		return err
459	}
460	if err := ops.Push(ctx, img.LatestRef); err != nil {
461		return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
462	}
463	if img.Version != "latest" {
464		if err := ops.Push(ctx, img.VersionRef); err != nil {
465			return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
466		}
467	}
468
469	return nil
470}
471
472// standardBuildArgs returns the standard set of git-related build args as
473// explicit KEY=VALUE pairs. User-supplied args are appended after and take
474// precedence (Docker uses the last occurrence of a duplicate key).
475func standardBuildArgs(ctx context.Context, version string) []string {
476	gitSHA := resolveGitSHA(ctx)
477	buildTime := time.Now().UTC().Format(time.RFC3339)
478	return []string{
479		"VERSION=" + version,
480		"GIT_TAG=" + version,
481		"GIT_SHA=" + gitSHA,
482		"BUILD_TIME=" + buildTime,
483	}
484}
485
486// resolveGitSHA returns the short git SHA of HEAD, or "unknown" if unavailable.
487func resolveGitSHA(ctx context.Context) string {
488	out, err := exec.CommandContext(ctx, "git", "rev-parse", "--short", "HEAD").Output() // #nosec G204
489	if err != nil {
490		return "unknown"
491	}
492	return strings.TrimSpace(string(out))
493}
494
495// buildImageArgs constructs the docker buildx build arguments.
496// Uses --load instead of --push so the image is loaded into the local
497// daemon, allowing docker push to handle the upload with chunked requests.
498func buildImageArgs(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string {
499	args := []string{
500		"buildx", "build",
501		"--platform", platform,
502		"-f", dockerfile,
503		"-t", latestRef,
504	}
505	if version != "latest" {
506		args = append(args, "-t", versionRef)
507	}
508
509	// Inject standard git build args as explicit KEY=VALUE pairs.
510	// User-supplied --build-arg flags are appended AFTER so they override defaults.
511	for _, ba := range standardBuildArgs(ctx, version) {
512		args = append(args, "--build-arg", ba)
513	}
514	for _, ba := range buildArgs {
515		args = append(args, "--build-arg", ba)
516	}
517
518	args = append(args, "--load", ".")
519	return args
520}
521
522func tagAndPush(ctx context.Context, out io.Writer, ops pushImageOps, img imagePush) error {
523	localImage := img.SourceRef
524	if localImage == "" {
525		return errors.New("push source image reference cannot be empty")
526	}
527
528	if err := cliWriteLine(out, "\nChecking local image..."); err != nil {
529		return err
530	}
531	exists, err := ops.Exists(ctx, localImage)
532	if err != nil {
533		return fmt.Errorf("failed to inspect local image %s: %w", localImage, err)
534	}
535	if !exists {
536		return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
537	}
538
539	if err := cliWriteLine(out, "Tagging..."); err != nil {
540		return err
541	}
542	if err := ops.Tag(ctx, localImage, img.VersionRef); err != nil {
543		return fmt.Errorf("failed to tag %s: %w", img.VersionRef, err)
544	}
545	if img.Version != "latest" {
546		if err := ops.Tag(ctx, localImage, img.LatestRef); err != nil {
547			return fmt.Errorf("failed to tag %s: %w", img.LatestRef, err)
548		}
549	}
550
551	if err := cliWriteLine(out, "Pushing..."); err != nil {
552		return err
553	}
554	if err := ops.Push(ctx, img.VersionRef); err != nil {
555		return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
556	}
557	if img.Version != "latest" {
558		if err := ops.Push(ctx, img.LatestRef); err != nil {
559			return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
560		}
561	}
562	return nil
563}
564
565func isInteractiveTerminal() bool {
566	term := os.Getenv("TERM")
567	if term == "" || term == "dumb" {
568		return false
569	}
570	info, err := os.Stdout.Stat()
571	if err != nil {
572		return false
573	}
574	return (info.Mode() & os.ModeCharDevice) != 0
575}
576
577// parseImageRef splits "registry/name:tag" into components.
578func parseImageRef(image string) (registry, name, tag string) {
579	// Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"
580	parts := strings.SplitN(image, "/", 2)
581	if len(parts) != 2 {
582		return "", "", ""
583	}
584	registry = parts[0]
585	nameTag := parts[1]
586	if idx := strings.LastIndex(nameTag, "@"); idx != -1 {
587		return registry, nameTag[:idx], nameTag[idx+1:]
588	}
589	if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
590		name = nameTag[:idx]
591		tag = nameTag[idx+1:]
592	} else {
593		name = nameTag
594		tag = "latest"
595	}
596	return
597}
598
599// getGitVersion returns git describe output, or empty string if unavailable.
600// When it falls back it prints a warning to stderr so the user knows the
601// image will be tagged "latest" rather than a real version.
602func getGitVersion(ctx context.Context) string {
603	out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output() // #nosec G204
604	if err != nil {
605		fmt.Fprintf(os.Stderr, "Warning: unable to determine git tag (%v) — image version will be 'latest'. Tag your repo to get versioned images.\n", err)
606		return ""
607	}
608	return strings.TrimSpace(string(out))
609}
610
611// buildArgPattern matches KEY=VALUE where KEY starts with a letter or underscore.
612var buildArgPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*=.*$`)
613
614func validateBuildArg(arg string) error {
615	if !buildArgPattern.MatchString(arg) {
616		return fmt.Errorf("invalid build arg %q: must match KEY=VALUE where KEY is [a-zA-Z_][a-zA-Z0-9_]*", arg)
617	}
618	return nil
619}

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