Skip to content

Commit

Permalink
feat(contrib): docker-push plugin (#6813)
Browse files Browse the repository at this point in the history
  • Loading branch information
fsamin authored Feb 2, 2024
1 parent 50e8a98 commit 149547d
Show file tree
Hide file tree
Showing 25 changed files with 774 additions and 18 deletions.
18 changes: 18 additions & 0 deletions contrib/grpcplugins/action/docker-push/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
PLUGIN_NAME = docker-push
TARGET_NAME = docker-push

##### ^^^^^^ EDIT ABOVE ^^^^^^ #####

include ../../../../.build/core.mk
include ../../../../.build/go.mk
include ../../../../.build/plugin.mk

build: mk_go_build_plugin ## build action plugin and prepare configuration for publish

clean: mk_go_clean ## clean binary and tests results

test: mk_go_test ## run unit tests

publish: mk_v2_plugin_publish ## publish the plugin on CDS. This use your cdsctl default context and commands cdsctl admin plugins import / binary-add.

package: mk_plugin_package ## prepare the tar.gz file, with all binaries / conf files
34 changes: 34 additions & 0 deletions contrib/grpcplugins/action/docker-push/docker-push.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: docker-push
type: action
author: "François SAMIN <francois.samin@corp.ovh.com>"
description: |
This pushes Docker image
inputs:
image:
type: string
description: Image name
required: true
tags:
type: string
description: |-
The tags to associate with the image on the registry.
This parameter can be empty if you want to keep the same tag.
required: false
registry:
type: string
description: |-
Docker registry to push on.
This parameter can be empty when an Artifactory integration is set up.
required: false
registryAuth:
type: string
description: |-
Docker base64url-encoded auth configuration.
See docker authentication section for more details: https://docs.docker.com/engine/api/v1.41/#section/Authentication.
This parameter can be empty when an Artifactory integration is set up.
required: false

315 changes: 315 additions & 0 deletions contrib/grpcplugins/action/docker-push/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
package main

import (
"context"
"encoding/base64"
"encoding/json"
"net/url"
"os"
"strings"
"time"

"github.com/docker/cli/cli/streams"
"github.com/docker/docker/api/types"
"github.com/docker/docker/pkg/jsonmessage"
"github.com/docker/go-units"
"github.com/golang/protobuf/ptypes/empty"
"github.com/moby/moby/client"
"github.com/pkg/errors"

"github.com/ovh/cds/contrib/grpcplugins"
"github.com/ovh/cds/engine/worker/pkg/workerruntime"
"github.com/ovh/cds/sdk"
"github.com/ovh/cds/sdk/grpcplugin/actionplugin"
)

type dockerPushPlugin struct {
actionplugin.Common
}

func main() {
actPlugin := dockerPushPlugin{}
if err := actionplugin.Start(context.Background(), &actPlugin); err != nil {
panic(err)
}
}

func (actPlugin *dockerPushPlugin) Manifest(_ context.Context, _ *empty.Empty) (*actionplugin.ActionPluginManifest, error) {
return &actionplugin.ActionPluginManifest{
Name: "docker-push",
Author: "François SAMIN <francois.samin@corp.ovh.com>",
Description: "Push an image docker on a docker registry",
Version: sdk.VERSION,
}, nil
}

// Run implements actionplugin.ActionPluginServer.
func (actPlugin *dockerPushPlugin) Run(ctx context.Context, q *actionplugin.ActionQuery) (*actionplugin.ActionResult, error) {
res := &actionplugin.ActionResult{
Status: sdk.StatusSuccess,
}

image := q.GetOptions()["image"]
tags := q.GetOptions()["tags"]
registry := q.GetOptions()["registry"]
auth := q.GetOptions()["registryAuth"]

tags = strings.Replace(tags, " ", ",", -1) // If tags are separated by <space>
tags = strings.Replace(tags, ";", ",", -1) // If tags are separated by <semicolon>
tagSlice := strings.Split(tags, ",")

if err := actPlugin.perform(ctx, image, tagSlice, registry, auth); err != nil {
res.Status = sdk.StatusFail
res.Status = err.Error()
return res, err
}

return res, nil
}

type img struct {
repository string
tag string
imageID string
created string
size string
}

func (actPlugin *dockerPushPlugin) perform(ctx context.Context, image string, tags []string, registry, registryAuth string) error {
if image == "" {
return sdk.Errorf("wrong usage: <image> parameter should not be empty")
}

cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return sdk.Errorf("unable to get instanciate docker client: %v", err)
}

imageSummaries, err := cli.ImageList(ctx, types.ImageListOptions{All: true})
if err != nil {
return sdk.Errorf("unable to get docker image %q: %v", image, err)
}

images := []img{}
for _, image := range imageSummaries {
repository := "<none>"
tag := "<none>"
if len(image.RepoTags) > 0 {
splitted := strings.Split(image.RepoTags[0], ":")
repository = splitted[0]
tag = splitted[1]
} else if len(image.RepoDigests) > 0 {
repository = strings.Split(image.RepoDigests[0], "@")[0]
}
duration := HumanDuration(image.Created)
size := HumanSize(image.Size)
images = append(images, img{repository: repository, tag: tag, imageID: image.ID[7:19], created: duration, size: size})
}

var imgFound *img
for i := range images {
grpcplugins.Logf("image %s:%s", images[i].repository, images[i].tag)
if images[i].repository+":"+images[i].tag == image {
imgFound = &images[i]
break
}
}

if imgFound == nil {
return sdk.Errorf("image %q not found", image)
}

if len(tags) == 0 { // If no tag is provided, keep the actual tag
tags = []string{imgFound.tag}
}

for _, tag := range tags {
result, d, err := actPlugin.performImage(ctx, cli, image, imgFound, registry, registryAuth, strings.TrimSpace(tag))
if err != nil {
grpcplugins.Error(err.Error())
return err
}
grpcplugins.Logf("Image %s pushed in %.3fs", result.Name(), d.Seconds())
}

return nil
}

func (actPlugin *dockerPushPlugin) performImage(ctx context.Context, cli *client.Client, source string, img *img, registry string, registryAuth string, tag string) (*sdk.V2WorkflowRunResult, time.Duration, error) {
var t0 = time.Now()

// Create run result at status "pending"
var runResultRequest = workerruntime.V2RunResultRequest{
RunResult: &sdk.V2WorkflowRunResult{
IssuedAt: time.Now(),
Type: sdk.V2WorkflowRunResultTypeDocker,
Status: sdk.V2WorkflowRunResultStatusPending,
Detail: sdk.V2WorkflowRunResultDetail{
Data: sdk.V2WorkflowRunResultDockerDetail{
Name: source,
ID: img.imageID,
HumanSize: img.size,
HumanCreated: img.created,
},
},
},
}

response, err := grpcplugins.CreateRunResult(ctx, &actPlugin.Common, &runResultRequest)
if err != nil {
return nil, time.Since(t0), err
}

result := response.RunResult

var destination string
// Upload the file to an artifactory or the docker registry
switch {
case result.ArtifactManagerIntegrationName != nil:
integration, err := grpcplugins.GetIntegrationByName(ctx, &actPlugin.Common, *response.RunResult.ArtifactManagerIntegrationName)
if err != nil {
return nil, time.Since(t0), err
}

repository := integration.Config[sdk.ArtifactoryConfigRepositoryPrefix].Value + "-docker"
rtURLRaw := integration.Config[sdk.ArtifactoryConfigURL].Value
if !strings.HasSuffix(rtURLRaw, "/") {
rtURLRaw = rtURLRaw + "/"
}
rtURL, err := url.Parse(rtURLRaw)
if err != nil {
return nil, time.Since(t0), err
}

destination = repository + "." + rtURL.Host + "/" + img.repository + ":" + tag

result.Detail.Data = sdk.V2WorkflowRunResultDockerDetail{
Name: destination,
ID: img.imageID,
HumanSize: img.size,
HumanCreated: img.created,
}

if tag != img.tag { // if the image already has the right tag, nothing to do
if err := cli.ImageTag(ctx, img.imageID, destination); err != nil {
return nil, time.Since(t0), errors.Errorf("unable to tag %q to %q: %v", source, destination, err)
}
}

auth := types.AuthConfig{
Username: integration.Config[sdk.ArtifactoryConfigTokenName].Value,
Password: integration.Config[sdk.ArtifactoryConfigToken].Value,
ServerAddress: repository + "." + rtURL.Host,
}
buf, _ := json.Marshal(auth)
registryAuth = base64.URLEncoding.EncodeToString(buf)

output, err := cli.ImagePush(ctx, destination, types.ImagePushOptions{RegistryAuth: registryAuth})
if err != nil {
return nil, time.Since(t0), errors.Errorf("unable to push %q: %v", destination, err)
}

if err := jsonmessage.DisplayJSONMessagesToStream(output, streams.NewOut(os.Stdout), nil); err != nil {
return nil, time.Since(t0), errors.Errorf("unable to push %q: %v", destination, err)
}

var rtConfig = grpcplugins.ArtifactoryConfig{
URL: rtURL.String(),
Token: integration.Config[sdk.ArtifactoryConfigToken].Value,
}

rtFolderPath := img.repository + "/" + tag
rtFolderPathInfo, err := grpcplugins.GetArtifactoryFolderInfo(ctx, &actPlugin.Common, rtConfig, repository, rtFolderPath)
if err != nil {
return nil, time.Since(t0), err
}

var manifestFound bool
for _, child := range rtFolderPathInfo.Children {
if strings.HasSuffix(child.URI, "manifest.json") { // Can be manifest.json of list.manifest.json for multi-arch docker image
rtPathInfo, err := grpcplugins.GetArtifactoryFileInfo(ctx, &actPlugin.Common, rtConfig, repository, rtFolderPath+child.URI)
if err != nil {
return nil, time.Since(t0), err
}
manifestFound = true
result.ArtifactManagerMetadata = &sdk.V2WorkflowRunResultArtifactManagerMetadata{}
result.ArtifactManagerMetadata.Set("repository", repository) // This is the virtual repository
result.ArtifactManagerMetadata.Set("type", "docker")
result.ArtifactManagerMetadata.Set("maturity", integration.Config[sdk.ArtifactoryConfigPromotionLowMaturity].Value)
result.ArtifactManagerMetadata.Set("name", destination)
result.ArtifactManagerMetadata.Set("path", rtPathInfo.Path)
result.ArtifactManagerMetadata.Set("md5", rtPathInfo.Checksums.Md5)
result.ArtifactManagerMetadata.Set("sha1", rtPathInfo.Checksums.Sha1)
result.ArtifactManagerMetadata.Set("sha256", rtPathInfo.Checksums.Sha256)
result.ArtifactManagerMetadata.Set("uri", rtPathInfo.URI)
result.ArtifactManagerMetadata.Set("mimeType", rtPathInfo.MimeType)
result.ArtifactManagerMetadata.Set("downloadURI", rtPathInfo.DownloadURI)
result.ArtifactManagerMetadata.Set("createdBy", rtPathInfo.CreatedBy)
result.ArtifactManagerMetadata.Set("localRepository", rtPathInfo.Repo)
result.ArtifactManagerMetadata.Set("id", img.imageID)
break
}
}
if !manifestFound {
return nil, time.Since(t0), errors.New("unable to get uploaded image manifest")
}

default:
// Push on the registry set as parameter
if registry == "" && registryAuth == "" {
return nil, time.Since(t0), errors.New("wrong usage: <registry> and <registryAuth> parameters should not be both empty")
}

destination = img.repository + ":" + tag
if registry != "" {
destination = registry + "/" + destination
}

if tag != img.tag { // if the image already has the right tag, nothing to do
if err := cli.ImageTag(ctx, img.imageID, destination); err != nil {
return nil, time.Since(t0), errors.Errorf("unable to tag %q to %q: %v", source, destination, err)
}
}

output, err := cli.ImagePush(ctx, destination, types.ImagePushOptions{RegistryAuth: registryAuth})
if err != nil {
return nil, time.Since(t0), errors.Errorf("unable to push %q: %v", destination, err)
}

if err := jsonmessage.DisplayJSONMessagesToStream(output, streams.NewOut(os.Stdout), nil); err != nil {
return nil, time.Since(t0), errors.Errorf("unable to push %q: %v", destination, err)
}

result.ArtifactManagerMetadata = &sdk.V2WorkflowRunResultArtifactManagerMetadata{}
result.ArtifactManagerMetadata.Set("registry", registry)
result.ArtifactManagerMetadata.Set("name", destination)
result.ArtifactManagerMetadata.Set("id", img.imageID)
}

details, err := result.GetDetailAsV2WorkflowRunResultDockerDetail()
if err != nil {
return nil, time.Since(t0), err
}
details.Name = destination
result.Detail.Data = details
result.Status = sdk.V2WorkflowRunResultStatusCompleted

updatedRunresult, err := grpcplugins.UpdateRunResult(ctx, &actPlugin.Common, &workerruntime.V2RunResultRequest{RunResult: result})
return updatedRunresult.RunResult, time.Since(t0), err

}

func HumanDuration(seconds int64) string {
createdAt := time.Unix(seconds, 0)

if createdAt.IsZero() {
return ""
}
// https://github.com/docker/cli/blob/0e70f1b7b831565336006298b9443b015c3c87a5/cli/command/formatter/buildcache.go#L156
return units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
}

func HumanSize(size int64) string {
// https://github.com/docker/cli/blob/0e70f1b7b831565336006298b9443b015c3c87a5/cli/command/formatter/buildcache.go#L148
return units.HumanSizeWithPrecision(float64(size), 3)
}
Loading

0 comments on commit 149547d

Please sign in to comment.