From aabeaaf7bb1d6b247b1656d4927e7a7d9ef3ef0d Mon Sep 17 00:00:00 2001 From: "OP (oppenheimer)" <21008429+Ompragash@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:55:18 +0530 Subject: [PATCH] feat: [CI-20527]: add push-only mode to skip build and push pre-existing images (#500) * Add push-only support * Include support for PLUGIN_NO_PUSH as well --- card.go | 14 ++- cmd/drone-docker/main.go | 14 ++- docker.go | 193 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 216 insertions(+), 5 deletions(-) diff --git a/card.go b/card.go index 0a66a43..5b8cd6b 100644 --- a/card.go +++ b/card.go @@ -17,8 +17,14 @@ import ( "github.com/inhies/go-bytesize" ) +// writeCard maintains backward compatibility by using TempTag func (p Plugin) writeCard() error { - cmd := exec.Command(dockerExe, "inspect", p.Build.TempTag) + return p.writeCardForImage(p.Build.TempTag) +} + +// writeCardForImage generates card for any image reference +func (p Plugin) writeCardForImage(imageRef string) error { + cmd := exec.Command(dockerExe, "inspect", imageRef) data, err := cmd.CombinedOutput() if err != nil { return err @@ -38,7 +44,11 @@ func (p Plugin) writeCard() error { for _, tag := range inspect.RepoTags { sliceTagStruct = append(sliceTagStruct, TagStruct{Tag: tag}) } - inspect.ParsedRepoTags = sliceTagStruct[1:] // remove the first tag which is always "hash:latest" + if len(sliceTagStruct) > 1 { + inspect.ParsedRepoTags = sliceTagStruct[1:] // remove the first tag which is always "hash:latest" + } else { + inspect.ParsedRepoTags = sliceTagStruct + } // create the url from repo and registry inspect.URL = mapRegistryToURL(p.Daemon.Registry, p.Build.Repo) cardData, _ := json.Marshal(inspect) diff --git a/cmd/drone-docker/main.go b/cmd/drone-docker/main.go index dd6a3ee..742d4a1 100644 --- a/cmd/drone-docker/main.go +++ b/cmd/drone-docker/main.go @@ -33,7 +33,7 @@ func main() { cli.BoolFlag{ Name: "dry-run", Usage: "dry run disables docker push", - EnvVar: "PLUGIN_DRY_RUN", + EnvVar: "PLUGIN_DRY_RUN, PLUGIN_NO_PUSH", }, cli.StringFlag{ Name: "remote.url", @@ -339,6 +339,16 @@ func main() { Usage: "additional cosign parameters (e.g., annotations, flags)", EnvVar: "PLUGIN_COSIGN_PARAMS", }, + cli.BoolFlag{ + Name: "push-only", + Usage: "skip build and only push images", + EnvVar: "PLUGIN_PUSH_ONLY", + }, + cli.StringFlag{ + Name: "source-image", + Usage: "source image to tag and push (format: repo:tag)", + EnvVar: "PLUGIN_SOURCE_IMAGE", + }, } if err := app.Run(os.Args); err != nil { @@ -419,6 +429,8 @@ func run(c *cli.Context) error { Password: c.String("cosign.password"), Params: c.String("cosign.params"), }, + PushOnly: c.Bool("push-only"), + SourceImage: c.String("source-image"), } if c.Bool("tags.auto") { diff --git a/docker.go b/docker.go index 88ee0b2..b7068a6 100644 --- a/docker.go +++ b/docker.go @@ -96,6 +96,8 @@ type ( BaseImageRegistry string // Docker registry to pull base image BaseImageUsername string // Docker registry username to pull base image BaseImagePassword string // Docker registry password to pull base image + PushOnly bool // Push only mode, skips build process + SourceImage string // Source image to push (optional) } Card []struct { @@ -201,7 +203,8 @@ func (p Plugin) Exec() error { fmt.Println(out) return fmt.Errorf("Error authenticating base connector: exit status 1") } - } else { + } else if !p.PushOnly { + // Skip base image connector warning in push-only mode (not pulling anything) fmt.Println("\033[33mTo ensure consistent and reliable pipeline execution, we recommend setting up a Base Image Connector.\033[0m\n" + "\033[33mWhile optional at this time, configuring it helps prevent failures caused by Docker Hub's rate limits.\033[0m") } @@ -229,6 +232,16 @@ func (p Plugin) Exec() error { } } + // Enforce mutual exclusivity: push-only and dry-run cannot be used together + if p.PushOnly && p.Dryrun { + return fmt.Errorf("conflict: push-only and dry-run cannot be used together") + } + + // Handle push-only mode if requested + if p.PushOnly { + return p.pushOnly() + } + if p.Build.Squash && !p.Daemon.Experimental { fmt.Println("Squash build flag is only available when Docker deamon is started with experimental flag. Ignoring...") p.Build.Squash = false @@ -742,6 +755,22 @@ func getDigest(buildName string) (string, error) { return "", errors.New("unable to fetch digest") } +// imageExists checks if an image exists in local daemon +func imageExists(tag string) bool { + cmd := exec.Command(dockerExe, "image", "inspect", tag) + return cmd.Run() == nil +} + +// getDigestAfterPush gets digest from a pushed image +func getDigestAfterPush(tag string) (string, error) { + cmd := exec.Command(dockerExe, "inspect", "--format", "{{ index (split (index .RepoDigests 0) \"@\") 1 }}", tag) + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get digest for %s: %w", tag, err) + } + return strings.TrimSpace(string(output)), nil +} + // shouldSignWithCosign determines if cosign signing should be performed func (p Plugin) shouldSignWithCosign() bool { return p.Cosign.PrivateKey != "" @@ -837,7 +866,7 @@ func executeCosignCommand(cmd *exec.Cmd) { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr fmt.Printf("🚀 Executing: %s %s\n", cmd.Path, strings.Join(cmd.Args[1:], " ")) - + if err := cmd.Run(); err != nil { fmt.Printf("⚠️ WARNING: Image signing failed: %s\n", err) fmt.Printf(" Image was pushed successfully but could not be signed\n") @@ -845,4 +874,164 @@ func executeCosignCommand(cmd *exec.Cmd) { } } +// pushOnly handles pushing images without building them +func (p Plugin) pushOnly() error { + // Check if source image is specified + sourceImageName := p.SourceImage + var sourceTags []string + if sourceImageName == "" { + // If no source image specified, use the repo and first tag + fmt.Println("source_image not provided, using repo and tag value") + sourceImageName = p.Build.Repo + sourceTags = p.Build.Tags + } else { + // If source image is specified, check if it has a tag + lastColonIndex := strings.LastIndex(sourceImageName, ":") + if lastColonIndex > 0 && lastColonIndex < len(sourceImageName) { + // Check if there's a slash after the last colon (indicating it's a port, not a tag) + // For example: registry:5000/image (has slash after colon - port not tag) + // vs image:tag (no slash after colon - it's a tag) + if strings.LastIndex(sourceImageName, "/") > lastColonIndex { + // The last colon is part of the registry:port, not a tag separator + sourceTags = []string{"latest"} + } else { + // The last colon separates the tag + tag := sourceImageName[lastColonIndex+1:] + sourceImageName = sourceImageName[:lastColonIndex] + + if tag == "" { + fmt.Printf("No tag specified in source image (or empty tag). Using 'latest' as the default tag.\n") + tag = "latest" + } + sourceTags = []string{tag} + } + } else { + // Default to "latest" if no tag specified + sourceTags = []string{"latest"} + } + fmt.Printf("Using source image: %s with tag(s): %s\n", sourceImageName, strings.Join(sourceTags, ", ")) + } + + // For each source tag and target tag combination + var digest string + var firstPushedImage string + + for _, sourceTag := range sourceTags { + sourceFullImageName := fmt.Sprintf("%s:%s", sourceImageName, sourceTag) + + // Check if the source image exists in local daemon + if !imageExists(sourceFullImageName) { + fmt.Printf("Warning: Source image %s not found\n", sourceFullImageName) + // Continue to the next source tag if available, otherwise return error + if len(sourceTags) > 1 { + continue + } + return fmt.Errorf("source image %s not found, cannot push", sourceFullImageName) + } + + // For each target tag, tag and push + for _, targetTag := range p.Build.Tags { + targetFullImageName := fmt.Sprintf("%s:%s", p.Build.Repo, targetTag) + + // Skip if source and target are identical + if sourceFullImageName == targetFullImageName { + fmt.Printf("Source and target image names are identical: %s\n", sourceFullImageName) + } else { + // Tag the source image with the target name + fmt.Printf("Tagging %s as %s\n", sourceFullImageName, targetFullImageName) + tagCmd := exec.Command(dockerExe, "tag", sourceFullImageName, targetFullImageName) + tagCmd.Stdout = os.Stdout + tagCmd.Stderr = os.Stderr + trace(tagCmd) + if err := tagCmd.Run(); err != nil { + return fmt.Errorf("failed to tag image %s as %s: %w", sourceFullImageName, targetFullImageName, err) + } + } + } + } + + // Push all target images + for _, tag := range p.Build.Tags { + fullImageName := fmt.Sprintf("%s:%s", p.Build.Repo, tag) + + // Check if image exists in local daemon + if !imageExists(fullImageName) { + return fmt.Errorf("image %s not found, cannot push", fullImageName) + } + + // Push image + fmt.Println("Pushing image:", fullImageName) + pushCmd := commandPush(p.Build, tag) + pushCmd.Stdout = os.Stdout + pushCmd.Stderr = os.Stderr + trace(pushCmd) + if err := pushCmd.Run(); err != nil { + return fmt.Errorf("failed to push image %s: %w", fullImageName, err) + } + + // Track the first pushed image for card generation + if firstPushedImage == "" { + firstPushedImage = fullImageName + } + + // Get the digest after push (we only need one) + if digest == "" { + d, err := getDigestAfterPush(fullImageName) + if err == nil { + digest = d + } else { + fmt.Printf("Warning: Could not get digest for %s: %v\n", fullImageName, err) + } + } + } + + // Output the adaptive card + if firstPushedImage != "" { + if err := p.writeCardForImage(firstPushedImage); err != nil { + fmt.Printf("Could not create adaptive card. %s\n", err) + } + } + + // Write to artifact file + if p.ArtifactFile != "" && digest != "" { + if err := drone.WritePluginArtifactFile( + p.Daemon.RegistryType, + p.ArtifactFile, + p.Daemon.Registry, + p.Build.Repo, + digest, + p.Build.Tags, + ); err != nil { + fmt.Printf("Failed to write plugin artifact file at path: %s with error: %s\n", + p.ArtifactFile, err) + } + } + + // Handle cosign signing after push + if p.shouldSignWithCosign() { + // Set up environment variables for cosign + os.Setenv("COSIGN_YES", "true") + + if digest != "" { + fmt.Printf("🔐 Found image digest: %s\n", digest) + + // Sign with digest reference + imageRef := fmt.Sprintf("%s@%s", p.Build.Repo, digest) + cosignCmd := createCosignCommand(imageRef, p.Cosign) + executeCosignCommand(cosignCmd) + } else { + fmt.Printf("⚠️ WARNING: Could not get image digest for cosign signing\n") + fmt.Printf(" Falling back to tag-based signing\n") + + // Fall back to tag-based signing for each tag + for _, tag := range p.Build.Tags { + imageRef := fmt.Sprintf("%s:%s", p.Build.Repo, tag) + cosignCmd := createCosignCommand(imageRef, p.Cosign) + executeCosignCommand(cosignCmd) + } + } + } + + return nil +}