chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
|
||||
var errUnsupportedEnvironmentType = errors.New("environment not supported")
|
||||
|
||||
const (
|
||||
defaultDockerRequestTimeout = 60 * time.Second
|
||||
dockerClientVersion = "1.37"
|
||||
)
|
||||
|
||||
type NodeNamesCtxKey struct{}
|
||||
|
||||
// ClientFactory is used to create Docker clients
|
||||
type ClientFactory struct {
|
||||
signatureService portainer.DigitalSignatureService
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
}
|
||||
|
||||
// NewClientFactory returns a new instance of a ClientFactory
|
||||
func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *ClientFactory {
|
||||
return &ClientFactory{
|
||||
signatureService: signatureService,
|
||||
reverseTunnelService: reverseTunnelService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateClient is a generic function to create a Docker client based on
|
||||
// a specific environment(endpoint) configuration. The nodeName parameter can be used
|
||||
// with an agent enabled environment(endpoint) to target a specific node in an agent cluster.
|
||||
// The underlying http client timeout may be specified, a default value is used otherwise.
|
||||
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
||||
switch endpoint.Type {
|
||||
case portainer.AzureEnvironment:
|
||||
return nil, errUnsupportedEnvironmentType
|
||||
case portainer.AgentOnDockerEnvironment:
|
||||
return createAgentClient(endpoint, endpoint.URL, factory.signatureService, nodeName, timeout)
|
||||
case portainer.EdgeAgentOnDockerEnvironment:
|
||||
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpointURL := "http://" + tunnelAddr
|
||||
|
||||
return createAgentClient(endpoint, endpointURL, factory.signatureService, nodeName, timeout)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
|
||||
return createLocalClient(endpoint)
|
||||
}
|
||||
|
||||
return createTCPClient(endpoint, timeout)
|
||||
}
|
||||
|
||||
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
||||
return client.NewClientWithOpts(
|
||||
client.WithHost(endpoint.URL),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
)
|
||||
}
|
||||
|
||||
func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) {
|
||||
httpCli, err := httpClient(endpoint, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := []client.Opt{
|
||||
client.WithHost(endpoint.URL),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
client.WithHTTPClient(httpCli),
|
||||
}
|
||||
|
||||
if endpoint.TLSConfig.TLS {
|
||||
opts = append(opts, client.WithScheme("https"))
|
||||
}
|
||||
|
||||
return client.NewClientWithOpts(opts...)
|
||||
}
|
||||
|
||||
func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
||||
httpCli, err := httpClient(endpoint, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signature, err := signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
portainer.PortainerAgentPublicKeyHeader: signatureService.EncodedPublicKey(),
|
||||
portainer.PortainerAgentSignatureHeader: signature,
|
||||
}
|
||||
|
||||
if nodeName != "" {
|
||||
headers[portainer.PortainerAgentTargetHeader] = nodeName
|
||||
}
|
||||
|
||||
opts := []client.Opt{
|
||||
client.WithHost(endpointURL),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
client.WithHTTPClient(httpCli),
|
||||
client.WithHTTPHeaders(headers),
|
||||
}
|
||||
|
||||
if endpoint.TLSConfig.TLS {
|
||||
opts = append(opts, client.WithScheme("https"))
|
||||
}
|
||||
|
||||
return client.NewClientWithOpts(opts...)
|
||||
}
|
||||
|
||||
type NodeNameTransport struct {
|
||||
*http.Transport
|
||||
}
|
||||
|
||||
func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.Transport.RoundTrip(req)
|
||||
if err != nil ||
|
||||
resp.StatusCode != http.StatusOK ||
|
||||
resp.ContentLength == 0 ||
|
||||
!strings.HasSuffix(req.URL.Path, "/images/json") {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to close response body")
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to close response body")
|
||||
}
|
||||
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
var rs []struct {
|
||||
image.Summary
|
||||
Portainer struct {
|
||||
Agent struct {
|
||||
NodeName string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(body, &rs); err != nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
nodeNames, ok := req.Context().Value(NodeNamesCtxKey{}).(map[string]string)
|
||||
if ok {
|
||||
for idx, r := range rs {
|
||||
// as there is no way to differentiate the same image available in multiple nodes only by their ID
|
||||
// we append the index of the image in the payload response to match the node name later
|
||||
// from the image.Summary[] list returned by docker's client.ImageList()
|
||||
nodeNames[fmt.Sprintf("%s-%d", r.ID, idx)] = r.Portainer.Agent.NodeName
|
||||
}
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
|
||||
var transport *NodeNameTransport
|
||||
if endpoint.TLSConfig.TLS {
|
||||
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := ssrf.NewTransport(tlsConfig)
|
||||
t.Protocols = ssrf.HTTP1Only()
|
||||
transport = &NodeNameTransport{Transport: t}
|
||||
} else {
|
||||
transport = &NodeNameTransport{Transport: ssrf.NewTransport(nil)}
|
||||
}
|
||||
|
||||
clientTimeout := defaultDockerRequestTimeout
|
||||
if timeout != nil {
|
||||
clientTimeout = *timeout
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: clientTimeout,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHttpClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
fips.InitFIPS(false)
|
||||
|
||||
// Valid TLS configuration
|
||||
endpoint := &portainer.Endpoint{}
|
||||
endpoint.TLSConfig = portainer.TLSConfiguration{TLS: true}
|
||||
|
||||
cli, err := httpClient(endpoint, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cli)
|
||||
|
||||
// Invalid TLS configuration
|
||||
endpoint.TLSConfig.TLSCertPath = "/invalid/path/client.crt"
|
||||
endpoint.TLSConfig.TLSKeyPath = "/invalid/path/client.key"
|
||||
|
||||
cli, err = httpClient(endpoint, nil)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, cli)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package consts
|
||||
|
||||
const (
|
||||
ComposeStackNameLabel = "com.docker.compose.project"
|
||||
SwarmStackNameLabel = "com.docker.stack.namespace"
|
||||
SwarmServiceIDLabel = "com.docker.swarm.service.id"
|
||||
SwarmNodeIDLabel = "com.docker.swarm.node.id"
|
||||
HideStackLabel = "io.portainer.hideStack"
|
||||
)
|
||||
@@ -0,0 +1,239 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/docker/images"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/docker/docker/api/types"
|
||||
dockercontainer "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type ContainerService struct {
|
||||
factory *dockerclient.ClientFactory
|
||||
dataStore dataservices.DataStore
|
||||
}
|
||||
|
||||
func NewContainerService(factory *dockerclient.ClientFactory, dataStore dataservices.DataStore) *ContainerService {
|
||||
return &ContainerService{
|
||||
factory: factory,
|
||||
dataStore: dataStore,
|
||||
}
|
||||
}
|
||||
|
||||
// applyVersionConstraint uses the version to apply a transformation function to
|
||||
// the value when the constraint is satisfied
|
||||
func applyVersionConstraint[T any](currentVersion, versionConstraint string, value T, transform func(T) T) (T, error) {
|
||||
newValue := value
|
||||
|
||||
constraint, err := semver.NewConstraint(versionConstraint)
|
||||
if err != nil {
|
||||
return newValue, errors.New("invalid version constraint specified")
|
||||
}
|
||||
|
||||
currentVer, err := semver.NewVersion(currentVersion)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to parse the Docker client version")
|
||||
|
||||
return newValue, nil
|
||||
}
|
||||
|
||||
if satisfiesConstraint, _ := constraint.Validate(currentVer); satisfiesConstraint {
|
||||
newValue = transform(value)
|
||||
}
|
||||
|
||||
return newValue, nil
|
||||
}
|
||||
|
||||
func clearMacAddrs(n network.NetworkingConfig) network.NetworkingConfig {
|
||||
netConfig := network.NetworkingConfig{
|
||||
EndpointsConfig: make(map[string]*network.EndpointSettings),
|
||||
}
|
||||
|
||||
for k := range n.EndpointsConfig {
|
||||
endpointConfig := n.EndpointsConfig[k].Copy()
|
||||
endpointConfig.MacAddress = ""
|
||||
netConfig.EndpointsConfig[k] = endpointConfig
|
||||
}
|
||||
|
||||
return netConfig
|
||||
}
|
||||
|
||||
// Recreate a container
|
||||
func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.Endpoint, containerId string, forcePullImage bool, imageTag, nodeName string) (*types.ContainerJSON, error) {
|
||||
cli, err := c.factory.CreateClient(endpoint, nodeName, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "create client error")
|
||||
}
|
||||
defer logs.CloseAndLogErr(cli)
|
||||
|
||||
log.Debug().Str("container_id", containerId).Msg("starting to fetch container information")
|
||||
|
||||
container, _, err := cli.ContainerInspectWithRaw(ctx, containerId, true)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "fetch container information error")
|
||||
}
|
||||
|
||||
log.Debug().Str("image", container.Config.Image).Msg("starting to parse image")
|
||||
img, err := images.ParseImage(images.ParseImageOptions{
|
||||
Name: container.Config.Image,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "parse image error")
|
||||
}
|
||||
|
||||
if imageTag != "" {
|
||||
if err := img.WithTag(imageTag); err != nil {
|
||||
return nil, errors.Wrapf(err, "set image tag error %s", imageTag)
|
||||
}
|
||||
|
||||
log.Debug().Str("image", container.Config.Image).Msg("new image with tag")
|
||||
|
||||
container.Config.Image = img.FullName()
|
||||
}
|
||||
|
||||
// 1. pull image if you need force pull
|
||||
if forcePullImage {
|
||||
puller := images.NewPuller(cli, images.NewRegistryClient(c.dataStore), c.dataStore)
|
||||
if err := puller.Pull(ctx, img); err != nil {
|
||||
return nil, errors.Wrapf(err, "pull image error %s", img.FullName())
|
||||
}
|
||||
}
|
||||
|
||||
// 2. stop the current container
|
||||
log.Debug().Str("container_id", containerId).Msg("starting to stop the container")
|
||||
if err := cli.ContainerStop(ctx, containerId, dockercontainer.StopOptions{}); err != nil {
|
||||
return nil, errors.Wrap(err, "stop container error")
|
||||
}
|
||||
|
||||
// 3. rename the current container
|
||||
log.Debug().Str("container_id", containerId).Msg("starting to rename the container")
|
||||
if err := cli.ContainerRename(ctx, containerId, container.Name+"-old"); err != nil {
|
||||
return nil, errors.Wrap(err, "rename container error")
|
||||
}
|
||||
|
||||
initialNetwork := network.NetworkingConfig{
|
||||
EndpointsConfig: make(map[string]*network.EndpointSettings),
|
||||
}
|
||||
|
||||
// 4. disconnect all networks from the current container
|
||||
for name, network := range container.NetworkSettings.Networks {
|
||||
// This allows new container to use the same IP address if specified
|
||||
if err := cli.NetworkDisconnect(ctx, network.NetworkID, containerId, true); err != nil {
|
||||
return nil, errors.Wrap(err, "disconnect network from old container error")
|
||||
}
|
||||
|
||||
// 5. get the first network attached to the current container
|
||||
if len(initialNetwork.EndpointsConfig) == 0 {
|
||||
// Retrieve the first network that is linked to the present container, which
|
||||
// will be utilized when creating the container.
|
||||
initialNetwork.EndpointsConfig[name] = network
|
||||
}
|
||||
}
|
||||
|
||||
restore := true
|
||||
|
||||
defer func() {
|
||||
if !restore {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("container_id", containerId).Str("container", container.Name).Msg("restoring the container")
|
||||
if err := cli.ContainerRename(ctx, containerId, container.Name); err != nil {
|
||||
log.Warn().Err(err).Msg("failure to rename container")
|
||||
}
|
||||
|
||||
for _, network := range container.NetworkSettings.Networks {
|
||||
if err := cli.NetworkConnect(ctx, network.NetworkID, containerId, network); err != nil {
|
||||
log.Warn().Err(err).Msg("failure to connect container to network")
|
||||
}
|
||||
}
|
||||
|
||||
if err := cli.ContainerStart(ctx, containerId, dockercontainer.StartOptions{}); err != nil {
|
||||
log.Warn().Err(err).Msg("failure to start container")
|
||||
}
|
||||
}()
|
||||
|
||||
log.Debug().Str("container", strings.Split(container.Name, "/")[1]).Msg("starting to create a new container")
|
||||
|
||||
// 6. create a new container
|
||||
// when a container is created without a network, docker connected it by default to the
|
||||
// bridge network with a random IP, also it can only connect to one network on creation.
|
||||
// to retain the same network settings we have to connect on creation to one of the old
|
||||
// container's networks, and connect to the other networks after creation.
|
||||
// see: https://portainer.atlassian.net/browse/EE-5448
|
||||
|
||||
// Docker API < 1.44 does not support specifying MAC addresses
|
||||
// https://github.com/moby/moby/blob/6aea26b431ea152a8b085e453da06ea403f89886/client/container_create.go#L44-L46
|
||||
initialNetwork, err = applyVersionConstraint(cli.ClientVersion(), "< 1.44", initialNetwork, clearMacAddrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
create, err := cli.ContainerCreate(ctx, container.Config, container.HostConfig, &initialNetwork, nil, container.Name)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "create container error")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if !restore {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("container_id", create.ID).Msg("removing the new container")
|
||||
|
||||
if err := cli.ContainerStop(ctx, create.ID, dockercontainer.StopOptions{}); err != nil {
|
||||
log.Warn().Err(err).Msg("failure to stop container")
|
||||
}
|
||||
|
||||
if err := cli.ContainerRemove(ctx, create.ID, dockercontainer.RemoveOptions{}); err != nil {
|
||||
log.Warn().Err(err).Msg("failure to remove container")
|
||||
}
|
||||
}()
|
||||
|
||||
newContainerId := create.ID
|
||||
|
||||
// 7. connect to networks
|
||||
// docker can connect to only one network at creation, so we need to connect to networks after creation
|
||||
// see https://github.com/moby/moby/issues/17750
|
||||
log.Debug().Str("container_id", newContainerId).Msg("connecting networks to container")
|
||||
networks := container.NetworkSettings.Networks
|
||||
for key, network := range networks {
|
||||
if _, ok := initialNetwork.EndpointsConfig[key]; ok {
|
||||
// skip the network that is used during container creation
|
||||
continue
|
||||
}
|
||||
|
||||
if err := cli.NetworkConnect(ctx, network.NetworkID, newContainerId, network); err != nil {
|
||||
return nil, errors.Wrap(err, "connect container network error")
|
||||
}
|
||||
}
|
||||
|
||||
// 8. start the new container
|
||||
log.Debug().Str("container_id", newContainerId).Msg("starting the new container")
|
||||
if err := cli.ContainerStart(ctx, newContainerId, dockercontainer.StartOptions{}); err != nil {
|
||||
return nil, errors.Wrap(err, "start container error")
|
||||
}
|
||||
|
||||
// 9. delete the old container
|
||||
log.Debug().Str("container_id", containerId).Msg("starting to remove the old container")
|
||||
_ = cli.ContainerRemove(ctx, containerId, dockercontainer.RemoveOptions{})
|
||||
|
||||
restore = false
|
||||
|
||||
newContainer, _, err := cli.ContainerInspectWithRaw(ctx, newContainerId, true)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "fetch container information error")
|
||||
}
|
||||
|
||||
return &newContainer, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplyVersionConstraint(t *testing.T) {
|
||||
t.Parallel()
|
||||
initialNet := network.NetworkingConfig{
|
||||
EndpointsConfig: map[string]*network.EndpointSettings{
|
||||
"key1": {
|
||||
MacAddress: "mac1",
|
||||
EndpointID: "endpointID1",
|
||||
},
|
||||
"key2": {
|
||||
MacAddress: "mac2",
|
||||
EndpointID: "endpointID2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
f := func(currentVer string, constraint string, success, emptyMac bool) {
|
||||
t.Helper()
|
||||
|
||||
transformedNet, err := applyVersionConstraint(currentVer, constraint, initialNet, clearMacAddrs)
|
||||
if success {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
require.Len(t, transformedNet.EndpointsConfig, len(initialNet.EndpointsConfig))
|
||||
|
||||
for k := range initialNet.EndpointsConfig {
|
||||
if emptyMac {
|
||||
require.NotEqual(t, initialNet.EndpointsConfig[k], transformedNet.EndpointsConfig[k])
|
||||
require.Empty(t, transformedNet.EndpointsConfig[k].MacAddress)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
require.Equal(t, initialNet.EndpointsConfig[k], transformedNet.EndpointsConfig[k])
|
||||
}
|
||||
}
|
||||
|
||||
f("1.45", "< 1.44", true, false) // No transformation
|
||||
f("1.43", "< 1.44", true, true) // Transformation
|
||||
f("a.b.", "< 1.44", true, false) // Invalid current version
|
||||
f("1.45", "z 1.44", false, false) // Invalid version constraint
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package docker
|
||||
|
||||
import "errors"
|
||||
|
||||
// Docker errors
|
||||
var (
|
||||
ErrUnableToPingEndpoint = errors.New("Unable to communicate with the environment")
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/docker/docker/api/types/image"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"go.podman.io/image/v5/docker"
|
||||
imagetypes "go.podman.io/image/v5/types"
|
||||
)
|
||||
|
||||
const digestFetchTimeout = 5 * time.Second
|
||||
|
||||
// ClientFactory creates Docker clients for a given environment.
|
||||
type ClientFactory interface {
|
||||
CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*dockerclient.Client, error)
|
||||
}
|
||||
|
||||
// RegistryAuthProvider looks up registry credentials for an image.
|
||||
type RegistryAuthProvider interface {
|
||||
RegistryAuth(image Image) (string, string, error)
|
||||
}
|
||||
|
||||
type DigestClient struct {
|
||||
clientFactory ClientFactory
|
||||
sysCtx *imagetypes.SystemContext
|
||||
registryClient RegistryAuthProvider
|
||||
}
|
||||
|
||||
func NewClientWithRegistry(registryClient RegistryAuthProvider, clientFactory ClientFactory) *DigestClient {
|
||||
return &DigestClient{
|
||||
clientFactory: clientFactory,
|
||||
registryClient: registryClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DigestClient) RemoteDigest(ctx context.Context, image Image) (digest.Digest, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, digestFetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Docker references with both a tag and digest are currently not supported
|
||||
if image.Tag != "" && image.Digest != "" {
|
||||
if err := image.TrimDigest(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
rmRef, err := ParseReference(image.String())
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "Cannot parse the image reference")
|
||||
}
|
||||
|
||||
sysCtx := c.sysCtx
|
||||
if c.registryClient != nil {
|
||||
username, password, err := c.registryClient.RegistryAuth(image)
|
||||
if err != nil {
|
||||
log.Info().Str("image up to date indicator", image.String()).Msg("No environment registry credentials found, using anonymous access")
|
||||
} else {
|
||||
sysCtx = &imagetypes.SystemContext{
|
||||
DockerAuthConfig: &imagetypes.DockerAuthConfig{
|
||||
Username: username,
|
||||
Password: password,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve remote digest through HEAD request
|
||||
rmDigest, err := docker.GetDigest(ctx, sysCtx, rmRef)
|
||||
if err != nil {
|
||||
// Fallback to public registry for hub
|
||||
if image.HubLink != "" {
|
||||
rmDigest, err = docker.GetDigest(ctx, c.sysCtx, rmRef)
|
||||
if err == nil {
|
||||
return rmDigest, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug().Err(err).Msg("get remote digest error")
|
||||
|
||||
return "", errors.Wrap(err, "Cannot get image digest from HEAD request")
|
||||
}
|
||||
|
||||
return rmDigest, nil
|
||||
}
|
||||
|
||||
func ParseLocalImage(inspect image.InspectResponse) (*Image, error) {
|
||||
if IsLocalImage(inspect) || IsDanglingImage(inspect) {
|
||||
return nil, errors.New("the image is not regular")
|
||||
}
|
||||
|
||||
fromRepoDigests, err := ParseImage(ParseImageOptions{
|
||||
// including image name but no tag
|
||||
Name: inspect.RepoDigests[0],
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if IsNoTagImage(inspect) {
|
||||
return &fromRepoDigests, nil
|
||||
}
|
||||
|
||||
fromRepoTags, err := ParseImage(ParseImageOptions{
|
||||
Name: inspect.RepoTags[0],
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fromRepoDigests.Tag = fromRepoTags.Tag
|
||||
|
||||
return &fromRepoDigests, nil
|
||||
}
|
||||
|
||||
func ParseRepoDigests(repoDigests []string) []digest.Digest {
|
||||
digests := make([]digest.Digest, 0)
|
||||
for _, repoDigest := range repoDigests {
|
||||
d := ParseRepoDigest(repoDigest)
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
digests = append(digests, d)
|
||||
}
|
||||
|
||||
return digests
|
||||
}
|
||||
|
||||
func ParseRepoTags(repoTags []string) []*Image {
|
||||
images := make([]*Image, 0)
|
||||
for _, repoTag := range repoTags {
|
||||
if image := ParseRepoTag(repoTag); image != nil {
|
||||
images = append(images, image)
|
||||
}
|
||||
}
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
func ParseRepoDigest(repoDigest string) digest.Digest {
|
||||
if !strings.ContainsAny(repoDigest, "@") {
|
||||
return ""
|
||||
}
|
||||
|
||||
d, err := digest.Parse(strings.Split(repoDigest, "@")[1])
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("digest", repoDigest).Msg("skip invalid repo item")
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func ParseRepoTag(repoTag string) *Image {
|
||||
if repoTag == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
image, err := ParseImage(ParseImageOptions{
|
||||
Name: repoTag,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("repoTag", repoTag).Msg("RepoTag cannot be parsed.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return &image
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseLocalImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Test with a regular image
|
||||
|
||||
img, err := ParseLocalImage(image.InspectResponse{
|
||||
ID: "sha256:9234e8fb04c47cfe0f49931e4ac7eb76fa904e33b7f8576aec0501c085f02516",
|
||||
RepoTags: []string{"myimage:latest"},
|
||||
RepoDigests: []string{"myimage@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, img)
|
||||
require.Equal(t, "library/myimage", img.Path)
|
||||
require.Equal(t, "latest", img.Tag)
|
||||
require.Equal(t, "sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1", img.Digest.String())
|
||||
|
||||
// Test with a dangling image
|
||||
|
||||
img, err = ParseLocalImage(image.InspectResponse{
|
||||
ID: "sha256:abcdef1234567890",
|
||||
RepoTags: []string{"<none>:<none>"},
|
||||
RepoDigests: []string{"<none>@<none>"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, img)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/pkg/errors"
|
||||
"go.podman.io/image/v5/docker/reference"
|
||||
)
|
||||
|
||||
type ImageID string
|
||||
|
||||
// Image holds information about an image.
|
||||
type Image struct {
|
||||
// Domain is the registry host of this image
|
||||
Domain string
|
||||
// Path may include username like portainer/portainer-ee, no Tag or Digest
|
||||
Path string
|
||||
Tag string
|
||||
Digest digest.Digest
|
||||
HubLink string
|
||||
named reference.Named
|
||||
Opts ParseImageOptions `json:"-"`
|
||||
}
|
||||
|
||||
// ParseImageOptions holds image options for parsing.
|
||||
type ParseImageOptions struct {
|
||||
Name string
|
||||
HubTpl string
|
||||
}
|
||||
|
||||
// Name returns the full name representation of an image but no Tag or Digest.
|
||||
func (i *Image) Name() string {
|
||||
return i.named.Name()
|
||||
}
|
||||
|
||||
// FullName return the real full name may include Tag or Digest of the image, Tag first.
|
||||
func (i *Image) FullName() string {
|
||||
if i.Tag == "" {
|
||||
return i.Name() + "@" + i.Digest.String()
|
||||
}
|
||||
|
||||
return i.Name() + ":" + i.Tag
|
||||
}
|
||||
|
||||
// String returns the string representation of an image, including Tag and Digest if existed.
|
||||
func (i *Image) String() string {
|
||||
return i.named.String()
|
||||
}
|
||||
|
||||
// Reference returns either the digest if it is non-empty or the tag for the image.
|
||||
func (i *Image) Reference() string {
|
||||
if len(i.Digest.String()) > 1 {
|
||||
return i.Digest.String()
|
||||
}
|
||||
|
||||
return i.Tag
|
||||
}
|
||||
|
||||
// WithDigest sets the digest for an image.
|
||||
func (i *Image) WithDigest(digest digest.Digest) (err error) {
|
||||
i.Digest = digest
|
||||
i.named, err = reference.WithDigest(i.named, digest)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (i *Image) WithTag(tag string) (err error) {
|
||||
i.Tag = tag
|
||||
i.named, err = reference.WithTag(i.named, tag)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (i *Image) TrimDigest() error {
|
||||
i.Digest = ""
|
||||
named, err := ParseImage(ParseImageOptions{Name: i.FullName()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.named = &named
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseImage returns an Image struct with all the values filled in for a given image.
|
||||
func ParseImage(parseOpts ParseImageOptions) (Image, error) {
|
||||
// Parse the image name and tag.
|
||||
named, err := reference.ParseNormalizedNamed(parseOpts.Name)
|
||||
if err != nil {
|
||||
return Image{}, errors.Wrapf(err, "parsing image %s failed", parseOpts.Name)
|
||||
}
|
||||
|
||||
// Add the latest lag if they did not provide one.
|
||||
named = reference.TagNameOnly(named)
|
||||
|
||||
i := Image{
|
||||
Opts: parseOpts,
|
||||
named: named,
|
||||
Domain: reference.Domain(named),
|
||||
Path: reference.Path(named),
|
||||
}
|
||||
|
||||
// Hub link
|
||||
i.HubLink, err = i.hubLink()
|
||||
if err != nil {
|
||||
return Image{}, errors.Wrap(err, fmt.Sprintf("resolving hub link for image %s failed", parseOpts.Name))
|
||||
}
|
||||
|
||||
// Add the tag if there was one.
|
||||
if tagged, ok := named.(reference.Tagged); ok {
|
||||
i.Tag = tagged.Tag()
|
||||
}
|
||||
|
||||
// Add the digest if there was one.
|
||||
if canonical, ok := named.(reference.Canonical); ok {
|
||||
i.Digest = canonical.Digest()
|
||||
}
|
||||
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (i *Image) hubLink() (string, error) {
|
||||
if i.Opts.HubTpl != "" {
|
||||
var out bytes.Buffer
|
||||
tmpl, err := template.New("tmpl").
|
||||
Option("missingkey=error").
|
||||
Parse(i.Opts.HubTpl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = tmpl.Execute(&out, i)
|
||||
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
switch i.Domain {
|
||||
case "docker.io":
|
||||
prefix := "r"
|
||||
path := i.Path
|
||||
if strings.HasPrefix(i.Path, "library/") {
|
||||
prefix = "_"
|
||||
path = strings.Replace(i.Path, "library/", "", 1)
|
||||
}
|
||||
|
||||
return "https://hub.docker.com/" + prefix + "/" + path, nil
|
||||
case "docker.bintray.io", "jfrog-docker-reg2.bintray.io":
|
||||
return "https://bintray.com/jfrog/reg2/" + strings.ReplaceAll(i.Path, "/", "%3A"), nil
|
||||
case "docker.pkg.github.com":
|
||||
return "https://github.com/" + filepath.ToSlash(filepath.Dir(i.Path)) + "/packages", nil
|
||||
case "gcr.io":
|
||||
return "https://" + i.Domain + "/" + i.Path, nil
|
||||
case "ghcr.io":
|
||||
ref := strings.Split(i.Path, "/")
|
||||
ghUser, ghPackage := ref[0], ref[1]
|
||||
return "https://github.com/users/" + ghUser + "/packages/container/package/" + ghPackage, nil
|
||||
case "quay.io":
|
||||
return "https://quay.io/repository/" + i.Path, nil
|
||||
case "registry.access.redhat.com":
|
||||
return "https://access.redhat.com/containers/#/registry.access.redhat.com/" + i.Path, nil
|
||||
case "registry.gitlab.com":
|
||||
return "https://gitlab.com/" + i.Path + "/container_registry", nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// IsLocalImage checks if the image has been built locally
|
||||
func IsLocalImage(image types.ImageInspect) bool {
|
||||
return len(image.RepoDigests) == 0
|
||||
}
|
||||
|
||||
// IsDanglingImage returns whether the given image is "dangling" which means
|
||||
// that there are no repository references to the given image and it has no
|
||||
// child images
|
||||
func IsDanglingImage(image image.InspectResponse) bool {
|
||||
return len(image.RepoTags) == 1 && image.RepoTags[0] == "<none>:<none>" && len(image.RepoDigests) == 1 && image.RepoDigests[0] == "<none>@<none>"
|
||||
}
|
||||
|
||||
// IsNoTagImage returns whether the given image is damaged, has no tags
|
||||
func IsNoTagImage(image image.InspectResponse) bool {
|
||||
return len(image.RepoTags) == 0
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestImageParser(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
// portainer/portainer-ee
|
||||
t.Run("", func(t *testing.T) {
|
||||
image, err := ParseImage(ParseImageOptions{
|
||||
Name: "portainer/portainer-ee",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
is.Equal("docker.io/portainer/portainer-ee:latest", image.FullName())
|
||||
is.Equal("portainer/portainer-ee", image.Opts.Name)
|
||||
is.Equal("latest", image.Tag)
|
||||
is.Equal("portainer/portainer-ee", image.Path)
|
||||
is.Equal("docker.io", image.Domain)
|
||||
is.Equal("docker.io/portainer/portainer-ee", image.Name())
|
||||
is.Equal("latest", image.Reference())
|
||||
is.Equal("docker.io/portainer/portainer-ee:latest", image.String())
|
||||
|
||||
})
|
||||
// gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2
|
||||
t.Run("", func(t *testing.T) {
|
||||
image, err := ParseImage(ParseImageOptions{
|
||||
Name: "gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Empty(image.Tag)
|
||||
is.Equal("k8s-minikube/kicbase", image.Path)
|
||||
is.Equal("gcr.io", image.Domain)
|
||||
is.Equal("https://gcr.io/k8s-minikube/kicbase", image.HubLink)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase", image.Name())
|
||||
is.Equal("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Reference())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.String())
|
||||
})
|
||||
|
||||
// gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2
|
||||
t.Run("", func(t *testing.T) {
|
||||
image, err := ParseImage(ParseImageOptions{
|
||||
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Equal("v0.0.30", image.Tag)
|
||||
is.Equal("k8s-minikube/kicbase", image.Path)
|
||||
is.Equal("gcr.io", image.Domain)
|
||||
is.Equal("https://gcr.io/k8s-minikube/kicbase", image.HubLink)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase", image.Name())
|
||||
is.Equal("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Reference())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateParsedImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
// gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2
|
||||
t.Run("", func(t *testing.T) {
|
||||
image, err := ParseImage(ParseImageOptions{
|
||||
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = image.WithTag("v0.0.31")
|
||||
require.NoError(t, err)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.31", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Equal("v0.0.31", image.Tag)
|
||||
is.Equal("k8s-minikube/kicbase", image.Path)
|
||||
is.Equal("gcr.io", image.Domain)
|
||||
is.Equal("https://gcr.io/k8s-minikube/kicbase", image.HubLink)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase", image.Name())
|
||||
is.Equal("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Reference())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.31@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.String())
|
||||
})
|
||||
|
||||
// gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2
|
||||
t.Run("", func(t *testing.T) {
|
||||
image, err := ParseImage(ParseImageOptions{
|
||||
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = image.WithDigest("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b3")
|
||||
require.NoError(t, err)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Equal("v0.0.30", image.Tag)
|
||||
is.Equal("k8s-minikube/kicbase", image.Path)
|
||||
is.Equal("gcr.io", image.Domain)
|
||||
is.Equal("https://gcr.io/k8s-minikube/kicbase", image.HubLink)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase", image.Name())
|
||||
is.Equal("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b3", image.Reference())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b3", image.String())
|
||||
})
|
||||
|
||||
// gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2
|
||||
t.Run("", func(t *testing.T) {
|
||||
image, err := ParseImage(ParseImageOptions{
|
||||
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = image.TrimDigest()
|
||||
require.NoError(t, err)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Equal("v0.0.30", image.Tag)
|
||||
is.Equal("k8s-minikube/kicbase", image.Path)
|
||||
is.Equal("gcr.io", image.Domain)
|
||||
is.Equal("https://gcr.io/k8s-minikube/kicbase", image.HubLink)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase", image.Name())
|
||||
is.Equal("v0.0.30", image.Reference())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.String())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type Puller struct {
|
||||
client *client.Client
|
||||
registryClient *RegistryClient
|
||||
dataStore dataservices.DataStore
|
||||
}
|
||||
|
||||
func NewPuller(client *client.Client, registryClient *RegistryClient, dataStore dataservices.DataStore) *Puller {
|
||||
return &Puller{
|
||||
client: client,
|
||||
registryClient: registryClient,
|
||||
dataStore: dataStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (puller *Puller) Pull(ctx context.Context, img Image) error {
|
||||
log.Debug().Str("image", img.FullName()).Msg("starting to pull the image")
|
||||
|
||||
registryAuth, err := puller.registryClient.EncodedRegistryAuth(img)
|
||||
if err != nil {
|
||||
log.Debug().
|
||||
Str("image", img.FullName()).
|
||||
Err(err).
|
||||
Msg("failed to get an encoded registry auth via image, try to pull image without registry auth")
|
||||
}
|
||||
|
||||
out, err := puller.client.ImagePull(ctx, img.FullName(), image.PullOptions{
|
||||
RegistryAuth: registryAuth,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer logs.CloseAndLogErr(out)
|
||||
|
||||
_, err = io.ReadAll(out)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"go.podman.io/image/v5/docker"
|
||||
"go.podman.io/image/v5/types"
|
||||
)
|
||||
|
||||
func ParseReference(imageStr string) (types.ImageReference, error) {
|
||||
if !strings.HasPrefix(imageStr, "//") {
|
||||
imageStr = "//" + imageStr
|
||||
}
|
||||
return docker.ParseReference(imageStr)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
|
||||
"github.com/patrickmn/go-cache"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var registriesCache = cache.New(5*time.Minute, 5*time.Minute)
|
||||
|
||||
type RegistryClient struct {
|
||||
dataStore dataservices.DataStore
|
||||
}
|
||||
|
||||
func NewRegistryClient(dataStore dataservices.DataStore) *RegistryClient {
|
||||
return &RegistryClient{dataStore: dataStore}
|
||||
}
|
||||
|
||||
func (c *RegistryClient) RegistryAuth(image Image) (string, string, error) {
|
||||
registry, err := cachedRegistry(image.Opts.Name)
|
||||
if err != nil {
|
||||
var registries []portainer.Registry
|
||||
err = c.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
registries, err = tx.Registry().ReadAll()
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
registry, err = findBestMatchRegistry(image.Opts.Name, registries)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
if !registry.Authentication {
|
||||
return "", "", errors.New("authentication is disabled")
|
||||
}
|
||||
|
||||
return c.CertainRegistryAuth(registry)
|
||||
}
|
||||
|
||||
func (c *RegistryClient) CertainRegistryAuth(registry *portainer.Registry) (string, string, error) {
|
||||
if err := registryutils.EnsureRegTokenValid(c.dataStore, registry); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if !registry.Authentication {
|
||||
return "", "", errors.New("authentication is disabled")
|
||||
}
|
||||
|
||||
return registryutils.GetRegEffectiveCredential(registry)
|
||||
}
|
||||
|
||||
func (c *RegistryClient) EncodedRegistryAuth(image Image) (string, error) {
|
||||
registry, err := cachedRegistry(image.Opts.Name)
|
||||
if err != nil {
|
||||
var registries []portainer.Registry
|
||||
err = c.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
registries, err = tx.Registry().ReadAll()
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
registry, err = findBestMatchRegistry(image.Opts.Name, registries)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if !registry.Authentication {
|
||||
return "", errors.New("authentication is disabled")
|
||||
}
|
||||
|
||||
return c.EncodedCertainRegistryAuth(registry)
|
||||
}
|
||||
|
||||
func (c *RegistryClient) EncodedCertainRegistryAuth(registry *portainer.Registry) (string, error) {
|
||||
if err := registryutils.EnsureRegTokenValid(c.dataStore, registry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return registryutils.GetRegistryAuthHeader(registry)
|
||||
}
|
||||
|
||||
// findBestMatchRegistry finds out the best match registry for repository @Meng
|
||||
// matching precedence:
|
||||
// 1. both domain name and username matched (for dockerhub only)
|
||||
// 2. only URL matched
|
||||
// 3. pick up the first dockerhub registry
|
||||
func findBestMatchRegistry(repository string, registries []portainer.Registry) (*portainer.Registry, error) {
|
||||
cachedRegistry, err := cachedRegistry(repository)
|
||||
if err == nil {
|
||||
return cachedRegistry, nil
|
||||
}
|
||||
|
||||
var match1, match2, match3 *portainer.Registry
|
||||
|
||||
for _, registry := range registries {
|
||||
if strings.Contains(repository, registry.URL) {
|
||||
match2 = ®istry
|
||||
}
|
||||
|
||||
if registry.Type != portainer.DockerHubRegistry {
|
||||
continue
|
||||
}
|
||||
|
||||
// try to match repository examples:
|
||||
// <USERNAME>/nginx:latest
|
||||
// docker.io/<USERNAME>/nginx:latest
|
||||
if strings.HasPrefix(repository, registry.Username+"/") || strings.HasPrefix(repository, registry.URL+"/"+registry.Username+"/") {
|
||||
match1 = ®istry
|
||||
}
|
||||
|
||||
// try to match repository examples:
|
||||
// portainer/portainer-ee:latest
|
||||
// <NON-USERNAME>/portainer-ee:latest
|
||||
if match3 == nil {
|
||||
match3 = ®istry
|
||||
}
|
||||
}
|
||||
|
||||
match := cmp.Or(match1, match2, match3)
|
||||
if match == nil {
|
||||
return nil, errors.New("no registries matched")
|
||||
}
|
||||
|
||||
registriesCache.Set(repository, *match, 0)
|
||||
|
||||
return match, nil
|
||||
}
|
||||
|
||||
func cachedRegistry(cacheKey string) (*portainer.Registry, error) {
|
||||
r, ok := registriesCache.Get(cacheKey)
|
||||
if ok {
|
||||
registry, ok := r.(portainer.Registry)
|
||||
if ok {
|
||||
return ®istry, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.Errorf("no registry found in cache: %s", cacheKey)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
t.Run("", func(t *testing.T) {
|
||||
image := "USERNAME/nginx:latest"
|
||||
registries := []portainer.Registry{createNewRegistry("docker.io", "USERNAME", false),
|
||||
createNewRegistry("hub-mirror.c.163.com", "", false)}
|
||||
r, err := findBestMatchRegistry(image, registries)
|
||||
require.NoError(t, err)
|
||||
is.NotNil(r)
|
||||
is.False(r.Authentication)
|
||||
is.Equal("docker.io", r.URL)
|
||||
})
|
||||
|
||||
t.Run("", func(t *testing.T) {
|
||||
image := "USERNAME/nginx:latest"
|
||||
registries := []portainer.Registry{createNewRegistry("docker.io", "", false),
|
||||
createNewRegistry("hub-mirror.c.163.com", "USERNAME", false)}
|
||||
r, err := findBestMatchRegistry(image, registries)
|
||||
require.NoError(t, err)
|
||||
is.NotNil(r)
|
||||
is.False(r.Authentication)
|
||||
is.Equal("docker.io", r.URL)
|
||||
})
|
||||
|
||||
t.Run("", func(t *testing.T) {
|
||||
image := "docker.io/<USERNAME>/nginx:latest"
|
||||
registries := []portainer.Registry{createNewRegistry("docker.io", "USERNAME", true),
|
||||
createNewRegistry("hub-mirror.c.163.com", "", false)}
|
||||
r, err := findBestMatchRegistry(image, registries)
|
||||
require.NoError(t, err)
|
||||
is.NotNil(r)
|
||||
is.True(r.Authentication)
|
||||
is.Equal("docker.io", r.URL)
|
||||
})
|
||||
|
||||
t.Run("", func(t *testing.T) {
|
||||
image := "portainer/portainer-ee:latest"
|
||||
registries := []portainer.Registry{createNewRegistry("docker.io", "", true)}
|
||||
r, err := findBestMatchRegistry(image, registries)
|
||||
require.NoError(t, err)
|
||||
is.NotNil(r)
|
||||
is.True(r.Authentication)
|
||||
is.Equal("docker.io", r.URL)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindBestMatchRegistryCachesResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repository := "caching-test/nginx:latest"
|
||||
registries := []portainer.Registry{createNewRegistry("docker.io", "", true)}
|
||||
|
||||
r, err := findBestMatchRegistry(repository, registries)
|
||||
require.NoError(t, err)
|
||||
|
||||
cached, err := cachedRegistry(repository)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, r.URL, cached.URL)
|
||||
require.Equal(t, r.Authentication, cached.Authentication)
|
||||
}
|
||||
|
||||
func createNewRegistry(domain, username string, auth bool) portainer.Registry {
|
||||
registry := portainer.Registry{
|
||||
URL: domain,
|
||||
Authentication: auth,
|
||||
Username: username,
|
||||
}
|
||||
|
||||
if domain == "docker.io" {
|
||||
registry.Type = portainer.DockerHubRegistry
|
||||
}
|
||||
|
||||
return registry
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
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"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// Status constants
|
||||
const (
|
||||
Processing = Status("processing")
|
||||
Outdated = Status("outdated")
|
||||
Updated = Status("updated")
|
||||
Skipped = Status("skipped")
|
||||
Preparing = Status("preparing")
|
||||
Error = Status("error")
|
||||
)
|
||||
|
||||
const (
|
||||
errorStatusCacheTTL = 5 * time.Minute
|
||||
maxConcurrentStatusChecks = 8
|
||||
)
|
||||
|
||||
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))
|
||||
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(maxConcurrentStatusChecks)
|
||||
|
||||
containerStatus := func(ct types.Container) Status {
|
||||
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, swarmNodeId)
|
||||
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 {
|
||||
log.Warn().Str("containerId", ct.ID).Err(err).Msg("error when fetching image status for container")
|
||||
return Error
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
for i, ct := range containers {
|
||||
g.Go(func() error {
|
||||
statuses[i] = containerStatus(ct)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
_ = g.Wait()
|
||||
|
||||
return AggregateImageStatus(statuses)
|
||||
}
|
||||
|
||||
func AggregateImageStatus(statuses []Status) Status {
|
||||
if allMatch(statuses, Skipped) {
|
||||
return Skipped
|
||||
}
|
||||
|
||||
if allMatch(statuses, Preparing) {
|
||||
return Preparing
|
||||
}
|
||||
|
||||
if slices.Contains(statuses, Outdated) {
|
||||
return Outdated
|
||||
} else if slices.Contains(statuses, Processing) {
|
||||
return Processing
|
||||
} else if slices.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(ctx, 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(ctx context.Context, 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(ctx, *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 CacheErrorImageStatus(resourceID string) {
|
||||
statusCache.Set(resourceID, Error, errorStatusCacheTTL)
|
||||
}
|
||||
|
||||
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 allMatch(statuses []Status, status Status) bool {
|
||||
if len(statuses) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, s := range statuses {
|
||||
if s != status {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAggregateImageStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := func(statuses []Status, expected Status) {
|
||||
t.Helper()
|
||||
require.Equal(t, expected, AggregateImageStatus(statuses))
|
||||
}
|
||||
|
||||
f([]Status{Skipped, Skipped, Skipped}, Skipped)
|
||||
f([]Status{Preparing, Preparing}, Preparing)
|
||||
f([]Status{Updated, Outdated, Processing, Error}, Outdated)
|
||||
f([]Status{Updated, Processing, Error}, Processing)
|
||||
f([]Status{Updated, Error}, Error)
|
||||
f([]Status{Updated, Updated}, Updated)
|
||||
f([]Status{}, Updated)
|
||||
f([]Status{Updated, Skipped}, Updated)
|
||||
}
|
||||
|
||||
func TestCachedResourceImageStatusMiss(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := CachedResourceImageStatus("status-test-miss-key")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCachedResourceImageStatusHitAndEvict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := "status-test-hit-evict-key"
|
||||
|
||||
CacheResourceImageStatus(key, Updated)
|
||||
|
||||
s, err := CachedResourceImageStatus(key)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Updated, s)
|
||||
|
||||
EvictImageStatus(key)
|
||||
|
||||
_, err = CachedResourceImageStatus(key)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCacheErrorImageStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := "status-test-error-key"
|
||||
|
||||
CacheErrorImageStatus(key)
|
||||
|
||||
s, err := CachedResourceImageStatus(key)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Error, s)
|
||||
|
||||
EvictImageStatus(key)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
"github.com/portainer/portainer/pkg/snapshot"
|
||||
)
|
||||
|
||||
// Snapshotter represents a service used to create environment(endpoint) snapshots
|
||||
type Snapshotter struct {
|
||||
clientFactory *dockerclient.ClientFactory
|
||||
}
|
||||
|
||||
// NewSnapshotter returns a new Snapshotter instance
|
||||
func NewSnapshotter(clientFactory *dockerclient.ClientFactory) *Snapshotter {
|
||||
return &Snapshotter{
|
||||
clientFactory: clientFactory,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSnapshot creates a snapshot of a specific Docker environment(endpoint)
|
||||
func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) {
|
||||
cli, err := snapshotter.clientFactory.CreateClient(endpoint, "", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer logs.CloseAndLogErr(cli)
|
||||
|
||||
return snapshot.CreateDockerSnapshot(cli)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
type ContainerStats struct {
|
||||
Running int `json:"running"`
|
||||
Stopped int `json:"stopped"`
|
||||
Healthy int `json:"healthy"`
|
||||
Unhealthy int `json:"unhealthy"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type DockerClient interface {
|
||||
ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error)
|
||||
}
|
||||
|
||||
func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool, containers []container.Summary) (ContainerStats, error) {
|
||||
if isSwarm {
|
||||
return CalculateContainerStatsForSwarm(containers), nil
|
||||
}
|
||||
|
||||
var running, stopped, healthy, unhealthy int
|
||||
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 5)
|
||||
|
||||
var aggErr error
|
||||
var aggMu sync.Mutex
|
||||
|
||||
var processedCount int
|
||||
for i := range containers {
|
||||
id := containers[i].ID
|
||||
|
||||
semaphore <- struct{}{}
|
||||
wg.Go(func() {
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
containerInspection, err := cli.ContainerInspect(ctx, id)
|
||||
stat := ContainerStats{}
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
// An edge case is reported that Docker can list containers with no names,
|
||||
// but when inspecting a container with specific ID and it is not found.
|
||||
// In this case, we can safely ignore the error.
|
||||
// ref@https://linear.app/portainer/issue/BE-12567/500-error-when-loading-docker-dashboard-in-portainer
|
||||
return
|
||||
}
|
||||
|
||||
aggMu.Lock()
|
||||
aggErr = errors.Join(aggErr, err)
|
||||
processedCount++
|
||||
aggMu.Unlock()
|
||||
return
|
||||
}
|
||||
stat = getContainerStatus(containerInspection.State)
|
||||
|
||||
mu.Lock()
|
||||
running += stat.Running
|
||||
stopped += stat.Stopped
|
||||
healthy += stat.Healthy
|
||||
unhealthy += stat.Unhealthy
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return ContainerStats{
|
||||
Running: running,
|
||||
Stopped: stopped,
|
||||
Healthy: healthy,
|
||||
Unhealthy: unhealthy,
|
||||
Total: processedCount,
|
||||
}, aggErr
|
||||
}
|
||||
|
||||
func getContainerStatus(state *container.State) ContainerStats {
|
||||
stat := ContainerStats{}
|
||||
if state == nil {
|
||||
return stat
|
||||
}
|
||||
|
||||
switch state.Status {
|
||||
case container.StateRunning:
|
||||
stat.Running++
|
||||
case container.StateExited, container.StateDead:
|
||||
stat.Stopped++
|
||||
}
|
||||
|
||||
if state.Health != nil {
|
||||
switch state.Health.Status {
|
||||
case container.Healthy:
|
||||
stat.Healthy++
|
||||
case container.Unhealthy:
|
||||
stat.Unhealthy++
|
||||
}
|
||||
}
|
||||
|
||||
return stat
|
||||
}
|
||||
|
||||
// This is a temporary workaround to calculate container stats for Swarm
|
||||
// TODO: Remove this once we have a proper way to calculate container stats for Swarm
|
||||
func CalculateContainerStatsForSwarm(containers []container.Summary) ContainerStats {
|
||||
var running, stopped, healthy, unhealthy int
|
||||
for _, container := range containers {
|
||||
switch container.State {
|
||||
case "running":
|
||||
running++
|
||||
case "exited", "stopped":
|
||||
stopped++
|
||||
}
|
||||
|
||||
if strings.Contains(container.Status, "(healthy)") {
|
||||
healthy++
|
||||
} else if strings.Contains(container.Status, "(unhealthy)") {
|
||||
unhealthy++
|
||||
}
|
||||
}
|
||||
|
||||
return ContainerStats{
|
||||
Running: running,
|
||||
Stopped: stopped,
|
||||
Healthy: healthy,
|
||||
Unhealthy: unhealthy,
|
||||
Total: len(containers),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockDockerClient implements the DockerClient interface for testing
|
||||
type MockDockerClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockDockerClient) ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) {
|
||||
args := m.Called(ctx, containerID)
|
||||
return args.Get(0).(container.InspectResponse), args.Error(1)
|
||||
}
|
||||
|
||||
func TestCalculateContainerStats(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockClient := new(MockDockerClient)
|
||||
|
||||
// Test containers - using enough containers to test concurrent processing
|
||||
containers := []container.Summary{
|
||||
{ID: "container1"},
|
||||
{ID: "container2"},
|
||||
{ID: "container3"},
|
||||
{ID: "container4"},
|
||||
{ID: "container5"},
|
||||
{ID: "container6"},
|
||||
{ID: "container7"},
|
||||
{ID: "container8"},
|
||||
{ID: "container9"},
|
||||
{ID: "container10"},
|
||||
{ID: "container11"},
|
||||
}
|
||||
|
||||
// Setup mock expectations with different container states to test various scenarios
|
||||
containerStates := []struct {
|
||||
id string
|
||||
status string
|
||||
health *container.Health
|
||||
expected ContainerStats
|
||||
}{
|
||||
{"container1", container.StateRunning, &container.Health{Status: container.Healthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 1, Unhealthy: 0}},
|
||||
{"container2", container.StateRunning, &container.Health{Status: container.Unhealthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 1}},
|
||||
{"container3", container.StateRunning, nil, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 0}},
|
||||
{"container4", container.StateExited, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
{"container5", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
{"container6", container.StateRunning, &container.Health{Status: container.Healthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 1, Unhealthy: 0}},
|
||||
{"container7", container.StateRunning, &container.Health{Status: container.Unhealthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 1}},
|
||||
{"container8", container.StateExited, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
{"container9", container.StateRunning, nil, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 0}},
|
||||
{"container10", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
}
|
||||
|
||||
// Setup mock expectations for all containers with artificial delays to simulate real Docker calls
|
||||
for _, state := range containerStates {
|
||||
mockClient.On("ContainerInspect", mock.Anything, state.id).Return(container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Status: state.status,
|
||||
Health: state.health,
|
||||
},
|
||||
},
|
||||
}, nil).After(30 * time.Millisecond) // Simulate 30ms Docker API call
|
||||
}
|
||||
|
||||
// Setup mock expectation for a container that returns NotFound error
|
||||
mockClient.On("ContainerInspect", mock.Anything, "container11").Return(container.InspectResponse{}, fmt.Errorf("No such container: %w", errdefs.ErrNotFound)).After(50 * time.Millisecond)
|
||||
|
||||
// Call the function and measure time
|
||||
startTime := time.Now()
|
||||
stats, err := CalculateContainerStats(t.Context(), mockClient, false, containers)
|
||||
require.NoError(t, err, "failed to calculate container stats")
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// Assert results
|
||||
assert.Equal(t, 6, stats.Running)
|
||||
assert.Equal(t, 4, stats.Stopped)
|
||||
assert.Equal(t, 2, stats.Healthy)
|
||||
assert.Equal(t, 2, stats.Unhealthy)
|
||||
assert.Equal(t, 10, stats.Total)
|
||||
|
||||
// Verify concurrent processing by checking that all mock calls were made
|
||||
mockClient.AssertExpectations(t)
|
||||
|
||||
// Test concurrency: With 5 workers and 10 containers taking 50ms each:
|
||||
// Sequential would take: 10 * 50ms = 500ms
|
||||
sequentialTime := 10 * 50 * time.Millisecond
|
||||
|
||||
// Verify that concurrent processing is actually faster than sequential
|
||||
// Allow some overhead for goroutine scheduling
|
||||
assert.Less(t, duration, sequentialTime, "Concurrent processing should be faster than sequential")
|
||||
// Concurrent should take: ~100-150ms (depending on scheduling)
|
||||
assert.Less(t, duration, 150*time.Millisecond, "Concurrent processing should be significantly faster")
|
||||
assert.Greater(t, duration, 100*time.Millisecond, "Concurrent processing should be longer than 100ms")
|
||||
}
|
||||
|
||||
func TestCalculateContainerStatsAllErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockClient := new(MockDockerClient)
|
||||
|
||||
// Test containers
|
||||
containers := []container.Summary{
|
||||
{ID: "container1"},
|
||||
{ID: "container2"},
|
||||
}
|
||||
|
||||
// Setup mock expectations with all calls returning errors
|
||||
mockClient.On("ContainerInspect", mock.Anything, "container1").Return(container.InspectResponse{}, errors.New("network error"))
|
||||
mockClient.On("ContainerInspect", mock.Anything, "container2").Return(container.InspectResponse{}, errors.New("permission denied"))
|
||||
|
||||
// Call the function
|
||||
stats, err := CalculateContainerStats(t.Context(), mockClient, false, containers)
|
||||
|
||||
// Assert that an error was returned
|
||||
require.Error(t, err, "should return error when all containers fail to inspect")
|
||||
assert.Contains(t, err.Error(), "network error", "error should contain one of the original error messages")
|
||||
assert.Contains(t, err.Error(), "permission denied", "error should contain the other original error message")
|
||||
|
||||
// Assert that stats are zero since no containers were successfully processed
|
||||
expectedStats := ContainerStats{
|
||||
Running: 0,
|
||||
Stopped: 0,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
Total: 2, // total containers processed
|
||||
}
|
||||
assert.Equal(t, expectedStats, stats)
|
||||
|
||||
// Verify all mock calls were made
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGetContainerStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
state *container.State
|
||||
expected ContainerStats
|
||||
}{
|
||||
{
|
||||
name: "running healthy container",
|
||||
state: &container.State{
|
||||
Status: container.StateRunning,
|
||||
Health: &container.Health{
|
||||
Status: container.Healthy,
|
||||
},
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 1,
|
||||
Stopped: 0,
|
||||
Healthy: 1,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "running unhealthy container",
|
||||
state: &container.State{
|
||||
Status: container.StateRunning,
|
||||
Health: &container.Health{
|
||||
Status: container.Unhealthy,
|
||||
},
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 1,
|
||||
Stopped: 0,
|
||||
Healthy: 0,
|
||||
Unhealthy: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "running container without health check",
|
||||
state: &container.State{
|
||||
Status: container.StateRunning,
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 1,
|
||||
Stopped: 0,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exited container",
|
||||
state: &container.State{
|
||||
Status: container.StateExited,
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 0,
|
||||
Stopped: 1,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dead container",
|
||||
state: &container.State{
|
||||
Status: container.StateDead,
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 0,
|
||||
Stopped: 1,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil state",
|
||||
state: nil,
|
||||
expected: ContainerStats{
|
||||
Running: 0,
|
||||
Stopped: 0,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
stat := getContainerStatus(testCase.state)
|
||||
assert.Equal(t, testCase.expected, stat)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateContainerStatsForSwarm(t *testing.T) {
|
||||
t.Parallel()
|
||||
containers := []container.Summary{
|
||||
{State: "running"},
|
||||
{State: "running", Status: "Up 5 minutes (healthy)"},
|
||||
{State: "exited"},
|
||||
{State: "stopped"},
|
||||
{State: "running", Status: "Up 10 minutes"},
|
||||
{State: "running", Status: "Up about an hour (unhealthy)"},
|
||||
}
|
||||
|
||||
stats := CalculateContainerStatsForSwarm(containers)
|
||||
|
||||
assert.Equal(t, 4, stats.Running)
|
||||
assert.Equal(t, 2, stats.Stopped)
|
||||
assert.Equal(t, 1, stats.Healthy)
|
||||
assert.Equal(t, 1, stats.Unhealthy)
|
||||
assert.Equal(t, 6, stats.Total)
|
||||
}
|
||||
Reference in New Issue
Block a user