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}