cloudfoundry/cf-k8s-controllers

View on GitHub
tools/image/client.go

Summary

Maintainability
A
50 mins
Test Coverage
package image

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"

    "github.com/buildpacks/pack/pkg/archive"
    "github.com/go-logr/logr"
    "github.com/google/go-containerregistry/pkg/authn"
    "github.com/google/go-containerregistry/pkg/authn/k8schain"
    "github.com/google/go-containerregistry/pkg/name"
    "github.com/google/go-containerregistry/pkg/v1/empty"
    "github.com/google/go-containerregistry/pkg/v1/mutate"
    "github.com/google/go-containerregistry/pkg/v1/remote"
    "github.com/google/go-containerregistry/pkg/v1/remote/transport"
    "github.com/google/go-containerregistry/pkg/v1/tarball"
    "k8s.io/client-go/kubernetes"
    "k8s.io/utils/net"
    ctrl "sigs.k8s.io/controller-runtime"
)

type Client struct {
    k8sClient kubernetes.Interface
    logger    logr.Logger
}

type Creds struct {
    Namespace string
    // At most one of SecretNames and ServiceAccountName should be set.
    // If both unset, the fallback auth approach will be used.
    SecretNames        []string
    ServiceAccountName string
}

type Config struct {
    Labels       map[string]string
    User         string
    ExposedPorts []int32
}

func NewClient(k8sClient kubernetes.Interface) Client {
    return Client{
        k8sClient: k8sClient,
        logger:    ctrl.Log.WithName("image.client"),
    }
}

func (c Client) Push(ctx context.Context, creds Creds, repoRef string, zipReader io.Reader, tags ...string) (string, error) {
    tmpFile, err := os.CreateTemp(os.TempDir(), "sourceimg-%s")
    if err != nil {
        return "", fmt.Errorf("failed to create a temp file for image: %w", err)
    }
    defer tmpFile.Close()

    if _, err = io.Copy(tmpFile, zipReader); err != nil {
        return "", fmt.Errorf("failed to copy image source into temp file '%s' %w", tmpFile.Name(), err)
    }

    layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
        return archive.ReadZipAsTar(tmpFile.Name(), "/", 0, 0, -1, true, nil), nil
    })
    if err != nil {
        return "", fmt.Errorf("failed to create a layer out of '%s': %w", tmpFile.Name(), err)
    }

    image, err := mutate.AppendLayers(empty.Image, layer)
    if err != nil {
        return "", fmt.Errorf("failed to append layer: %w", err)
    }

    ref, err := name.ParseReference(repoRef)
    if err != nil {
        return "", fmt.Errorf("error parsing repository reference %s: %w", repoRef, err)
    }

    authOpt, err := c.authOpt(ctx, creds)
    if err != nil {
        return "", fmt.Errorf("error creating keychain: %w", err)
    }

    if err = remote.Write(ref, image, authOpt); err != nil {
        return "", fmt.Errorf("failed to upload image: %w", err)
    }

    for _, tag := range tags {
        err = remote.Tag(ref.Context().Tag(tag), image, authOpt)
        if err != nil {
            return "", fmt.Errorf("failed to tag image: %w", err)
        }
    }

    imgDigest, err := image.Digest()
    if err != nil {
        return "", fmt.Errorf("failed to get image digest: %w", err)
    }

    refWithDigest, err := name.NewDigest(fmt.Sprintf("%s@%s", ref.Context().Name(), imgDigest.String()))
    if err != nil {
        return "", fmt.Errorf("failed to create digest: %w", err)
    }

    return refWithDigest.Name(), nil
}

func (c Client) Config(ctx context.Context, creds Creds, imageRef string) (Config, error) {
    ref, err := name.ParseReference(imageRef)
    if err != nil {
        return Config{}, fmt.Errorf("error parsing repository reference %s: %w", imageRef, err)
    }

    authOpt, err := c.authOpt(ctx, creds)
    if err != nil {
        return Config{}, fmt.Errorf("error creating keychain: %w", err)
    }

    img, err := remote.Image(ref, authOpt)
    if err != nil {
        return Config{}, fmt.Errorf("failed to get image: %w", err)
    }

    cfgFile, err := img.ConfigFile()
    if err != nil {
        return Config{}, fmt.Errorf("error getting image config file: %w", err)
    }

    ports := []int32{}
    for _, p := range parseExposedPorts(cfgFile.Config.ExposedPorts) {
        parsed, err := net.ParsePort(p, false)
        if err != nil {
            return Config{}, fmt.Errorf("error getting exposed ports: %w", err)
        }
        ports = append(ports, int32(parsed))
    }

    return Config{
        Labels:       cfgFile.Config.Labels,
        User:         cfgFile.Config.User,
        ExposedPorts: ports,
    }, nil
}

func parseExposedPorts(ports map[string]struct{}) []string {
    result := []string{}
    for p := range ports {
        result = append(result, strings.Split(p, "/")[0])
    }

    return result
}

func (c Client) Delete(ctx context.Context, creds Creds, imageRef string, tagsToDelete ...string) error {
    c.logger.V(1).Info("deleting", "ref", imageRef)
    ref, err := name.ParseReference(imageRef)
    if err != nil {
        return err
    }

    authOpt, err := c.authOpt(ctx, creds)
    if err != nil {
        return fmt.Errorf("error creating keychain: %w", err)
    }

    allTagSet, err := c.getTagSet(ref, authOpt)
    if err != nil {
        return fmt.Errorf("failed to list tags: %w", err)
    }

    for _, tag := range tagsToDelete {
        if !allTagSet[tag] {
            c.logger.Info("tag not found for this ref", "tag", tag, "ref", imageRef)
            continue
        }

        if err = c.deleteTag(ref, tag, authOpt); err != nil {
            c.logger.Info("failed to delete tag", "reason", err)
            continue
        }
        delete(allTagSet, tag)
    }

    // The latest tag is set automatically by registries. If it is the only
    // remaining tag, remove it to prevent digest deletion errors
    latestTag := "latest"
    if len(allTagSet) == 1 && allTagSet[latestTag] {
        if err = c.deleteTag(ref, latestTag, authOpt); err != nil {
            c.logger.Info("failed to delete tag", "reason", err)
        } else {
            delete(allTagSet, latestTag)
        }
    }

    if len(allTagSet) == 0 {
        err = remote.Delete(ref, authOpt)
        if err != nil {
            if structuredErr, ok := err.(*transport.Error); ok && structuredErr.StatusCode == http.StatusNotFound {
                c.logger.V(1).Info("manifest disappeared - continuing", "reason", err)
                return nil
            }
        }
    }

    return err
}

func (c Client) getTagSet(ref name.Reference, authOpt remote.Option) (map[string]bool, error) {
    allTags, err := remote.List(ref.Context(), authOpt)
    if err != nil {
        c.logger.V(1).Info("failed to list tags - skipping tag deletion", "reason", err)
        return nil, err
    }

    allTagSet := map[string]bool{}
    for _, t := range allTags {
        var tagRef name.Reference
        tagRef, err = name.ParseReference(ref.Context().String() + ":" + t)
        if err != nil {
            return nil, fmt.Errorf("couldn't create a tag ref: %w", err)
        }

        var descriptor *remote.Descriptor
        descriptor, err = remote.Get(tagRef, authOpt)
        if err != nil {
            return nil, fmt.Errorf("couldn't get tag: %w", err)
        }

        if descriptor.Digest.String() == ref.Identifier() {
            allTagSet[t] = true
        }
    }

    return allTagSet, nil
}

func (c Client) deleteTag(ref name.Reference, tag string, authOpt remote.Option) error {
    tagRef, err := name.ParseReference(ref.Context().String() + ":" + tag)
    if err != nil {
        return fmt.Errorf("couldn't create a tag ref: %w", err)
    }
    var descriptor *remote.Descriptor
    descriptor, err = remote.Get(tagRef, authOpt)
    if err != nil {
        c.logger.V(1).Info("failed get tag - continuing", "reason", err)
        return nil
    }

    if descriptor.Digest.String() == ref.Identifier() {
        c.logger.V(1).Info("deleting tag", "tag", tag)
        err = remote.Delete(tagRef, authOpt)
        if err != nil {
            c.logger.V(1).Info("failed to delete tag", "reason", err)
        }
    }

    return nil
}

func (c Client) authOpt(ctx context.Context, creds Creds) (remote.Option, error) {
    var keychain authn.Keychain
    var err error

    if len(creds.SecretNames) > 0 {
        keychain, err = k8schain.New(ctx, c.k8sClient, k8schain.Options{
            Namespace:        creds.Namespace,
            ImagePullSecrets: creds.SecretNames,
        })
    } else if creds.ServiceAccountName != "" {
        keychain, err = k8schain.New(ctx, c.k8sClient, k8schain.Options{
            Namespace:          creds.Namespace,
            ServiceAccountName: creds.ServiceAccountName,
        })
    } else {
        keychain, err = k8schain.NewNoClient(ctx)
    }
    if err != nil {
        return nil, err
    }

    return remote.WithAuthFromKeychain(keychain), nil
}