include platform and creation date listing image used by running compose application

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2025-05-21 11:18:57 +02:00 committed by Guillaume Lours
parent f4fc010d6b
commit eb3074bbda
8 changed files with 96 additions and 108 deletions

View File

@ -20,10 +20,12 @@ import (
"context"
"fmt"
"io"
"maps"
"slices"
"sort"
"strings"
"time"
"github.com/containerd/platforms"
"github.com/docker/cli/cli/command"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units"
@ -86,13 +88,10 @@ func runImages(ctx context.Context, dockerCli command.Cli, backend api.Service,
return nil
}
sort.Slice(images, func(i, j int) bool {
return images[i].ContainerName < images[j].ContainerName
})
return formatter.Print(images, opts.Format, dockerCli.Out(),
func(w io.Writer) {
for _, img := range images {
for _, container := range slices.Sorted(maps.Keys(images)) {
img := images[container]
id := stringid.TruncateID(img.ID)
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
repo := img.Repository
@ -103,8 +102,10 @@ func runImages(ctx context.Context, dockerCli command.Cli, backend api.Service,
if tag == "" {
tag = "<none>"
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", img.ContainerName, repo, tag, id, size)
created := units.HumanDuration(time.Now().UTC().Sub(img.LastTagTime)) + " ago"
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
container, repo, tag, platforms.Format(img.Platform), id, size, created)
}
},
"CONTAINER", "REPOSITORY", "TAG", "IMAGE ID", "SIZE")
"CONTAINER", "REPOSITORY", "TAG", "PLATFORM", "IMAGE ID", "SIZE", "CREATED")
}

2
go.mod
View File

@ -43,7 +43,6 @@ require (
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
github.com/stretchr/testify v1.10.0
github.com/theupdateframework/notary v0.7.0
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0
go.opentelemetry.io/otel v1.35.0
@ -161,6 +160,7 @@ require (
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect
github.com/tonistiigi/fsutil v0.0.0-20250417144416-3f76f8130144 // indirect
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect

View File

@ -24,6 +24,7 @@ import (
"time"
"github.com/compose-spec/compose-go/v2/types"
"github.com/containerd/platforms"
"github.com/docker/cli/opts"
)
@ -78,7 +79,7 @@ type Service interface {
// Publish executes the equivalent to a `compose publish`
Publish(ctx context.Context, project *types.Project, repository string, options PublishOptions) error
// Images executes the equivalent of a `compose images`
Images(ctx context.Context, projectName string, options ImagesOptions) ([]ImageSummary, error)
Images(ctx context.Context, projectName string, options ImagesOptions) (map[string]ImageSummary, error)
// MaxConcurrency defines upper limit for concurrent operations against engine API
MaxConcurrency(parallel int)
// DryRunMode defines if dry run applies to the command
@ -536,9 +537,9 @@ type ContainerProcSummary struct {
// ImageSummary holds container image description
type ImageSummary struct {
ID string
ContainerName string
Repository string
Tag string
Platform platforms.Platform
Size int64
LastTagTime time.Time
}

View File

@ -24,15 +24,18 @@ import (
"sync"
cerrdefs "github.com/containerd/errdefs"
"github.com/containerd/platforms"
"github.com/distribution/reference"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/versions"
"github.com/docker/docker/client"
"golang.org/x/sync/errgroup"
"github.com/docker/compose/v2/pkg/api"
)
func (s *composeService) Images(ctx context.Context, projectName string, options api.ImagesOptions) ([]api.ImageSummary, error) {
func (s *composeService) Images(ctx context.Context, projectName string, options api.ImagesOptions) (map[string]api.ImageSummary, error) {
projectName = strings.ToLower(projectName)
allContainers, err := s.apiClient().ContainerList(ctx, container.ListOptions{
All: true,
@ -53,27 +56,61 @@ func (s *composeService) Images(ctx context.Context, projectName string, options
containers = allContainers
}
images := []string{}
for _, c := range containers {
if !slices.Contains(images, c.Image) {
images = append(images, c.Image)
}
}
imageSummaries, err := s.getImageSummaries(ctx, images)
version, err := s.RuntimeVersion(ctx)
if err != nil {
return nil, err
}
summary := make([]api.ImageSummary, len(containers))
for i, c := range containers {
img, ok := imageSummaries[c.Image]
if !ok {
return nil, fmt.Errorf("failed to retrieve image for container %s", getCanonicalContainerName(c))
withPlatform := versions.GreaterThanOrEqualTo(version, "1.49")
summary := map[string]api.ImageSummary{}
var mux sync.Mutex
eg, ctx := errgroup.WithContext(ctx)
for _, c := range containers {
eg.Go(func() error {
image, err := s.apiClient().ImageInspect(ctx, c.Image)
if err != nil {
return err
}
id := image.ID // platform-specific image ID can't be combined with image tag, see https://github.com/moby/moby/issues/49995
if withPlatform && c.ImageManifestDescriptor != nil && c.ImageManifestDescriptor.Platform != nil {
image, err = s.apiClient().ImageInspect(ctx, c.Image, client.ImageInspectWithPlatform(c.ImageManifestDescriptor.Platform))
if err != nil {
return err
}
}
summary[i] = img
summary[i].ContainerName = getCanonicalContainerName(c)
var repository, tag string
ref, err := reference.ParseDockerRef(c.Image)
if err == nil {
// ParseDockerRef will reject a local image ID
repository = reference.FamiliarName(ref)
if tagged, ok := ref.(reference.Tagged); ok {
tag = tagged.Tag()
}
return summary, nil
}
mux.Lock()
defer mux.Unlock()
summary[getCanonicalContainerName(c)] = api.ImageSummary{
ID: id,
Repository: repository,
Tag: tag,
Platform: platforms.Platform{
Architecture: image.Architecture,
OS: image.Os,
OSVersion: image.OsVersion,
Variant: image.Variant,
},
Size: image.Size,
LastTagTime: image.Metadata.LastTagTime,
}
return nil
})
}
err = eg.Wait()
return summary, err
}
func (s *composeService) getImageSummaries(ctx context.Context, repoTags []string) (map[string]api.ImageSummary, error) {

View File

@ -21,6 +21,7 @@ import (
"strings"
"testing"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
@ -42,9 +43,10 @@ func TestImages(t *testing.T) {
ctx := context.Background()
args := filters.NewArgs(projectFilter(strings.ToLower(testProject)))
listOpts := container.ListOptions{All: true, Filters: args}
api.EXPECT().ServerVersion(gomock.Any()).Return(types.Version{APIVersion: "1.96"}, nil).AnyTimes()
image1 := imageInspect("image1", "foo:1", 12345)
image2 := imageInspect("image2", "bar:2", 67890)
api.EXPECT().ImageInspect(anyCancellableContext(), "foo:1").Return(image1, nil)
api.EXPECT().ImageInspect(anyCancellableContext(), "foo:1").Return(image1, nil).MaxTimes(2)
api.EXPECT().ImageInspect(anyCancellableContext(), "bar:2").Return(image2, nil)
c1 := containerDetail("service1", "123", "running", "foo:1")
c2 := containerDetail("service1", "456", "running", "bar:2")
@ -54,24 +56,21 @@ func TestImages(t *testing.T) {
images, err := tested.Images(ctx, strings.ToLower(testProject), compose.ImagesOptions{})
expected := []compose.ImageSummary{
{
expected := map[string]compose.ImageSummary{
"123": {
ID: "image1",
ContainerName: "123",
Repository: "foo",
Tag: "1",
Size: 12345,
},
{
"456": {
ID: "image2",
ContainerName: "456",
Repository: "bar",
Tag: "2",
Size: 67890,
},
{
"789": {
ID: "image1",
ContainerName: "789",
Repository: "foo",
Tag: "1",
Size: 12345,

View File

@ -5,7 +5,6 @@
//
// mockgen -destination pkg/mocks/mock_docker_api.go -package mocks github.com/docker/docker/client APIClient
//
// Package mocks is a generated GoMock package.
package mocks

View File

@ -5,7 +5,6 @@
//
// mockgen -destination pkg/mocks/mock_docker_cli.go -package mocks github.com/docker/cli/cli/command Cli
//
// Package mocks is a generated GoMock package.
package mocks
@ -16,12 +15,8 @@ import (
configfile "github.com/docker/cli/cli/config/configfile"
docker "github.com/docker/cli/cli/context/docker"
store "github.com/docker/cli/cli/context/store"
store0 "github.com/docker/cli/cli/manifest/store"
client "github.com/docker/cli/cli/registry/client"
streams "github.com/docker/cli/cli/streams"
trust "github.com/docker/cli/cli/trust"
client0 "github.com/docker/docker/client"
client1 "github.com/theupdateframework/notary/client"
client "github.com/docker/docker/client"
metric "go.opentelemetry.io/otel/metric"
resource "go.opentelemetry.io/otel/sdk/resource"
trace "go.opentelemetry.io/otel/trace"
@ -85,10 +80,10 @@ func (mr *MockCliMockRecorder) BuildKitEnabled() *gomock.Call {
}
// Client mocks base method.
func (m *MockCli) Client() client0.APIClient {
func (m *MockCli) Client() client.APIClient {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Client")
ret0, _ := ret[0].(client0.APIClient)
ret0, _ := ret[0].(client.APIClient)
return ret0
}
@ -224,20 +219,6 @@ func (mr *MockCliMockRecorder) In() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "In", reflect.TypeOf((*MockCli)(nil).In))
}
// ManifestStore mocks base method.
func (m *MockCli) ManifestStore() store0.Store {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ManifestStore")
ret0, _ := ret[0].(store0.Store)
return ret0
}
// ManifestStore indicates an expected call of ManifestStore.
func (mr *MockCliMockRecorder) ManifestStore() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ManifestStore", reflect.TypeOf((*MockCli)(nil).ManifestStore))
}
// MeterProvider mocks base method.
func (m *MockCli) MeterProvider() metric.MeterProvider {
m.ctrl.T.Helper()
@ -252,21 +233,6 @@ func (mr *MockCliMockRecorder) MeterProvider() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MeterProvider", reflect.TypeOf((*MockCli)(nil).MeterProvider))
}
// NotaryClient mocks base method.
func (m *MockCli) NotaryClient(arg0 trust.ImageRefAndAuth, arg1 []string) (client1.Repository, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "NotaryClient", arg0, arg1)
ret0, _ := ret[0].(client1.Repository)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// NotaryClient indicates an expected call of NotaryClient.
func (mr *MockCliMockRecorder) NotaryClient(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotaryClient", reflect.TypeOf((*MockCli)(nil).NotaryClient), arg0, arg1)
}
// Out mocks base method.
func (m *MockCli) Out() *streams.Out {
m.ctrl.T.Helper()
@ -281,20 +247,6 @@ func (mr *MockCliMockRecorder) Out() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Out", reflect.TypeOf((*MockCli)(nil).Out))
}
// RegistryClient mocks base method.
func (m *MockCli) RegistryClient(arg0 bool) client.RegistryClient {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RegistryClient", arg0)
ret0, _ := ret[0].(client.RegistryClient)
return ret0
}
// RegistryClient indicates an expected call of RegistryClient.
func (mr *MockCliMockRecorder) RegistryClient(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegistryClient", reflect.TypeOf((*MockCli)(nil).RegistryClient), arg0)
}
// Resource mocks base method.
func (m *MockCli) Resource() *resource.Resource {
m.ctrl.T.Helper()

View File

@ -5,7 +5,6 @@
//
// mockgen -destination pkg/mocks/mock_docker_compose_api.go -package mocks -source=./pkg/api/api.go Service
//
// Package mocks is a generated GoMock package.
package mocks
@ -199,10 +198,10 @@ func (mr *MockServiceMockRecorder) Generate(ctx, options any) *gomock.Call {
}
// Images mocks base method.
func (m *MockService) Images(ctx context.Context, projectName string, options api.ImagesOptions) ([]api.ImageSummary, error) {
func (m *MockService) Images(ctx context.Context, projectName string, options api.ImagesOptions) (map[string]api.ImageSummary, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Images", ctx, projectName, options)
ret0, _ := ret[0].([]api.ImageSummary)
ret0, _ := ret[0].(map[string]api.ImageSummary)
ret1, _ := ret[1].(error)
return ret0, ret1
}