Files
Pulse/internal/dockeragent/test_helpers_test.go
rcourtman 053a40d7df fix: Docker container update detection showing false positives
Fixed an issue where all Docker containers were showing 'click to update'
even when they were up to date. The root cause was comparing the wrong
digest types:

- Previously: Compared ImageID (local config hash) vs registry manifest digest
- Now: Uses RepoDigests from image inspect, which is the actual manifest
  digest that Docker received from the registry when pulling the image

For multi-arch images, the registry returns a manifest list digest, while
Docker stores the platform-specific image config digest locally. These
will never match, causing false positives for all multi-arch images.

Changes:
- Added ImageInspectWithRaw to dockerClient interface
- Added getImageRepoDigest method to extract RepoDigest from image
- Added matchesImageReference helper for Docker Hub naming conventions
- Added tests for matchesImageReference

Fixes #955
2025-12-29 13:49:04 +00:00

177 lines
7.0 KiB
Go

package dockeragent
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"testing"
containertypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
swarmtypes "github.com/docker/docker/api/types/swarm"
systemtypes "github.com/docker/docker/api/types/system"
"github.com/opencontainers/image-spec/specs-go/v1"
)
type fakeDockerClient struct {
daemonHost string
infoFunc func(ctx context.Context) (systemtypes.Info, error)
containerListFunc func(ctx context.Context, opts containertypes.ListOptions) ([]containertypes.Summary, error)
containerInspectWithRawFn func(ctx context.Context, id string, size bool) (containertypes.InspectResponse, []byte, error)
containerStatsOneShotFn func(ctx context.Context, id string) (containertypes.StatsResponseReader, error)
containerInspectFn func(ctx context.Context, id string) (containertypes.InspectResponse, error)
imagePullFn func(ctx context.Context, ref string, opts image.PullOptions) (io.ReadCloser, error)
containerStopFn func(ctx context.Context, id string, opts containertypes.StopOptions) error
containerRenameFn func(ctx context.Context, id, newName string) error
containerCreateFn func(ctx context.Context, config *containertypes.Config, hostConfig *containertypes.HostConfig, networkingConfig *network.NetworkingConfig, platform *v1.Platform, containerName string) (containertypes.CreateResponse, error)
networkConnectFn func(ctx context.Context, netName, containerID string, endpoint *network.EndpointSettings) error
containerStartFn func(ctx context.Context, id string, opts containertypes.StartOptions) error
containerRemoveFn func(ctx context.Context, id string, opts containertypes.RemoveOptions) error
serviceListFn func(ctx context.Context, opts swarmtypes.ServiceListOptions) ([]swarmtypes.Service, error)
taskListFn func(ctx context.Context, opts swarmtypes.TaskListOptions) ([]swarmtypes.Task, error)
imageInspectWithRawFn func(ctx context.Context, imageID string) (image.InspectResponse, []byte, error)
closeFn func() error
}
func (f *fakeDockerClient) Info(ctx context.Context) (systemtypes.Info, error) {
if f.infoFunc == nil {
return systemtypes.Info{}, errors.New("unexpected Info call")
}
return f.infoFunc(ctx)
}
func (f *fakeDockerClient) DaemonHost() string {
return f.daemonHost
}
func (f *fakeDockerClient) ContainerList(ctx context.Context, opts containertypes.ListOptions) ([]containertypes.Summary, error) {
if f.containerListFunc == nil {
return nil, errors.New("unexpected ContainerList call")
}
return f.containerListFunc(ctx, opts)
}
func (f *fakeDockerClient) ContainerInspectWithRaw(ctx context.Context, id string, size bool) (containertypes.InspectResponse, []byte, error) {
if f.containerInspectWithRawFn == nil {
return containertypes.InspectResponse{}, nil, errors.New("unexpected ContainerInspectWithRaw call")
}
return f.containerInspectWithRawFn(ctx, id, size)
}
func (f *fakeDockerClient) ContainerStatsOneShot(ctx context.Context, id string) (containertypes.StatsResponseReader, error) {
if f.containerStatsOneShotFn == nil {
return containertypes.StatsResponseReader{}, errors.New("unexpected ContainerStatsOneShot call")
}
return f.containerStatsOneShotFn(ctx, id)
}
func (f *fakeDockerClient) ContainerInspect(ctx context.Context, id string) (containertypes.InspectResponse, error) {
if f.containerInspectFn == nil {
return containertypes.InspectResponse{}, errors.New("unexpected ContainerInspect call")
}
return f.containerInspectFn(ctx, id)
}
func (f *fakeDockerClient) ImagePull(ctx context.Context, ref string, opts image.PullOptions) (io.ReadCloser, error) {
if f.imagePullFn == nil {
return nil, errors.New("unexpected ImagePull call")
}
return f.imagePullFn(ctx, ref, opts)
}
func (f *fakeDockerClient) ContainerStop(ctx context.Context, id string, opts containertypes.StopOptions) error {
if f.containerStopFn == nil {
return errors.New("unexpected ContainerStop call")
}
return f.containerStopFn(ctx, id, opts)
}
func (f *fakeDockerClient) ContainerRename(ctx context.Context, id, newName string) error {
if f.containerRenameFn == nil {
return errors.New("unexpected ContainerRename call")
}
return f.containerRenameFn(ctx, id, newName)
}
func (f *fakeDockerClient) ContainerCreate(ctx context.Context, config *containertypes.Config, hostConfig *containertypes.HostConfig, networkingConfig *network.NetworkingConfig, platform *v1.Platform, containerName string) (containertypes.CreateResponse, error) {
if f.containerCreateFn == nil {
return containertypes.CreateResponse{}, errors.New("unexpected ContainerCreate call")
}
return f.containerCreateFn(ctx, config, hostConfig, networkingConfig, platform, containerName)
}
func (f *fakeDockerClient) NetworkConnect(ctx context.Context, netName, containerID string, endpoint *network.EndpointSettings) error {
if f.networkConnectFn == nil {
return errors.New("unexpected NetworkConnect call")
}
return f.networkConnectFn(ctx, netName, containerID, endpoint)
}
func (f *fakeDockerClient) ContainerStart(ctx context.Context, id string, opts containertypes.StartOptions) error {
if f.containerStartFn == nil {
return errors.New("unexpected ContainerStart call")
}
return f.containerStartFn(ctx, id, opts)
}
func (f *fakeDockerClient) ContainerRemove(ctx context.Context, id string, opts containertypes.RemoveOptions) error {
if f.containerRemoveFn == nil {
return errors.New("unexpected ContainerRemove call")
}
return f.containerRemoveFn(ctx, id, opts)
}
func (f *fakeDockerClient) ServiceList(ctx context.Context, opts swarmtypes.ServiceListOptions) ([]swarmtypes.Service, error) {
if f.serviceListFn == nil {
return nil, errors.New("unexpected ServiceList call")
}
return f.serviceListFn(ctx, opts)
}
func (f *fakeDockerClient) TaskList(ctx context.Context, opts swarmtypes.TaskListOptions) ([]swarmtypes.Task, error) {
if f.taskListFn == nil {
return nil, errors.New("unexpected TaskList call")
}
return f.taskListFn(ctx, opts)
}
func (f *fakeDockerClient) ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) {
if f.imageInspectWithRawFn == nil {
// Return empty response with no RepoDigests by default (simulates locally built image)
return image.InspectResponse{}, nil, nil
}
return f.imageInspectWithRawFn(ctx, imageID)
}
func (f *fakeDockerClient) Close() error {
if f.closeFn == nil {
return nil
}
return f.closeFn()
}
func statsReader(t *testing.T, stats containertypes.StatsResponse) containertypes.StatsResponseReader {
t.Helper()
payload, err := json.Marshal(stats)
if err != nil {
t.Fatalf("marshal stats: %v", err)
}
return containertypes.StatsResponseReader{
Body: io.NopCloser(bytes.NewReader(payload)),
}
}
func swap[T any](t *testing.T, target *T, value T) {
t.Helper()
prev := *target
*target = value
t.Cleanup(func() {
*target = prev
})
}