func/docker/pusher.go

174 lines
3.9 KiB
Go

package docker
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"regexp"
"strings"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/pkg/errors"
bosonFunc "github.com/boson-project/func"
)
type Opt func(*Pusher) error
type Credentials struct {
Username string
Password string
}
type CredentialsProvider func(ctx context.Context, registry string) (Credentials, error)
// Pusher of images from local to remote registry.
type Pusher struct {
// Verbose logging.
Verbose bool
credentialsProvider CredentialsProvider
}
func WithCredentialsProvider(cp CredentialsProvider) Opt {
return func(p *Pusher) error {
p.credentialsProvider = cp
return nil
}
}
func EmptyCredentialsProvider(ctx context.Context, registry string) (Credentials, error) {
return Credentials{}, nil
}
// NewPusher creates an instance of a docker-based image pusher.
func NewPusher(opts ...Opt) (*Pusher, error) {
result := &Pusher{
Verbose: false,
credentialsProvider: EmptyCredentialsProvider,
}
for _, opt := range opts {
err := opt(result)
if err != nil {
return nil, err
}
}
return result, nil
}
// Push the image of the Function.
func (n *Pusher) Push(ctx context.Context, f bosonFunc.Function) (digest string, err error) {
if f.Image == "" {
return "", errors.New("Function has no associated image. Has it been built?")
}
var registry string
parts := strings.Split(f.Image, "/")
switch len(parts) {
case 2:
registry = bosonFunc.DefaultRegistry
case 3:
registry = parts[0]
default:
return "", errors.Errorf("failed to parse image name: %q", f.Image)
}
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return "", errors.Wrap(err, "failed to create docker api client")
}
credentials, err := n.credentialsProvider(ctx, registry)
if err != nil {
return "", errors.Wrap(err, "failed to get credentials")
}
b, err := json.Marshal(&credentials)
if err != nil {
return "", err
}
opts := types.ImagePushOptions{RegistryAuth: base64.StdEncoding.EncodeToString(b)}
r, err := cli.ImagePush(ctx, f.Image, opts)
if err != nil {
return "", errors.Wrap(err, "failed to push the image")
}
defer r.Close()
var output io.Writer
var outBuff bytes.Buffer
// If verbose logging is enabled, echo chatty stdout.
if n.Verbose {
output = io.MultiWriter(&outBuff, os.Stdout)
} else {
output = &outBuff
}
decoder := json.NewDecoder(r)
li := logItem{}
for {
err = decoder.Decode(&li)
if err != nil {
if err == io.EOF {
err = nil
}
break
}
if li.Error != "" {
return "", errors.New(li.ErrorDetail.Message)
}
if li.Id != "" {
fmt.Fprintf(output, "%s: ", li.Id)
}
var percent int
if li.ProgressDetail.Total == 0 {
percent = 100
} else {
percent = (li.ProgressDetail.Current * 100) / li.ProgressDetail.Total
}
fmt.Fprintf(output, "%s (%d%%)\n", li.Status, percent)
}
digest = parseDigest(outBuff.String())
return
}
var digestRE = regexp.MustCompile(`digest:\s+(sha256:\w{64})`)
// parseDigest tries to parse the last line from the output, which holds the pushed image digest
// The output should contain line like this:
// latest: digest: sha256:a278a91112d17f8bde6b5f802a3317c7c752cf88078dae6f4b5a0784deb81782 size: 2613
func parseDigest(output string) string {
match := digestRE.FindStringSubmatch(output)
if len(match) >= 2 {
return match[1]
}
return ""
}
type errorDetail struct {
Message string `json:"message"`
}
type progressDetail struct {
Current int `json:"current"`
Total int `json:"total"`
}
type logItem struct {
Id string `json:"id"`
Status string `json:"status"`
Error string `json:"error"`
ErrorDetail errorDetail `json:"errorDetail"`
Progress string `json:"progress"`
ProgressDetail progressDetail `json:"progressDetail"`
}