diff --git a/cmd/compose/images.go b/cmd/compose/images.go index f06f8784c..458bbccd7 100644 --- a/cmd/compose/images.go +++ b/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 = "" } - _, _ = 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") } diff --git a/go.mod b/go.mod index d5c4e9ebb..262739cd9 100644 --- a/go.mod +++ b/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 diff --git a/pkg/api/api.go b/pkg/api/api.go index 16b1f9344..04b7f6c25 100644 --- a/pkg/api/api.go +++ b/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 diff --git a/pkg/compose/images.go b/pkg/compose/images.go index 81936180c..4db036266 100644 --- a/pkg/compose/images.go +++ b/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[i] = img - summary[i].ContainerName = getCanonicalContainerName(c) + 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 + } + } + + 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) { diff --git a/pkg/compose/images_test.go b/pkg/compose/images_test.go index 62fbd1dc5..a7a1d5e9a 100644 --- a/pkg/compose/images_test.go +++ b/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) diff --git a/pkg/mocks/mock_docker_api.go b/pkg/mocks/mock_docker_api.go index 5a73aab3d..818d84583 100644 --- a/pkg/mocks/mock_docker_api.go +++ b/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 diff --git a/pkg/mocks/mock_docker_cli.go b/pkg/mocks/mock_docker_cli.go index f08d62d4a..34ccb0446 100644 --- a/pkg/mocks/mock_docker_cli.go +++ b/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() diff --git a/pkg/mocks/mock_docker_compose_api.go b/pkg/mocks/mock_docker_compose_api.go index adf811a90..2fd3a3e72 100644 --- a/pkg/mocks/mock_docker_compose_api.go +++ b/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 }