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}