Browse Source

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

Signed-off-by: Nicolas De Loof <[email protected]>
Nicolas De Loof 6 months ago
parent
commit
eb3074bbda

+ 9 - 8
cmd/compose/images.go

@@ -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")
 }

+ 1 - 1
go.mod

@@ -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

+ 8 - 7
pkg/api/api.go

@@ -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
@@ -535,12 +536,12 @@ type ContainerProcSummary struct {
 
 // ImageSummary holds container image description
 type ImageSummary struct {
-	ID            string
-	ContainerName string
-	Repository    string
-	Tag           string
-	Size          int64
-	LastTagTime   time.Time
+	ID          string
+	Repository  string
+	Tag         string
+	Platform    platforms.Platform
+	Size        int64
+	LastTagTime time.Time
 }
 
 // ServiceStatus hold status about a service

+ 54 - 17
pkg/compose/images.go

@@ -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()
+				}
+			}
+
+			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
+		})
 	}
-	return summary, nil
+
+	err = eg.Wait()
+	return summary, err
 }
 
 func (s *composeService) getImageSummaries(ctx context.Context, repoTags []string) (map[string]api.ImageSummary, error) {

+ 19 - 20
pkg/compose/images_test.go

@@ -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,27 +56,24 @@ func TestImages(t *testing.T) {
 
 	images, err := tested.Images(ctx, strings.ToLower(testProject), compose.ImagesOptions{})
 
-	expected := []compose.ImageSummary{
-		{
-			ID:            "image1",
-			ContainerName: "123",
-			Repository:    "foo",
-			Tag:           "1",
-			Size:          12345,
+	expected := map[string]compose.ImageSummary{
+		"123": {
+			ID:         "image1",
+			Repository: "foo",
+			Tag:        "1",
+			Size:       12345,
 		},
-		{
-			ID:            "image2",
-			ContainerName: "456",
-			Repository:    "bar",
-			Tag:           "2",
-			Size:          67890,
+		"456": {
+			ID:         "image2",
+			Repository: "bar",
+			Tag:        "2",
+			Size:       67890,
 		},
-		{
-			ID:            "image1",
-			ContainerName: "789",
-			Repository:    "foo",
-			Tag:           "1",
-			Size:          12345,
+		"789": {
+			ID:         "image1",
+			Repository: "foo",
+			Tag:        "1",
+			Size:       12345,
 		},
 	}
 	assert.NilError(t, err)

+ 0 - 1
pkg/mocks/mock_docker_api.go

@@ -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
 

+ 3 - 51
pkg/mocks/mock_docker_cli.go

@@ -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()

+ 2 - 3
pkg/mocks/mock_docker_compose_api.go

@@ -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
 }