v2.31.0 e02f654

push.go RawHistoryFolder

1package cli
2
3import (
4	"bufio"
5	"context"
6	"errors"
7	"fmt"
8	"io"
9	"net/http"
10	"os"
11	"os/exec"
12	"path/filepath"
13	"regexp"
14	"strings"
15	"time"
16
17	tea "github.com/charmbracelet/bubbletea"
18	"github.com/spf13/cobra"
19
20	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
21	"github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
22	"github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
23	"github.com/bnema/gordon/internal/domain"
24	"github.com/bnema/gordon/pkg/validation"
25)
26
27var newImageOpsFn = newImageOpsFromFlags
28
29// buildConfig holds image build settings for push commands.
30type buildConfig struct {
31	Enabled    bool
32	Platform   string
33	Dockerfile string
34	BuildArgs  []string
35}
36
37// imagePush holds resolved image identity for push operations.
38type imagePush struct {
39	Registry   string
40	ImageName  string
41	Version    string
42	VersionRef string
43	LatestRef  string
44}
45
46// pushRequest holds all inputs for the push command.
47type pushRequest struct {
48	ImageArg  string
49	Domain    string
50	Tag       string
51	Build     buildConfig
52	NoDeploy  bool
53	NoConfirm bool
54}
55
56func newPushCmd() *cobra.Command {
57	var (
58		noDeploy   bool
59		noConfirm  bool
60		build      bool
61		platform   string
62		tag        string
63		dockerfile string
64		buildArgs  []string
65		domainFlag string
66	)
67
68	cmd := &cobra.Command{
69		Use:   "push [image]",
70		Short: "Tag, push, and optionally deploy an image",
71		Long: `Tags a local image for the Gordon registry and pushes it.
72Uses git tags for versioning. Optionally triggers deployment after push.
73
74Image-first syntax is primary:
75  - Positional args resolve routes by image name first
76  - Tagged positional refs still resolve routes by image name
77  - The pushed version comes from --tag, CI tag refs, or git describe
78  - Dotted positional refs fall back to legacy domain lookup if no image route exists
79  - --domain is a deploy target override for legacy workflows
80  - No-arg mode still auto-detects from Dockerfile labels or current directory name
81
82With --build, builds the image first using docker buildx.
83
84Examples:
85  gordon push myapp --build --remote ...
86  gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy --remote ...
87  gordon push registry.example.com/myapp:v1.2.3 --build --remote ...
88  gordon push --domain myapp.example.com --build --remote ...
89  gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,
90		Args: cobra.MaximumNArgs(1),
91		RunE: func(cmd *cobra.Command, args []string) error {
92			var imageArg string
93			if len(args) > 0 {
94				imageArg = args[0]
95			}
96			return runPush(cmd.Context(), cmd.OutOrStdout(), pushRequest{
97				ImageArg:  imageArg,
98				Domain:    domainFlag,
99				Tag:       tag,
100				Build:     buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
101				NoDeploy:  noDeploy,
102				NoConfirm: noConfirm,
103			})
104		},
105	}
106
107	cmd.Flags().BoolVar(&noDeploy, "no-deploy", false, "Push only, don't trigger deploy")
108	cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt")
109	cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
110	cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
111	cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
112	cmd.Flags().StringVar(&tag, "tag", "", "Override pushed version tag (default: CI tag ref or git describe)")
113	cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
114	cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
115
116	return cmd
117}
118
119func resolveDockerfile(dockerfile string, build bool) (string, error) {
120	if dockerfile != "" && !build {
121		return "", fmt.Errorf("--file can only be used with --build")
122	}
123	if dockerfile == "" {
124		return "Dockerfile", nil
125	}
126	return dockerfile, nil
127}
128
129func resolveImageRefs(registry, imageName, version string) (versionRef, latestRef string) {
130	versionRef = fmt.Sprintf("%s/%s:%s", registry, imageName, version)
131	latestRef = fmt.Sprintf("%s/%s:latest", registry, imageName)
132	return versionRef, latestRef
133}
134
135type pushArgKind string
136
137const (
138	pushArgKindImage        pushArgKind = "image"
139	pushArgKindLegacyDomain pushArgKind = "legacy-domain"
140)
141
142type classifiedPushArg struct {
143	kind         pushArgKind
144	lookupImage  string
145	legacyDomain string
146}
147
148func classifyPushArgument(arg string) classifiedPushArg {
149	if looksLikeLegacyDomain(arg) {
150		return classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: arg}
151	}
152
153	classified := classifiedPushArg{kind: pushArgKindImage, lookupImage: arg}
154	if parsedImage, ref := validation.ParseImageReference(arg); parsedImage != arg && !strings.HasPrefix(ref, "sha256:") {
155		classified.lookupImage = parsedImage
156	}
157	return classified
158}
159
160func looksLikeLegacyDomain(arg string) bool {
161	if arg == "" || strings.Contains(arg, "/") {
162		return false
163	}
164	host := arg
165	if idx := strings.Index(host, ":"); idx != -1 {
166		host = host[:idx]
167	}
168	return strings.Contains(host, ".")
169}
170
171// resolveRoute determines the registry, image name, and domain from the input mode.
172func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) {
173	if domainFlag != "" {
174		return resolveFromDomain(ctx, cp, domainFlag)
175	}
176
177	if imageArg == "" {
178		imageArg, err = detectImageName(dockerfile)
179		if err != nil {
180			return "", "", "", err
181		}
182		fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg))
183	}
184
185	if looksLikeLegacyDomain(imageArg) {
186		lookupImage := imageArg
187		domainLookupArg := imageArg
188		if parsedImage, ref := validation.ParseImageReference(imageArg); parsedImage != imageArg {
189			domainLookupArg = parsedImage
190			if !strings.HasPrefix(ref, "sha256:") {
191				lookupImage = parsedImage
192			}
193		}
194
195		registry, imageName, pushDomain, err = resolveFromImage(ctx, cp, lookupImage, dockerfile)
196		if err == nil {
197			return registry, imageName, pushDomain, nil
198		}
199		if !isImageBootstrapError(err) {
200			return "", "", "", err
201		}
202
203		registry, imageName, pushDomain, err = resolveFromDomain(ctx, cp, domainLookupArg)
204		if err == nil {
205			return registry, imageName, pushDomain, nil
206		}
207		if errors.Is(err, domain.ErrRouteNotFound) {
208			return "", "", "", noRouteForImageError(domainLookupArg)
209		}
210		return "", "", "", err
211	}
212
213	classified := classifyPushArgument(imageArg)
214
215	return resolveFromImage(ctx, cp, classified.lookupImage, dockerfile)
216}
217
218// resolveVersion determines and validates the version tag.
219func resolveVersion(ctx context.Context, tag string) (string, error) {
220	version := determineVersion(ctx, tag)
221	if version != "latest" {
222		if err := validation.ValidateReference(version); err != nil {
223			return "", fmt.Errorf("invalid version tag %q: %w", version, err)
224		}
225	}
226	return version, nil
227}
228
229func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
230	dockerfile, inferredRemote, handle, err := resolvePushTarget(ctx, out, req)
231	if err != nil {
232		return err
233	}
234	defer handle.close()
235
236	registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile)
237	if err != nil {
238		return err
239	}
240
241	version, err := resolveVersion(ctx, req.Tag)
242	if err != nil {
243		return err
244	}
245	if err := validateBuildArgsList(req.Build.BuildArgs); err != nil {
246		return err
247	}
248
249	img := imagePush{Registry: registry, ImageName: imageName, Version: version}
250	img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
251
252	imageOps, err := newPushImageOps(inferredRemote)
253	if err != nil {
254		return err
255	}
256
257	if err := cliWritef(out, "Image:  %s\n", styles.Theme.Bold.Render(img.VersionRef)); err != nil {
258		return err
259	}
260	if version != "latest" {
261		if err := cliWritef(out, "Also:   %s\n", styles.Theme.Bold.Render(img.LatestRef)); err != nil {
262			return err
263		}
264	}
265	if err := cliWritef(out, "Domain: %s\n", styles.Theme.Bold.Render(pushDomain)); err != nil {
266		return err
267	}
268
269	skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
270	build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs}
271	if err := pushResolvedImage(ctx, imageOps, build, img); err != nil {
272		return err
273	}
274
275	if err := cliWriteLine(out, styles.RenderSuccess("Push complete")); err != nil {
276		return err
277	}
278	if !skipExplicitDeploy {
279		return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
280	}
281	return nil
282}
283
284func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (dockerfile string, inferredRemote *remote.ResolvedRemote, handle *controlPlaneHandle, err error) {
285	dockerfile, err = resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
286	if err != nil {
287		return "", nil, nil, err
288	}
289	inferredRemote, err = inferPushRemote(ctx, req.ImageArg, req.Domain, dockerfile)
290	if err != nil {
291		return "", nil, nil, err
292	}
293	if inferredRemote != nil {
294		handle = newRemoteControlPlaneHandle(inferredRemote)
295		if err := cliWritef(out, "Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")); err != nil {
296			return "", nil, nil, err
297		}
298		return dockerfile, inferredRemote, handle, nil
299	}
300	handle, err = resolveControlPlane(configPath)
301	if err != nil {
302		return "", nil, nil, err
303	}
304	return dockerfile, nil, handle, nil
305}
306
307func validateBuildArgsList(buildArgs []string) error {
308	for _, ba := range buildArgs {
309		if err := validateBuildArg(ba); err != nil {
310			return err
311		}
312	}
313	return nil
314}
315
316func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error) {
317	if inferredRemote != nil {
318		return newImageOpsForResolvedRemote(inferredRemote)
319	}
320	return newImageOpsFn()
321}
322
323func pushResolvedImage(ctx context.Context, imageOps pushImageOps, build buildConfig, img imagePush) error {
324	if build.Enabled {
325		return buildAndPush(ctx, imageOps, build, img)
326	}
327	return tagAndPush(ctx, imageOps, img)
328}
329
330// shouldSkipDeploy signals deploy intent and returns whether the explicit deploy
331// should be skipped (either because --no-deploy was set, or the token lacks scope).
332func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string, noDeploy bool) bool {
333	// Always call DeployIntent to suppress server-side auto-deploy, even with --no-deploy.
334	// Without this, the server's event listener would still auto-deploy the pushed image.
335	if err := plane.DeployIntent(ctx, imageName); err != nil {
336		if isInsufficientScope(err) {
337			fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
338			return true
339		}
340		// Non-fatal: worst case we get a redundant deploy via event
341		fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
342	}
343	return noDeploy
344}
345
346// isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,
347// meaning the token lacks the required admin scope.
348func isInsufficientScope(err error) bool {
349	var httpErr *remote.HTTPError
350	return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusForbidden
351}
352
353// resolveFromDomain resolves image info from a domain name (legacy mode).
354func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) {
355	route, err := cp.GetRoute(ctx, pushDomain)
356	if err != nil {
357		return "", "", "", fmt.Errorf("failed to get route for domain %q: %w", pushDomain, err)
358	}
359
360	registry, imageName, _ = parseImageRef(route.Image)
361	if registry == "" || imageName == "" {
362		return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
363	}
364
365	return registry, imageName, pushDomain, nil
366}
367
368// resolveFromImage resolves domain(s) from an image name using the backend.
369func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile string) (registry, imageName, resolvedDomain string, err error) {
370	// First, query the backend to find routes for this image
371	routes, err := cp.FindRoutesByImage(ctx, imageArg)
372	if err != nil {
373		return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
374	}
375
376	routes = filterPreviewRoutes(routes)
377
378	if len(routes) == 0 {
379		return "", "", "", noRouteForImageError(imageArg)
380	}
381
382	// Pick the target domain
383	var targetRoute domain.Route
384	if len(routes) == 1 {
385		targetRoute = routes[0]
386	} else {
387		// Multiple domains: check Dockerfile labels first, then prompt
388		targetRoute, err = selectDomain(routes, dockerfile)
389		if err != nil {
390			return "", "", "", err
391		}
392	}
393
394	registry, imageName, _ = parseImageRef(targetRoute.Image)
395	if registry == "" || imageName == "" {
396		return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", targetRoute.Image)
397	}
398
399	return registry, imageName, targetRoute.Domain, nil
400}
401
402func isImageBootstrapError(err error) bool {
403	return errors.Is(err, domain.ErrNoRouteForImage)
404}
405
406func noRouteForImageError(imageArg string) error {
407	// bootstrap command is registered in bootstrap.go (see issue #98)
408	return noRouteForImageBootstrapError{imageArg: imageArg}
409}
410
411type noRouteForImageBootstrapError struct {
412	imageArg string
413}
414
415func (e noRouteForImageBootstrapError) Error() string {
416	return fmt.Sprintf(
417		"no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'\nIf this is an attachment image, use 'gordon attachments push %s'",
418		e.imageArg,
419		e.imageArg,
420		e.imageArg,
421		e.imageArg,
422	)
423}
424
425func (e noRouteForImageBootstrapError) Unwrap() error {
426	return domain.ErrNoRouteForImage
427}
428
429// selectDomain picks the target domain from multiple routes.
430// Checks Dockerfile labels first, then falls back to interactive selection.
431func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) {
432	// Try to resolve from Dockerfile labels
433	labels := parseDockerfileLabels(dockerfile)
434	labelDomain := labels[domain.LabelDomain]
435	labelDomains := labels[domain.LabelDomains]
436
437	// Collect domains from labels
438	var labelDomainList []string
439	if labelDomain != "" {
440		labelDomainList = append(labelDomainList, labelDomain)
441	}
442	if labelDomains != "" {
443		for d := range strings.SplitSeq(labelDomains, ",") {
444			d = strings.TrimSpace(d)
445			if d != "" {
446				labelDomainList = append(labelDomainList, d)
447			}
448		}
449	}
450
451	// Try to find a matching route from labels
452	if len(labelDomainList) > 0 {
453		routeMap := make(map[string]domain.Route, len(routes))
454		for _, r := range routes {
455			routeMap[r.Domain] = r
456		}
457		for _, ld := range labelDomainList {
458			if r, ok := routeMap[ld]; ok {
459				return r, nil
460			}
461		}
462	}
463
464	// Fall back to interactive selection
465	items := make([]string, 0, len(routes))
466	for _, r := range routes {
467		items = append(items, fmt.Sprintf("%s  %s", r.Domain, styles.Theme.Muted.Render(r.Image)))
468	}
469
470	selected, err := components.RunSelector(
471		"Multiple domains found for this image. Select target:",
472		items,
473		"",
474	)
475	if err != nil {
476		return domain.Route{}, fmt.Errorf("selection error: %w", err)
477	}
478	if selected == "" {
479		return domain.Route{}, fmt.Errorf("no domain selected")
480	}
481
482	// Extract domain from the selected display string
483	for i, item := range items {
484		if item == selected {
485			return routes[i], nil
486		}
487	}
488
489	return domain.Route{}, fmt.Errorf("selected domain not found")
490}
491
492// detectImageName auto-detects the image name from context.
493// Resolution order:
494// 1. Dockerfile label gordon.domain (if Dockerfile exists)
495// 2. Current directory name
496func detectImageName(dockerfile string) (string, error) {
497	// Try Dockerfile labels
498	labels := parseDockerfileLabels(dockerfile)
499	if d := labels[domain.LabelDomain]; d != "" {
500		// Strip the registry prefix if it looks like a full image ref
501		_, name, _ := parseImageRef(d)
502		if name != "" {
503			return name, nil
504		}
505		return d, nil
506	}
507
508	// Fall back to current directory name
509	cwd, err := os.Getwd()
510	if err != nil {
511		return "", fmt.Errorf("cannot detect image name: %w", err)
512	}
513
514	dirName := filepath.Base(cwd)
515	if dirName == "." || dirName == "/" {
516		return "", fmt.Errorf("cannot detect image name from current directory; provide an image name or use --domain")
517	}
518
519	return dirName, nil
520}
521
522// parseDockerfileLabels extracts LABEL instructions from a Dockerfile.
523// Returns a map of label key -> value. Only parses gordon.* labels.
524func parseDockerfileLabels(dockerfile string) map[string]string {
525	labels := make(map[string]string)
526
527	f, err := os.Open(dockerfile)
528	if err != nil {
529		return labels
530	}
531	defer f.Close()
532
533	scanner := bufio.NewScanner(f)
534	for scanner.Scan() {
535		line := strings.TrimSpace(scanner.Text())
536
537		// Skip comments and empty lines
538		if line == "" || strings.HasPrefix(line, "#") {
539			continue
540		}
541
542		// Match LABEL instructions
543		if !strings.HasPrefix(strings.ToUpper(line), "LABEL ") {
544			continue
545		}
546
547		// Parse "LABEL key=value" or "LABEL key=\"value\""
548		labelContent := strings.TrimSpace(line[6:])
549		for _, pair := range splitLabelPairs(labelContent) {
550			key, value, ok := parseLabelPair(pair)
551			if ok && strings.HasPrefix(key, "gordon.") {
552				labels[key] = value
553			}
554		}
555	}
556
557	return labels
558}
559
560// splitLabelPairs splits LABEL content into key=value pairs,
561// handling quoted values that may contain spaces.
562func splitLabelPairs(content string) []string {
563	var pairs []string
564	var current strings.Builder
565	inQuote := false
566	quoteChar := byte(0)
567
568	for i := 0; i < len(content); i++ {
569		ch := content[i]
570		if inQuote {
571			current.WriteByte(ch)
572			if ch == quoteChar {
573				inQuote = false
574			}
575		} else if ch == '"' || ch == '\'' {
576			inQuote = true
577			quoteChar = ch
578			current.WriteByte(ch)
579		} else if ch == ' ' || ch == '\t' {
580			if current.Len() > 0 {
581				pairs = append(pairs, current.String())
582				current.Reset()
583			}
584		} else {
585			current.WriteByte(ch)
586		}
587	}
588	if current.Len() > 0 {
589		pairs = append(pairs, current.String())
590	}
591
592	return pairs
593}
594
595// parseLabelPair parses a single "key=value" or "key=\"value\"" pair.
596func parseLabelPair(pair string) (key, value string, ok bool) {
597	key, value, ok = strings.Cut(pair, "=")
598	if !ok {
599		return "", "", false
600	}
601	// Strip surrounding quotes
602	value = strings.Trim(value, "\"'")
603	return key, value, true
604}
605
606func determineVersion(ctx context.Context, tag string) string {
607	version := tag
608	if version == "" {
609		version = versionFromTagRefs(os.Getenv)
610	}
611	if version == "" {
612		version = getGitVersion(ctx)
613	}
614	if version == "" {
615		version = "latest"
616	}
617	return version
618}
619
620func versionFromTagRefs(getenv func(string) string) string {
621	if ref := strings.TrimSpace(getenv("GITHUB_REF")); ref != "" {
622		if tag := parseTagRef(ref); tag != "" {
623			return tag
624		}
625	}
626
627	if strings.TrimSpace(getenv("GITHUB_REF_TYPE")) == "tag" {
628		if tag := strings.TrimSpace(getenv("GITHUB_REF_NAME")); tag != "" {
629			return tag
630		}
631	}
632
633	if tag := strings.TrimSpace(getenv("CI_COMMIT_TAG")); tag != "" {
634		return tag
635	}
636
637	if ref := strings.TrimSpace(getenv("BUILD_SOURCEBRANCH")); ref != "" {
638		if tag := parseTagRef(ref); tag != "" {
639			return tag
640		}
641	}
642
643	return ""
644}
645
646func parseTagRef(ref string) string {
647	ref = strings.TrimSpace(strings.TrimSuffix(ref, "^{}"))
648	if !strings.HasPrefix(ref, "refs/tags/") {
649		return ""
650	}
651	tag := strings.TrimPrefix(ref, "refs/tags/")
652	if tag == "" {
653		return ""
654	}
655	return tag
656}
657
658func buildAndPush(ctx context.Context, ops pushImageOps, build buildConfig, img imagePush) error {
659	if _, err := os.Stat(build.Dockerfile); os.IsNotExist(err) {
660		return fmt.Errorf("dockerfile not found: %s", build.Dockerfile)
661	}
662
663	// Build and load into local daemon (NOT --push).
664	// The native registry push client handles chunked uploads to stay
665	// within Cloudflare's 100MB per-request limit.
666	fmt.Println("\nBuilding image...")
667	if err := ops.Build(ctx, buildImageArgs(ctx, img.Version, build.Platform, build.Dockerfile, build.BuildArgs, img.VersionRef, img.LatestRef)); err != nil {
668		return err
669	}
670
671	fmt.Println("Pushing...")
672	if err := ops.Push(ctx, img.LatestRef); err != nil {
673		return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
674	}
675	if img.Version != "latest" {
676		if err := ops.Push(ctx, img.VersionRef); err != nil {
677			return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
678		}
679	}
680
681	return nil
682}
683
684// standardBuildArgs returns the standard set of git-related build args as
685// explicit KEY=VALUE pairs. User-supplied args are appended after and take
686// precedence (Docker uses the last occurrence of a duplicate key).
687func standardBuildArgs(ctx context.Context, version string) []string {
688	gitSHA := resolveGitSHA(ctx)
689	buildTime := time.Now().UTC().Format(time.RFC3339)
690	return []string{
691		"VERSION=" + version,
692		"GIT_TAG=" + version,
693		"GIT_SHA=" + gitSHA,
694		"BUILD_TIME=" + buildTime,
695	}
696}
697
698// resolveGitSHA returns the short git SHA of HEAD, or "unknown" if unavailable.
699func resolveGitSHA(ctx context.Context) string {
700	out, err := exec.CommandContext(ctx, "git", "rev-parse", "--short", "HEAD").Output() // #nosec G204
701	if err != nil {
702		return "unknown"
703	}
704	return strings.TrimSpace(string(out))
705}
706
707// buildImageArgs constructs the docker buildx build arguments.
708// Uses --load instead of --push so the image is loaded into the local
709// daemon, allowing docker push to handle the upload with chunked requests.
710func buildImageArgs(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string {
711	args := []string{
712		"buildx", "build",
713		"--platform", platform,
714		"-f", dockerfile,
715		"-t", latestRef,
716	}
717	if version != "latest" {
718		args = append(args, "-t", versionRef)
719	}
720
721	// Inject standard git build args as explicit KEY=VALUE pairs.
722	// User-supplied --build-arg flags are appended AFTER so they override defaults.
723	for _, ba := range standardBuildArgs(ctx, version) {
724		args = append(args, "--build-arg", ba)
725	}
726	for _, ba := range buildArgs {
727		args = append(args, "--build-arg", ba)
728	}
729
730	args = append(args, "--load", ".")
731	return args
732}
733
734func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
735	localImage := fmt.Sprintf("%s/%s", img.Registry, img.ImageName)
736
737	fmt.Println("\nChecking local image...")
738	exists, err := ops.Exists(ctx, localImage)
739	if err != nil {
740		return fmt.Errorf("failed to inspect local image %s: %w", localImage, err)
741	}
742	if !exists {
743		return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
744	}
745
746	fmt.Println("Tagging...")
747	if err := ops.Tag(ctx, localImage, img.VersionRef); err != nil {
748		return fmt.Errorf("failed to tag %s: %w", img.VersionRef, err)
749	}
750	if img.Version != "latest" {
751		if err := ops.Tag(ctx, localImage, img.LatestRef); err != nil {
752			return fmt.Errorf("failed to tag %s: %w", img.LatestRef, err)
753		}
754	}
755
756	fmt.Println("Pushing...")
757	if err := ops.Push(ctx, img.VersionRef); err != nil {
758		return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
759	}
760	if img.Version != "latest" {
761		if err := ops.Push(ctx, img.LatestRef); err != nil {
762			return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
763		}
764	}
765	return nil
766}
767
768func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, noConfirm bool) error {
769	if !noConfirm {
770		confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())
771		if err != nil {
772			return err
773		}
774		if !confirmed {
775			return nil
776		}
777	}
778
779	var (
780		result *remote.DeployResult
781		err    error
782	)
783	if remoteCP, ok := cp.(*remoteControlPlane); ok {
784		result, err = deployWithSpinner(ctx, remoteCP.client, pushDomain)
785	} else {
786		result, err = cp.Deploy(ctx, pushDomain)
787	}
788	if err != nil {
789		return formatDeployFailure(err)
790	}
791	containerID := shortContainerID(result.ContainerID)
792	fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID)))
793	return nil
794}
795
796func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain string) (*remote.DeployResult, error) {
797	if !isInteractiveTerminal() {
798		fmt.Printf("Deploying %s...\n", pushDomain)
799		return client.Deploy(ctx, pushDomain)
800	}
801
802	done := make(chan deployOutcome, 1)
803	go func() {
804		result, err := client.Deploy(ctx, pushDomain)
805		done <- deployOutcome{result: result, err: err}
806	}()
807
808	model := newDeploySpinnerModel(pushDomain, done)
809	final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
810	fmt.Print("\r\033[K")
811	if err != nil {
812		return nil, err
813	}
814
815	deployModel, ok := final.(deploySpinnerModel)
816	if !ok {
817		return nil, fmt.Errorf("spinner exited with unexpected model type %T", final)
818	}
819	if !deployModel.finished {
820		return nil, fmt.Errorf("deploy spinner exited before deploy result was received")
821	}
822
823	return deployModel.outcome.result, deployModel.outcome.err
824}
825
826type deployOutcome struct {
827	result *remote.DeployResult
828	err    error
829}
830
831type deployDoneMsg deployOutcome
832
833type deploySpinnerModel struct {
834	spinner  components.SpinnerModel
835	done     <-chan deployOutcome
836	outcome  deployOutcome
837	finished bool
838}
839
840func newDeploySpinnerModel(pushDomain string, done <-chan deployOutcome) deploySpinnerModel {
841	return deploySpinnerModel{
842		spinner: components.NewSpinner(
843			components.WithMessage(fmt.Sprintf("Deploying %s...", pushDomain)),
844			components.WithSpinnerType(components.SpinnerMiniDot),
845		),
846		done: done,
847	}
848}
849
850func (m deploySpinnerModel) Init() tea.Cmd {
851	return tea.Batch(m.spinner.Init(), waitForDeployDone(m.done))
852}
853
854func (m deploySpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
855	switch msg := msg.(type) {
856	case deployDoneMsg:
857		m.outcome = deployOutcome(msg)
858		m.finished = true
859		return m, tea.Quit
860	default:
861		updated, cmd := m.spinner.Update(msg)
862		spinnerModel, ok := updated.(components.SpinnerModel)
863		if ok {
864			m.spinner = spinnerModel
865		}
866		return m, cmd
867	}
868}
869
870func (m deploySpinnerModel) View() string {
871	return m.spinner.View()
872}
873
874func waitForDeployDone(done <-chan deployOutcome) tea.Cmd {
875	return func() tea.Msg {
876		return deployDoneMsg(<-done)
877	}
878}
879
880func isInteractiveTerminal() bool {
881	term := os.Getenv("TERM")
882	if term == "" || term == "dumb" {
883		return false
884	}
885	info, err := os.Stdout.Stat()
886	if err != nil {
887		return false
888	}
889	return (info.Mode() & os.ModeCharDevice) != 0
890}
891
892// parseImageRef splits "registry/name:tag" into components.
893func parseImageRef(image string) (registry, name, tag string) {
894	// Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"
895	parts := strings.SplitN(image, "/", 2)
896	if len(parts) != 2 {
897		return "", "", ""
898	}
899	registry = parts[0]
900	nameTag := parts[1]
901	if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
902		name = nameTag[:idx]
903		tag = nameTag[idx+1:]
904	} else {
905		name = nameTag
906		tag = "latest"
907	}
908	return
909}
910
911// getGitVersion returns git describe output, or empty string if unavailable.
912// When it falls back it prints a warning to stderr so the user knows the
913// image will be tagged "latest" rather than a real version.
914func getGitVersion(ctx context.Context) string {
915	out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output() // #nosec G204
916	if err != nil {
917		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)
918		return ""
919	}
920	return strings.TrimSpace(string(out))
921}
922
923// buildArgPattern matches KEY=VALUE where KEY starts with a letter or underscore.
924var buildArgPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*=.*$`)
925
926func validateBuildArg(arg string) error {
927	if !buildArgPattern.MatchString(arg) {
928		return fmt.Errorf("invalid build arg %q: must match KEY=VALUE where KEY is [a-zA-Z_][a-zA-Z0-9_]*", arg)
929	}
930	return nil
931}

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