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