v2.31.0-dev9 062dcae

push.go RawHistoryFolder

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

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