portainer/portainer

View on GitHub
api/docker/images/status.go

Summary

Maintainability
A
1 hr
Test Coverage
package images

import (
    "context"
    "slices"
    "strings"
    "time"

    "github.com/docker/docker/api/types"
    "github.com/docker/docker/api/types/container"
    "github.com/docker/docker/api/types/filters"
    portainer "github.com/portainer/portainer/api"
    consts "github.com/portainer/portainer/api/docker/consts"

    "github.com/opencontainers/go-digest"
    "github.com/patrickmn/go-cache"
    "github.com/pkg/errors"
    "github.com/rs/zerolog/log"
)

// Status constants
const (
    Processing = Status("processing")
    Outdated   = Status("outdated")
    Updated    = Status("updated")
    Skipped    = Status("skipped")
    Preparing  = Status("preparing")
    Error      = Status("error")
)

var (
    statusCache       = cache.New(24*time.Hour, 24*time.Hour)
    remoteDigestCache = cache.New(5*time.Second, 5*time.Second)
    swarmID2NameCache = cache.New(5*time.Second, 5*time.Second)
)

// Status holds Docker image  analysis
type Status string

func (c *DigestClient) ContainersImageStatus(ctx context.Context, containers []types.Container, endpoint *portainer.Endpoint) Status {
    cli, err := c.clientFactory.CreateClient(endpoint, "", nil)
    if err != nil {
        log.Error().Err(err).Msg("cannot create docker client")

        return Error
    }

    statuses := make([]Status, len(containers))
    for i, ct := range containers {
        var nodeName string
        if swarmNodeId := ct.Labels[consts.SwarmNodeIdLabel]; swarmNodeId != "" {
            if swarmNodeName, ok := swarmID2NameCache.Get(swarmNodeId); ok {
                nodeName, _ = swarmNodeName.(string)
            } else {
                node, _, err := cli.NodeInspectWithRaw(ctx, ct.Labels[consts.SwarmNodeIdLabel])
                if err != nil {
                    return Error
                }

                nodeName = node.Description.Hostname
                swarmID2NameCache.Set(swarmNodeId, nodeName, 0)
            }
        }

        s, err := c.ContainerImageStatus(ctx, ct.ID, endpoint, nodeName)
        if err != nil {
            statuses[i] = Error
            log.Warn().Str("containerId", ct.ID).Err(err).Msg("error when fetching image status for container")

            continue
        }

        statuses[i] = s

        if s == Outdated || s == Processing {
            break
        }
    }

    return FigureOut(statuses)
}

func FigureOut(statuses []Status) Status {
    if allMatch(statuses, Skipped) {
        return Skipped
    }

    if allMatch(statuses, Preparing) {
        return Preparing
    }

    if contains(statuses, Outdated) {
        return Outdated
    } else if contains(statuses, Processing) {
        return Processing
    } else if contains(statuses, Error) {
        return Error
    }

    return Updated
}

func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID string, endpoint *portainer.Endpoint, nodeName string) (Status, error) {
    cli, err := c.clientFactory.CreateClient(endpoint, nodeName, nil)
    if err != nil {
        log.Warn().Str("swarmNodeId", nodeName).Msg("Cannot create new docker client.")
    }

    container, err := cli.ContainerInspect(ctx, containerID)
    if err != nil {
        log.Warn().Err(err).Str("containerID", containerID).Msg("Inspect container error.")
        return Skipped, nil
    }

    var imageID string
    if strings.Contains(container.Image, "sha256") {
        imageID = container.Image[strings.Index(container.Image, "sha256"):]
    }

    if imageID == "" {
        return Skipped, nil
    }

    digs := make([]digest.Digest, 0)
    images := make([]*Image, 0)
    if i, err := ParseImage(ParseImageOptions{Name: container.Config.Image}); err == nil {
        images = append(images, &i)
    }

    imageInspect, _, err := cli.ImageInspectWithRaw(ctx, imageID)
    if err != nil {
        log.Debug().Str("imageID", imageID).Msg("inspect failed")
        return Error, err
    }

    if len(imageInspect.RepoDigests) > 0 {
        digs = append(digs, ParseRepoDigests(imageInspect.RepoDigests)...)
    }

    if len(imageInspect.RepoTags) > 0 {
        images = append(images, ParseRepoTags(imageInspect.RepoTags)...)
    }

    s, err := c.checkStatus(images, digs)
    if err != nil {
        log.Debug().Str("image", container.Image).Err(err).Msg("fetching a certain image status")
        return Error, err
    }

    statusCache.Set(imageID, s, 0)

    return s, err
}

func (c *DigestClient) ServiceImageStatus(ctx context.Context, serviceID string, endpoint *portainer.Endpoint) (Status, error) {
    cli, err := c.clientFactory.CreateClient(endpoint, "", nil)
    if err != nil {
        return Error, nil
    }

    containers, err := cli.ContainerList(ctx, container.ListOptions{
        All:     true,
        Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIdLabel+"="+serviceID)),
    })
    if err != nil {
        log.Warn().Err(err).Str("serviceID", serviceID).Msg("cannot list container for the service")
        return Error, err
    }

    nonExistedOrStoppedContainers := make([]types.Container, 0)
    for _, container := range containers {
        if container.State == "exited" || container.State == "stopped" {
            continue
        }

        // When there is a container with the state "Created" under the service, it
        // indicates that the Docker Swarm is replacing the existing task with
        // a new task. At the moment, the state of the new task is "Created", and
        // the state of the old task is "Running".
        // Until the new task runs up, the image status should be set "Preparing"
        if container.State == "created" {
            return Preparing, nil
        }
        nonExistedOrStoppedContainers = append(nonExistedOrStoppedContainers, container)
    }

    if len(nonExistedOrStoppedContainers) == 0 {
        return Preparing, nil
    }

    return c.ContainersImageStatus(ctx, nonExistedOrStoppedContainers, endpoint), nil
}

func (c *DigestClient) checkStatus(images []*Image, digests []digest.Digest) (Status, error) {
    if digests == nil {
        digests = make([]digest.Digest, 0)
    }

    for _, img := range images {
        if img.Digest != "" && !slices.Contains(digests, img.Digest) {
            log.Info().Str("localDigest", img.Domain).Msg("incoming local digest is not nil")
            digests = append([]digest.Digest{img.Digest}, digests...)
        }
    }

    if len(digests) == 0 {
        return Skipped, nil
    }

    var imageStatus Status

    for _, img := range images {
        var remoteDigest digest.Digest
        var err error
        if rd, ok := remoteDigestCache.Get(img.FullName()); ok {
            remoteDigest, _ = rd.(digest.Digest)
        }
        if remoteDigest == "" {
            remoteDigest, err = c.RemoteDigest(*img)
            if err != nil {
                log.Error().Str("image", img.String()).Msg("error when fetch remote digest for image")
                return Error, err
            }
        }
        remoteDigestCache.Set(img.FullName(), remoteDigest, 0)

        log.Debug().Str("image", img.FullName()).Stringer("remote_digest", remoteDigest).
            Int("local_digest_size", len(digests)).
            Msg("Digests")

        // final locals vs remote one
        for _, dig := range digests {
            log.Debug().
                Str("image", img.FullName()).
                Stringer("remote_digest", remoteDigest).
                Stringer("local_digest", dig).
                Msg("Comparing")

            if dig == remoteDigest {
                log.Debug().Str("image", img.FullName()).
                    Stringer("remote_digest", remoteDigest).
                    Stringer("local_digest", dig).
                    Msg("Found a match")
                return Updated, nil
            }
        }
    }

    imageStatus = Outdated

    return imageStatus, nil
}

func CachedResourceImageStatus(resourceID string) (Status, error) {
    if s, ok := statusCache.Get(resourceID); ok {
        return s.(Status), nil
    }

    return "", errors.Errorf("no image found in cache: %s", resourceID)
}

func CacheResourceImageStatus(resourceID string, status Status) {
    statusCache.Set(resourceID, status, 0)
}

func CachedImageDigest(resourceID string) (Status, error) {
    if s, ok := statusCache.Get(resourceID); ok {
        return s.(Status), nil
    }

    return "", errors.Errorf("no image found in cache: %s", resourceID)
}

func EvictImageStatus(resourceID string) {
    statusCache.Delete(resourceID)
}

func contains(statuses []Status, status Status) bool {
    if len(statuses) == 0 {
        return false
    }

    for _, s := range statuses {
        if s == status {
            return true
        }
    }

    return false
}

func allMatch(statuses []Status, status Status) bool {
    if len(statuses) == 0 {
        return false
    }

    for _, s := range statuses {
        if s != status {
            return false
        }
    }

    return true
}