Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(contrib): docker-push plugin #6813

Merged
merged 9 commits into from
Feb 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 <[email protected]>"
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.
fsamin marked this conversation as resolved.
Show resolved Hide resolved
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 <[email protected]>",
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