From d9653677a2de0ce43034f52528b1b596875c87fd Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 13 Oct 2026 08:49:03 +0200 Subject: [PATCH 1/2] refactor(inspect): move the read-only operations to internal/inspect ps, ls, top, port, volumes, events and viz only read the state of a project: they need the Docker API, the list of the project's containers and the concurrency bound, nothing else of composeService. They now live in internal/inspect, which declares that need as a Backend interface. composeService implements it in a new file of pkg/compose and forwards the seven api.Inspector methods, so no other file of pkg/compose changes, and neither does the behavior: the service-level tests stay in pkg/compose and run unchanged through the forwarders. The few tiny helpers the operations share with the rest of pkg/compose (canonical container name, concurrency-limited errgroup, project filter) are copied for now: the shared kernel they belong to is a later step. Signed-off-by: Nicolas De Loof --- {pkg/compose => internal/inspect}/events.go | 6 +- internal/inspect/inspect.go | 105 +++++++++++++++++ internal/inspect/inspect_test.go | 114 +++++++++++++++++++ {pkg/compose => internal/inspect}/ls.go | 6 +- {pkg/compose => internal/inspect}/ls_test.go | 2 +- {pkg/compose => internal/inspect}/port.go | 6 +- internal/inspect/port_test.go | 61 ++++++++++ {pkg/compose => internal/inspect}/ps.go | 20 ++-- {pkg/compose => internal/inspect}/top.go | 15 ++- {pkg/compose => internal/inspect}/viz.go | 4 +- {pkg/compose => internal/inspect}/volumes.go | 8 +- pkg/compose/inspect.go | 91 +++++++++++++++ pkg/compose/port_test.go | 38 ------- 13 files changed, 404 insertions(+), 72 deletions(-) rename {pkg/compose => internal/inspect}/events.go (90%) create mode 100644 internal/inspect/inspect.go create mode 100644 internal/inspect/inspect_test.go rename {pkg/compose => internal/inspect}/ls.go (94%) rename {pkg/compose => internal/inspect}/ls_test.go (99%) rename {pkg/compose => internal/inspect}/port.go (85%) create mode 100644 internal/inspect/port_test.go rename {pkg/compose => internal/inspect}/ps.go (84%) rename {pkg/compose => internal/inspect}/top.go (74%) rename {pkg/compose => internal/inspect}/viz.go (97%) rename {pkg/compose => internal/inspect}/volumes.go (84%) create mode 100644 pkg/compose/inspect.go diff --git a/pkg/compose/events.go b/internal/inspect/events.go similarity index 90% rename from pkg/compose/events.go rename to internal/inspect/events.go index d2a4e0e42f4..ac4e849b841 100644 --- a/pkg/compose/events.go +++ b/internal/inspect/events.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package inspect import ( "context" @@ -27,9 +27,9 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) Events(ctx context.Context, projectName string, options api.EventsOptions) error { +func (s *Service) Events(ctx context.Context, projectName string, options api.EventsOptions) error { projectName = strings.ToLower(projectName) - res := s.apiClient().Events(ctx, client.EventsListOptions{ + res := s.backend.APIClient().Events(ctx, client.EventsListOptions{ Filters: projectFilter(projectName), Since: options.Since, Until: options.Until, diff --git a/internal/inspect/inspect.go b/internal/inspect/inspect.go new file mode 100644 index 00000000000..7d8567bc2f3 --- /dev/null +++ b/internal/inspect/inspect.go @@ -0,0 +1,105 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Package inspect implements the read-only operations that report on a +// project and its resources: ps, ls, top, port, volumes, events and viz. +package inspect + +import ( + "context" + "slices" + "strings" + + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + "golang.org/x/sync/errgroup" + + "github.com/docker/compose/v5/pkg/api" +) + +// OneOff tells whether the containers of `compose run` are part of a listing. +type OneOff int + +const ( + // OneOffInclude lists the one-off containers along with the services' ones. + OneOffInclude OneOff = iota + // OneOffExclude leaves the one-off containers out. + OneOffExclude +) + +// Backend is what the operations need from the engine they report on. +type Backend interface { + // APIClient talks to the Docker daemon. + APIClient() client.APIClient + // MaxConcurrency bounds the daemon calls made in parallel, 0 meaning no bound. + MaxConcurrency() int + // Containers lists the containers of a project, narrowed to services when given. + Containers(ctx context.Context, projectName string, oneOff OneOff, all bool, services ...string) ([]container.Summary, error) + // SpecifiedContainer returns the container of a service, the replica with + // the given index when it is above zero. + SpecifiedContainer(ctx context.Context, projectName string, oneOff OneOff, all bool, serviceName string, index int) (container.Summary, error) +} + +// Service runs the inspection operations on a Backend. +type Service struct { + backend Backend +} + +// New creates a Service reporting on the given Backend. +func New(backend Backend) *Service { + return &Service{backend: backend} +} + +func projectFilter(projectName string) client.Filters { + return make(client.Filters).Add("label", api.ProjectLabel+"="+projectName) +} + +// inServices keeps the containers of the given services. +func inServices(containers []container.Summary, services []string) []container.Summary { + var filtered []container.Summary + for _, ctr := range containers { + if slices.Contains(services, ctr.Labels[api.ServiceLabel]) { + filtered = append(filtered, ctr) + } + } + return filtered +} + +// limitedGroup returns an errgroup running at most maxConcurrency goroutines at once. +func limitedGroup(ctx context.Context, maxConcurrency int) (*errgroup.Group, context.Context) { + eg, ctx := errgroup.WithContext(ctx) + if maxConcurrency > 0 { + eg.SetLimit(maxConcurrency) + } + return eg, ctx +} + +// canonicalContainerName returns the name a container goes by, without the +// leading slash nor the link aliases. +func canonicalContainerName(c container.Summary) string { + if len(c.Names) == 0 { + // corner case, sometime happens on removal. return short ID as a safeguard value + return c.ID[:12] + } + // Names return container canonical name /foo + link aliases /linked_by/foo + for _, name := range c.Names { + if strings.LastIndex(name, "/") == 0 { + return name[1:] + } + } + + return strings.TrimPrefix(c.Names[0], "/") +} diff --git a/internal/inspect/inspect_test.go b/internal/inspect/inspect_test.go new file mode 100644 index 00000000000..4a863ff72ba --- /dev/null +++ b/internal/inspect/inspect_test.go @@ -0,0 +1,114 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package inspect + +import ( + "context" + "testing" + + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/mocks" +) + +// fakeBackend serves canned containers and records how it was asked for them. +type fakeBackend struct { + apiClient client.APIClient + listed []container.Summary + + oneOff OneOff + all bool + services []string + index int +} + +func (f *fakeBackend) APIClient() client.APIClient { return f.apiClient } +func (f *fakeBackend) MaxConcurrency() int { return 2 } + +func (f *fakeBackend) Containers(_ context.Context, _ string, oneOff OneOff, all bool, services ...string) ([]container.Summary, error) { + f.oneOff, f.all, f.services = oneOff, all, services + return f.listed, nil +} + +func (f *fakeBackend) SpecifiedContainer(_ context.Context, _ string, oneOff OneOff, all bool, serviceName string, index int) (container.Summary, error) { + f.oneOff, f.all, f.services, f.index = oneOff, all, []string{serviceName}, index + return f.listed[0], nil +} + +func summary(service, id string) container.Summary { + return container.Summary{ + ID: id, + Names: []string{"/" + id}, + Labels: map[string]string{api.ServiceLabel: service}, + } +} + +func TestPsOneOffContainersOnlyWithAll(t *testing.T) { + for _, all := range []bool{false, true} { + ctrl := gomock.NewController(t) + apiClient := mocks.NewMockAPIClient(ctrl) + apiClient.EXPECT().ContainerInspect(gomock.Any(), "web1", gomock.Any()).Return(client.ContainerInspectResult{}, nil) + backend := &fakeBackend{apiClient: apiClient, listed: []container.Summary{summary("web", "web1")}} + + got, err := New(backend).Ps(t.Context(), "proj", api.PsOptions{All: all}) + + assert.NilError(t, err) + assert.Equal(t, len(got), 1) + assert.Equal(t, got[0].Service, "web") + assert.Equal(t, backend.all, all) + if all { + assert.Equal(t, backend.oneOff, OneOffInclude) + } else { + assert.Equal(t, backend.oneOff, OneOffExclude) + } + } +} + +func TestPsKeepsTheRequestedServices(t *testing.T) { + ctrl := gomock.NewController(t) + apiClient := mocks.NewMockAPIClient(ctrl) + apiClient.EXPECT().ContainerInspect(gomock.Any(), "db1", gomock.Any()).Return(client.ContainerInspectResult{}, nil) + backend := &fakeBackend{apiClient: apiClient, listed: []container.Summary{summary("web", "web1"), summary("db", "db1")}} + + got, err := New(backend).Ps(t.Context(), "proj", api.PsOptions{Services: []string{"db"}}) + + assert.NilError(t, err) + assert.Equal(t, len(got), 1) + assert.Equal(t, got[0].ID, "db1") +} + +func TestPortsAsksForTheReplica(t *testing.T) { + backend := &fakeBackend{listed: []container.Summary{summary("web", "web2")}} + + _, err := New(backend).Ports(t.Context(), "proj", "web", 0, api.PortOptions{Index: 2}) + + assert.NilError(t, err) + assert.DeepEqual(t, backend.services, []string{"web"}) + assert.Equal(t, backend.index, 2) + assert.Equal(t, backend.oneOff, OneOffInclude) + assert.Equal(t, backend.all, false) +} + +func TestCanonicalContainerName(t *testing.T) { + assert.Equal(t, canonicalContainerName(container.Summary{Names: []string{"/proj-web-1", "/other/web"}}), "proj-web-1") + assert.Equal(t, canonicalContainerName(container.Summary{Names: []string{"/linked_by/web"}}), "linked_by/web") + assert.Equal(t, canonicalContainerName(container.Summary{ID: "0123456789abcdef"}), "0123456789ab") +} diff --git a/pkg/compose/ls.go b/internal/inspect/ls.go similarity index 94% rename from pkg/compose/ls.go rename to internal/inspect/ls.go index 99b050c5e42..729191d811a 100644 --- a/pkg/compose/ls.go +++ b/internal/inspect/ls.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package inspect import ( "context" @@ -30,8 +30,8 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) List(ctx context.Context, options api.ListOptions) ([]api.Stack, error) { - list, err := s.apiClient().ContainerList(ctx, client.ContainerListOptions{ +func (s *Service) List(ctx context.Context, options api.ListOptions) ([]api.Stack, error) { + list, err := s.backend.APIClient().ContainerList(ctx, client.ContainerListOptions{ Filters: make(client.Filters).Add("label", api.ProjectLabel).Add("label", api.ConfigHashLabel), All: options.All, }) diff --git a/pkg/compose/ls_test.go b/internal/inspect/ls_test.go similarity index 99% rename from pkg/compose/ls_test.go rename to internal/inspect/ls_test.go index 84f7dcac7d0..abe30cb9f9a 100644 --- a/pkg/compose/ls_test.go +++ b/internal/inspect/ls_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package inspect import ( "fmt" diff --git a/pkg/compose/port.go b/internal/inspect/port.go similarity index 85% rename from pkg/compose/port.go rename to internal/inspect/port.go index a7260fb5f9b..0b34d51188f 100644 --- a/pkg/compose/port.go +++ b/internal/inspect/port.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package inspect import ( "context" @@ -26,9 +26,9 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) Ports(ctx context.Context, projectName string, serviceName string, port uint16, options api.PortOptions) (api.PortPublishers, error) { +func (s *Service) Ports(ctx context.Context, projectName string, serviceName string, port uint16, options api.PortOptions) (api.PortPublishers, error) { projectName = strings.ToLower(projectName) - ctr, err := s.getSpecifiedContainer(ctx, projectName, oneOffInclude, false, serviceName, options.Index) + ctr, err := s.backend.SpecifiedContainer(ctx, projectName, OneOffInclude, false, serviceName, options.Index) if err != nil { return nil, err } diff --git a/internal/inspect/port_test.go b/internal/inspect/port_test.go new file mode 100644 index 00000000000..05558e00934 --- /dev/null +++ b/internal/inspect/port_test.go @@ -0,0 +1,61 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package inspect + +import ( + "testing" + + "github.com/moby/moby/api/types/container" + "gotest.tools/v3/assert" +) + +// TestContainerPublishersPreservesTieOrder guards against containerPublishers +// reordering same-PrivatePort entries (e.g. a dual-stack publish exposing the +// same target port on both an IPv4 and an IPv6 host address): Ports() picks +// publishers[0] as *the* answer for a single-port lookup, so a sort that +// merely orders by PrivatePort must not additionally scramble ties. +// +// sort.Slice's pdqsort falls back to a (stable) insertion sort for slices of +// <=12 elements, so a small fixture can't tell a stable sort from an unstable +// one; this uses enough tied groups to force the quicksort partitioning path. +func TestContainerPublishersPreservesTieOrder(t *testing.T) { + var ports []container.PortSummary + privatePorts := []int{80, 53, 443} + for _, pp := range privatePorts { + for i := range 5 { + ports = append(ports, container.PortSummary{ + PrivatePort: uint16(pp), + PublicPort: uint16(i), // tags each tied entry with its input position + Type: "tcp", + }) + } + } + ports = append(ports, container.PortSummary{PrivatePort: 22, PublicPort: 0, Type: "tcp"}) + + ctr := container.Summary{ID: "123", Ports: ports} + + got := containerPublishers(ctr) + + lastPosByTargetPort := map[int]int{} + for _, p := range got { + pos := p.PublishedPort + if prev, ok := lastPosByTargetPort[p.TargetPort]; ok { + assert.Assert(t, pos > prev, "tied entries for private port %d out of input order: got position %d after %d", p.TargetPort, pos, prev) + } + lastPosByTargetPort[p.TargetPort] = pos + } +} diff --git a/pkg/compose/ps.go b/internal/inspect/ps.go similarity index 84% rename from pkg/compose/ps.go rename to internal/inspect/ps.go index e6c78673b9c..0a01c97bb1b 100644 --- a/pkg/compose/ps.go +++ b/internal/inspect/ps.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package inspect import ( "context" @@ -27,22 +27,22 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) Ps(ctx context.Context, projectName string, options api.PsOptions) ([]api.ContainerSummary, error) { +func (s *Service) Ps(ctx context.Context, projectName string, options api.PsOptions) ([]api.ContainerSummary, error) { projectName = strings.ToLower(projectName) - oneOff := oneOffExclude + oneOff := OneOffExclude if options.All { - oneOff = oneOffInclude + oneOff = OneOffInclude } - containers, err := s.getContainers(ctx, projectName, oneOff, options.All, options.Services...) + containers, err := s.backend.Containers(ctx, projectName, oneOff, options.All, options.Services...) if err != nil { return nil, err } if len(options.Services) != 0 { - containers = containers.filter(isService(options.Services...)) + containers = inServices(containers, options.Services) } summary := make([]api.ContainerSummary, len(containers)) - eg, ctx := newLimitedErrgroup(ctx, s.maxConcurrency) + eg, ctx := limitedGroup(ctx, s.backend.MaxConcurrency()) for i, ctr := range containers { eg.Go(func() error { var err error @@ -55,8 +55,8 @@ func (s *composeService) Ps(ctx context.Context, projectName string, options api // containerSummary builds the api summary for a container, inspecting it to // retrieve its health and exit code -func (s *composeService) containerSummary(ctx context.Context, ctr container.Summary) (api.ContainerSummary, error) { - inspect, err := s.apiClient().ContainerInspect(ctx, ctr.ID, client.ContainerInspectOptions{}) +func (s *Service) containerSummary(ctx context.Context, ctr container.Summary) (api.ContainerSummary, error) { + inspect, err := s.backend.APIClient().ContainerInspect(ctx, ctr.ID, client.ContainerInspectOptions{}) if err != nil { return api.ContainerSummary{}, err } @@ -64,7 +64,7 @@ func (s *composeService) containerSummary(ctx context.Context, ctr container.Sum mounts, localVolumes := containerMounts(ctr) return api.ContainerSummary{ ID: ctr.ID, - Name: getCanonicalContainerName(ctr), + Name: canonicalContainerName(ctr), Names: ctr.Names, Image: ctr.Image, Project: ctr.Labels[api.ProjectLabel], diff --git a/pkg/compose/top.go b/internal/inspect/top.go similarity index 74% rename from pkg/compose/top.go rename to internal/inspect/top.go index a01566c7cd1..1a980b41a69 100644 --- a/pkg/compose/top.go +++ b/internal/inspect/top.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package inspect import ( "context" @@ -25,27 +25,26 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) Top(ctx context.Context, projectName string, services []string) ([]api.ContainerProcSummary, error) { +func (s *Service) Top(ctx context.Context, projectName string, services []string) ([]api.ContainerProcSummary, error) { projectName = strings.ToLower(projectName) - var containers Containers - containers, err := s.getContainers(ctx, projectName, oneOffInclude, false) + containers, err := s.backend.Containers(ctx, projectName, OneOffInclude, false) if err != nil { return nil, err } if len(services) > 0 { - containers = containers.filter(isService(services...)) + containers = inServices(containers, services) } summary := make([]api.ContainerProcSummary, len(containers)) - eg, ctx := newLimitedErrgroup(ctx, s.maxConcurrency) + eg, ctx := limitedGroup(ctx, s.backend.MaxConcurrency()) for i, ctr := range containers { eg.Go(func() error { - topContent, err := s.apiClient().ContainerTop(ctx, ctr.ID, client.ContainerTopOptions{ + topContent, err := s.backend.APIClient().ContainerTop(ctx, ctr.ID, client.ContainerTopOptions{ Arguments: []string{}, }) if err != nil { return err } - name := getCanonicalContainerName(ctr) + name := canonicalContainerName(ctr) s := api.ContainerProcSummary{ ID: ctr.ID, Name: name, diff --git a/pkg/compose/viz.go b/internal/inspect/viz.go similarity index 97% rename from pkg/compose/viz.go rename to internal/inspect/viz.go index ec1f34998ce..e0be7d6e747 100644 --- a/pkg/compose/viz.go +++ b/internal/inspect/viz.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package inspect import ( "context" @@ -29,7 +29,7 @@ import ( // maps a service with the services it depends on type vizGraph map[*types.ServiceConfig][]*types.ServiceConfig -func (s *composeService) Viz(_ context.Context, project *types.Project, options api.VizOptions) (string, error) { +func (s *Service) Viz(_ context.Context, project *types.Project, options api.VizOptions) (string, error) { graph := make(vizGraph) for _, service := range project.Services { graph[&service] = make([]*types.ServiceConfig, 0, len(service.DependsOn)) diff --git a/pkg/compose/volumes.go b/internal/inspect/volumes.go similarity index 84% rename from pkg/compose/volumes.go rename to internal/inspect/volumes.go index 1a6e29385b0..185c5d95ef5 100644 --- a/pkg/compose/volumes.go +++ b/internal/inspect/volumes.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package inspect import ( "context" @@ -26,8 +26,8 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) Volumes(ctx context.Context, projectName string, options api.VolumesOptions) ([]api.VolumesSummary, error) { - allContainers, err := s.apiClient().ContainerList(ctx, client.ContainerListOptions{ +func (s *Service) Volumes(ctx context.Context, projectName string, options api.VolumesOptions) ([]api.VolumesSummary, error) { + allContainers, err := s.backend.APIClient().ContainerList(ctx, client.ContainerListOptions{ Filters: projectFilter(projectName), }) if err != nil { @@ -47,7 +47,7 @@ func (s *composeService) Volumes(ctx context.Context, projectName string, option containers = allContainers.Items } - volumesResponse, err := s.apiClient().VolumeList(ctx, client.VolumeListOptions{ + volumesResponse, err := s.backend.APIClient().VolumeList(ctx, client.VolumeListOptions{ Filters: projectFilter(projectName), }) if err != nil { diff --git a/pkg/compose/inspect.go b/pkg/compose/inspect.go new file mode 100644 index 00000000000..fe769f4daa6 --- /dev/null +++ b/pkg/compose/inspect.go @@ -0,0 +1,91 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "context" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + + "github.com/docker/compose/v5/internal/inspect" + "github.com/docker/compose/v5/pkg/api" +) + +// The read-only operations live in internal/inspect: composeService serves +// them by handing that package the engine it reports on. + +func (s *composeService) inspector() *inspect.Service { + return inspect.New(inspectBackend{s}) +} + +func (s *composeService) List(ctx context.Context, options api.ListOptions) ([]api.Stack, error) { + return s.inspector().List(ctx, options) +} + +func (s *composeService) Ps(ctx context.Context, projectName string, options api.PsOptions) ([]api.ContainerSummary, error) { + return s.inspector().Ps(ctx, projectName, options) +} + +func (s *composeService) Top(ctx context.Context, projectName string, services []string) ([]api.ContainerProcSummary, error) { + return s.inspector().Top(ctx, projectName, services) +} + +func (s *composeService) Ports(ctx context.Context, projectName string, serviceName string, port uint16, options api.PortOptions) (api.PortPublishers, error) { + return s.inspector().Ports(ctx, projectName, serviceName, port, options) +} + +func (s *composeService) Volumes(ctx context.Context, projectName string, options api.VolumesOptions) ([]api.VolumesSummary, error) { + return s.inspector().Volumes(ctx, projectName, options) +} + +func (s *composeService) Events(ctx context.Context, projectName string, options api.EventsOptions) error { + return s.inspector().Events(ctx, projectName, options) +} + +func (s *composeService) Viz(ctx context.Context, project *types.Project, options api.VizOptions) (string, error) { + return s.inspector().Viz(ctx, project, options) +} + +// inspectBackend is the engine of a composeService, as internal/inspect sees it. +type inspectBackend struct { + s *composeService +} + +func (b inspectBackend) APIClient() client.APIClient { + return b.s.apiClient() +} + +func (b inspectBackend) MaxConcurrency() int { + return b.s.maxConcurrency +} + +func (b inspectBackend) Containers(ctx context.Context, projectName string, oneOff inspect.OneOff, all bool, services ...string) ([]container.Summary, error) { + return b.s.getContainers(ctx, projectName, toOneOff(oneOff), all, services...) +} + +func (b inspectBackend) SpecifiedContainer(ctx context.Context, projectName string, oneOff inspect.OneOff, all bool, serviceName string, index int) (container.Summary, error) { + return b.s.getSpecifiedContainer(ctx, projectName, toOneOff(oneOff), all, serviceName, index) +} + +func toOneOff(v inspect.OneOff) oneOff { + if v == inspect.OneOffExclude { + return oneOffExclude + } + return oneOffInclude +} diff --git a/pkg/compose/port_test.go b/pkg/compose/port_test.go index 9647c5e379b..efecb43c8b9 100644 --- a/pkg/compose/port_test.go +++ b/pkg/compose/port_test.go @@ -106,41 +106,3 @@ func TestPorts(t *testing.T) { }) } } - -// TestContainerPublishersPreservesTieOrder guards against containerPublishers -// reordering same-PrivatePort entries (e.g. a dual-stack publish exposing the -// same target port on both an IPv4 and an IPv6 host address): Ports() picks -// publishers[0] as *the* answer for a single-port lookup, so a sort that -// merely orders by PrivatePort must not additionally scramble ties. -// -// sort.Slice's pdqsort falls back to a (stable) insertion sort for slices of -// <=12 elements, so a small fixture can't tell a stable sort from an unstable -// one; this uses enough tied groups to force the quicksort partitioning path. -func TestContainerPublishersPreservesTieOrder(t *testing.T) { - var ports []container.PortSummary - privatePorts := []int{80, 53, 443} - for _, pp := range privatePorts { - for i := range 5 { - ports = append(ports, container.PortSummary{ - PrivatePort: uint16(pp), - PublicPort: uint16(i), // tags each tied entry with its input position - Type: "tcp", - }) - } - } - ports = append(ports, container.PortSummary{PrivatePort: 22, PublicPort: 0, Type: "tcp"}) - - ctr := testContainer("web", "123", false) - ctr.Ports = ports - - got := containerPublishers(ctr) - - lastPosByTargetPort := map[int]int{} - for _, p := range got { - pos := p.PublishedPort - if prev, ok := lastPosByTargetPort[p.TargetPort]; ok { - assert.Assert(t, pos > prev, "tied entries for private port %d out of input order: got position %d after %d", p.TargetPort, pos, prev) - } - lastPosByTargetPort[p.TargetPort] = pos - } -} From 76260966dc6a506df8e4fe2e126c2956bcd89603 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 13 Oct 2026 09:21:07 +0200 Subject: [PATCH 2/2] refactor(projectloader): move project loading and generation to internal/projectloader LoadProject and Generate (the api.ProjectLoader operations) need two things from the engine: the Docker API, to read the containers a project is generated from, and the Docker CLI, which resolves remote Compose files. They now live in internal/projectloader, along with the unsupported attributes detection only the loader uses, behind a Backend interface that composeService implements in a new file of pkg/compose, like internal/inspect. The tests move with the code; their assertions are unchanged, only the way the service is built differs. The canonical container name, which both this package and internal/inspect need, moves to a tiny internal/naming package instead of being copied a third time. pkg/compose keeps its own copy until the shared kernel is extracted. Signed-off-by: Nicolas De Loof --- internal/inspect/inspect.go | 18 ------ internal/inspect/inspect_test.go | 6 -- internal/inspect/ps.go | 3 +- internal/inspect/top.go | 3 +- internal/naming/naming.go | 41 +++++++++++++ internal/naming/naming_test.go | 30 ++++++++++ .../projectloader}/generate.go | 33 ++++++---- .../projectloader}/loader.go | 14 ++--- .../projectloader}/loader_test.go | 37 ++++++------ internal/projectloader/projectloader.go | 43 +++++++++++++ .../projectloader}/unsupported_attributes.go | 2 +- .../unsupported_attributes_test.go | 15 ++--- pkg/compose/projectloader.go | 60 +++++++++++++++++++ 13 files changed, 231 insertions(+), 74 deletions(-) create mode 100644 internal/naming/naming.go create mode 100644 internal/naming/naming_test.go rename {pkg/compose => internal/projectloader}/generate.go (82%) rename {pkg/compose => internal/projectloader}/loader.go (87%) rename {pkg/compose => internal/projectloader}/loader_test.go (93%) create mode 100644 internal/projectloader/projectloader.go rename {pkg/compose => internal/projectloader}/unsupported_attributes.go (99%) rename {pkg/compose => internal/projectloader}/unsupported_attributes_test.go (97%) create mode 100644 pkg/compose/projectloader.go diff --git a/internal/inspect/inspect.go b/internal/inspect/inspect.go index 7d8567bc2f3..6e11ec7d572 100644 --- a/internal/inspect/inspect.go +++ b/internal/inspect/inspect.go @@ -21,7 +21,6 @@ package inspect import ( "context" "slices" - "strings" "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" @@ -86,20 +85,3 @@ func limitedGroup(ctx context.Context, maxConcurrency int) (*errgroup.Group, con } return eg, ctx } - -// canonicalContainerName returns the name a container goes by, without the -// leading slash nor the link aliases. -func canonicalContainerName(c container.Summary) string { - if len(c.Names) == 0 { - // corner case, sometime happens on removal. return short ID as a safeguard value - return c.ID[:12] - } - // Names return container canonical name /foo + link aliases /linked_by/foo - for _, name := range c.Names { - if strings.LastIndex(name, "/") == 0 { - return name[1:] - } - } - - return strings.TrimPrefix(c.Names[0], "/") -} diff --git a/internal/inspect/inspect_test.go b/internal/inspect/inspect_test.go index 4a863ff72ba..7a03fb86b78 100644 --- a/internal/inspect/inspect_test.go +++ b/internal/inspect/inspect_test.go @@ -106,9 +106,3 @@ func TestPortsAsksForTheReplica(t *testing.T) { assert.Equal(t, backend.oneOff, OneOffInclude) assert.Equal(t, backend.all, false) } - -func TestCanonicalContainerName(t *testing.T) { - assert.Equal(t, canonicalContainerName(container.Summary{Names: []string{"/proj-web-1", "/other/web"}}), "proj-web-1") - assert.Equal(t, canonicalContainerName(container.Summary{Names: []string{"/linked_by/web"}}), "linked_by/web") - assert.Equal(t, canonicalContainerName(container.Summary{ID: "0123456789abcdef"}), "0123456789ab") -} diff --git a/internal/inspect/ps.go b/internal/inspect/ps.go index 0a01c97bb1b..cc9f61e3211 100644 --- a/internal/inspect/ps.go +++ b/internal/inspect/ps.go @@ -24,6 +24,7 @@ import ( "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" + "github.com/docker/compose/v5/internal/naming" "github.com/docker/compose/v5/pkg/api" ) @@ -64,7 +65,7 @@ func (s *Service) containerSummary(ctx context.Context, ctr container.Summary) ( mounts, localVolumes := containerMounts(ctr) return api.ContainerSummary{ ID: ctr.ID, - Name: canonicalContainerName(ctr), + Name: naming.CanonicalContainerName(ctr), Names: ctr.Names, Image: ctr.Image, Project: ctr.Labels[api.ProjectLabel], diff --git a/internal/inspect/top.go b/internal/inspect/top.go index 1a980b41a69..1a627285dbe 100644 --- a/internal/inspect/top.go +++ b/internal/inspect/top.go @@ -22,6 +22,7 @@ import ( "github.com/moby/moby/client" + "github.com/docker/compose/v5/internal/naming" "github.com/docker/compose/v5/pkg/api" ) @@ -44,7 +45,7 @@ func (s *Service) Top(ctx context.Context, projectName string, services []string if err != nil { return err } - name := canonicalContainerName(ctr) + name := naming.CanonicalContainerName(ctr) s := api.ContainerProcSummary{ ID: ctr.ID, Name: name, diff --git a/internal/naming/naming.go b/internal/naming/naming.go new file mode 100644 index 00000000000..8e95ce8675a --- /dev/null +++ b/internal/naming/naming.go @@ -0,0 +1,41 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Package naming derives the names Compose uses for the Docker resources it manages. +package naming + +import ( + "strings" + + "github.com/moby/moby/api/types/container" +) + +// CanonicalContainerName returns the name a container goes by, without the +// leading slash nor the link aliases. +func CanonicalContainerName(c container.Summary) string { + if len(c.Names) == 0 { + // corner case, sometime happens on removal. return short ID as a safeguard value + return c.ID[:12] + } + // Names return container canonical name /foo + link aliases /linked_by/foo + for _, name := range c.Names { + if strings.LastIndex(name, "/") == 0 { + return name[1:] + } + } + + return strings.TrimPrefix(c.Names[0], "/") +} diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go new file mode 100644 index 00000000000..dae7b976dfc --- /dev/null +++ b/internal/naming/naming_test.go @@ -0,0 +1,30 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package naming + +import ( + "testing" + + "github.com/moby/moby/api/types/container" + "gotest.tools/v3/assert" +) + +func TestCanonicalContainerName(t *testing.T) { + assert.Equal(t, CanonicalContainerName(container.Summary{Names: []string{"/proj-web-1", "/other/web"}}), "proj-web-1") + assert.Equal(t, CanonicalContainerName(container.Summary{Names: []string{"/linked_by/web"}}), "linked_by/web") + assert.Equal(t, CanonicalContainerName(container.Summary{ID: "0123456789abcdef"}), "0123456789ab") +} diff --git a/pkg/compose/generate.go b/internal/projectloader/generate.go similarity index 82% rename from pkg/compose/generate.go rename to internal/projectloader/generate.go index 4ee63352e7b..c2a8efbde4e 100644 --- a/pkg/compose/generate.go +++ b/internal/projectloader/generate.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package projectloader import ( "context" @@ -29,11 +29,12 @@ import ( "github.com/moby/moby/api/types/network" "github.com/moby/moby/client" + "github.com/docker/compose/v5/internal/naming" "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) Generate(ctx context.Context, options api.GenerateOptions) (*types.Project, error) { - res, err := s.apiClient().ContainerList(ctx, client.ContainerListOptions{ +func (s *Service) Generate(ctx context.Context, options api.GenerateOptions) (*types.Project, error) { + res, err := s.backend.APIClient().ContainerList(ctx, client.ContainerListOptions{ Filters: make(client.Filters).Add("name", options.Containers...), All: true, }) @@ -42,7 +43,7 @@ func (s *composeService) Generate(ctx context.Context, options api.GenerateOptio } containers := res.Items - containersByIds, err := s.apiClient().ContainerList(ctx, client.ContainerListOptions{ + containersByIds, err := s.backend.APIClient().ContainerList(ctx, client.ContainerListOptions{ Filters: make(client.Filters).Add("id", options.Containers...), All: true, }) @@ -65,7 +66,7 @@ func (s *composeService) Generate(ctx context.Context, options api.GenerateOptio return s.createProjectFromContainers(containers, options.ProjectName) } -func (s *composeService) createProjectFromContainers(containers []container.Summary, projectName string) (*types.Project, error) { +func (s *Service) createProjectFromContainers(containers []container.Summary, projectName string) (*types.Project, error) { project := &types.Project{} services := types.Services{} networks := types.Networks{} @@ -80,7 +81,7 @@ func (s *composeService) createProjectFromContainers(containers []container.Summ // if the container is from a previous Compose application, use the existing service name serviceLabel, ok := ctr.Labels[api.ServiceLabel] if !ok { - serviceLabel = getCanonicalContainerName(ctr) + serviceLabel = naming.CanonicalContainerName(ctr) } service, ok := services[serviceLabel] if !ok { @@ -94,7 +95,7 @@ func (s *composeService) createProjectFromContainers(containers []container.Summ } service.Scale = increment(service.Scale) - inspect, err := s.apiClient().ContainerInspect(context.Background(), ctr.ID, client.ContainerInspectOptions{}) + inspect, err := s.backend.APIClient().ContainerInspect(context.Background(), ctr.ID, client.ContainerInspectOptions{}) if err != nil { services[serviceLabel] = service continue @@ -111,7 +112,7 @@ func (s *composeService) createProjectFromContainers(containers []container.Summ return project, nil } -func (s *composeService) extractComposeConfiguration(service *types.ServiceConfig, inspect container.InspectResponse, volumes types.Volumes, secrets types.Secrets, networks types.Networks) { +func (s *Service) extractComposeConfiguration(service *types.ServiceConfig, inspect container.InspectResponse, volumes types.Volumes, secrets types.Secrets, networks types.Networks) { service.Environment = types.NewMappingWithEquals(inspect.Config.Env) if inspect.Config.Healthcheck != nil { healthConfig := inspect.Config.Healthcheck @@ -143,7 +144,7 @@ func (s *composeService) extractComposeConfiguration(service *types.ServiceConfi } } -func (s *composeService) toComposeHealthCheck(healthConfig *container.HealthConfig) *types.HealthCheckConfig { +func (s *Service) toComposeHealthCheck(healthConfig *container.HealthConfig) *types.HealthCheckConfig { var healthCheck types.HealthCheckConfig healthCheck.Test = healthConfig.Test if healthConfig.Timeout != 0 { @@ -169,7 +170,7 @@ func (s *composeService) toComposeHealthCheck(healthConfig *container.HealthConf return &healthCheck } -func (s *composeService) toComposeVolumes(volumes []container.MountPoint) (map[string]types.VolumeConfig, +func (s *Service) toComposeVolumes(volumes []container.MountPoint) (map[string]types.VolumeConfig, []types.ServiceVolumeConfig, map[string]types.SecretConfig, []types.ServiceSecretConfig, ) { volumeConfigs := make(map[string]types.VolumeConfig) @@ -214,12 +215,12 @@ func (s *composeService) toComposeVolumes(volumes []container.MountPoint) (map[s return volumeConfigs, serviceVolumeConfigs, secretConfigs, serviceSecretConfigs } -func (s *composeService) toComposeNetwork(networks map[string]*network.EndpointSettings) (map[string]types.NetworkConfig, map[string]*types.ServiceNetworkConfig) { +func (s *Service) toComposeNetwork(networks map[string]*network.EndpointSettings) (map[string]types.NetworkConfig, map[string]*types.ServiceNetworkConfig) { networkConfigs := make(map[string]types.NetworkConfig) serviceNetworkConfigs := make(map[string]*types.ServiceNetworkConfig) for name, net := range networks { - inspect, err := s.apiClient().NetworkInspect(context.Background(), name, client.NetworkInspectOptions{}) + inspect, err := s.backend.APIClient().NetworkInspect(context.Background(), name, client.NetworkInspectOptions{}) if err != nil { networkConfigs[name] = types.NetworkConfig{} } else { @@ -243,3 +244,11 @@ func cleanDockerPreviousLabels(labels types.Labels) types.Labels { } return cleanedLabels } + +func increment(scale *int) *int { + i := 1 + if scale != nil { + i = *scale + 1 + } + return &i +} diff --git a/pkg/compose/loader.go b/internal/projectloader/loader.go similarity index 87% rename from pkg/compose/loader.go rename to internal/projectloader/loader.go index f2bdc5014ed..d767d4eaae6 100644 --- a/pkg/compose/loader.go +++ b/internal/projectloader/loader.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package projectloader import ( "context" @@ -33,7 +33,7 @@ import ( // LoadProject implements api.Compose.LoadProject // It loads and validates a Compose project from configuration files. -func (s *composeService) LoadProject(ctx context.Context, options api.ProjectLoadOptions) (*types.Project, error) { +func (s *Service) LoadProject(ctx context.Context, options api.ProjectLoadOptions) (*types.Project, error) { // Setup remote loaders (Git, OCI) remoteLoaders := s.createRemoteLoaders(options) @@ -68,17 +68,17 @@ func (s *composeService) LoadProject(ctx context.Context, options api.ProjectLoa } // createRemoteLoaders creates Git and OCI remote loaders if not in offline mode -func (s *composeService) createRemoteLoaders(options api.ProjectLoadOptions) []loader.ResourceLoader { +func (s *Service) createRemoteLoaders(options api.ProjectLoadOptions) []loader.ResourceLoader { if options.Offline { return nil } - git := remote.NewGitRemoteLoader(s.dockerCli, options.Offline) - oci := remote.NewOCIRemoteLoader(s.dockerCli, options.Offline, options.OCI) + git := remote.NewGitRemoteLoader(s.backend.CLI(), options.Offline) + oci := remote.NewOCIRemoteLoader(s.backend.CLI(), options.Offline, options.OCI) return []loader.ResourceLoader{git, oci} } // buildProjectOptions constructs compose-go ProjectOptions from API options -func (s *composeService) buildProjectOptions(options api.ProjectLoadOptions, remoteLoaders []loader.ResourceLoader) (*cli.ProjectOptions, error) { +func (s *Service) buildProjectOptions(options api.ProjectLoadOptions, remoteLoaders []loader.ResourceLoader) (*cli.ProjectOptions, error) { projectOptionsFns := []cli.ProjectOptionsFn{ cli.WithWorkingDirectory(options.WorkingDir), cli.WithOsEnv, @@ -121,7 +121,7 @@ func (s *composeService) buildProjectOptions(options api.ProjectLoadOptions, rem } // postProcessProject applies post-loading transformations to the project -func (s *composeService) postProcessProject(project *types.Project, options api.ProjectLoadOptions) (*types.Project, error) { +func (s *Service) postProcessProject(project *types.Project, options api.ProjectLoadOptions) (*types.Project, error) { if project.Name == "" { return nil, errors.New("project name can't be empty. Use ProjectName option to set a valid name") } diff --git a/pkg/compose/loader_test.go b/internal/projectloader/loader_test.go similarity index 93% rename from pkg/compose/loader_test.go rename to internal/projectloader/loader_test.go index 979db0aa91f..2dbba5fbced 100644 --- a/pkg/compose/loader_test.go +++ b/internal/projectloader/loader_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package projectloader import ( "os" @@ -22,6 +22,8 @@ import ( "testing" "github.com/compose-spec/compose-go/v2/cli" + "github.com/docker/cli/cli/command" + "github.com/moby/moby/client" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" @@ -47,8 +49,7 @@ services: err := os.WriteFile(composeFile, []byte(composeContent), 0o644) assert.NilError(t, err) - service, err := NewComposeService(nil) - assert.NilError(t, err) + service := New(noBackend{}) // Load the project project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{ @@ -85,8 +86,7 @@ services: // Set environment variable t.Setenv("TEST_VAR", "resolved_value") - service, err := NewComposeService(nil) - assert.NilError(t, err) + service := New(noBackend{}) // Test with environment resolution (default) t.Run("WithResolution", func(t *testing.T) { @@ -133,8 +133,7 @@ services: err := os.WriteFile(composeFile, []byte(composeContent), 0o644) assert.NilError(t, err) - service, err := NewComposeService(nil) - assert.NilError(t, err) + service := New(noBackend{}) // Load only specific services project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{ @@ -163,8 +162,7 @@ services: err := os.WriteFile(composeFile, []byte(composeContent), 0o644) assert.NilError(t, err) - service, err := NewComposeService(nil) - assert.NilError(t, err) + service := New(noBackend{}) // Without debug profile t.Run("WithoutProfile", func(t *testing.T) { @@ -200,8 +198,7 @@ services: err := os.WriteFile(composeFile, []byte(composeContent), 0o644) assert.NilError(t, err) - service, err := NewComposeService(nil) - assert.NilError(t, err) + service := New(noBackend{}) // Track events received var events []string @@ -233,8 +230,7 @@ services: err := os.WriteFile(composeFile, []byte(composeContent), 0o644) assert.NilError(t, err) - service, err := NewComposeService(nil) - assert.NilError(t, err) + service := New(noBackend{}) // Without explicit project name t.Run("InferredName", func(t *testing.T) { @@ -268,8 +264,7 @@ services: err := os.WriteFile(composeFile, []byte(composeContent), 0o644) assert.NilError(t, err) - service, err := NewComposeService(nil) - assert.NilError(t, err) + service := New(noBackend{}) // With compatibility mode project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{ @@ -295,8 +290,7 @@ this is not valid yaml: [[[ err := os.WriteFile(composeFile, []byte(composeContent), 0o644) assert.NilError(t, err) - service, err := NewComposeService(nil) - assert.NilError(t, err) + service := New(noBackend{}) // Should return an error for invalid YAML project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{ @@ -308,8 +302,7 @@ this is not valid yaml: [[[ } func TestLoadProject_MissingComposeFile(t *testing.T) { - service, err := NewComposeService(nil) - assert.NilError(t, err) + service := New(noBackend{}) // Should return an error for missing file project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{ @@ -319,3 +312,9 @@ func TestLoadProject_MissingComposeFile(t *testing.T) { assert.Assert(t, err != nil) assert.Assert(t, project == nil) } + +// noBackend is a Backend with no daemon and no CLI, enough to load local Compose files. +type noBackend struct{} + +func (noBackend) APIClient() client.APIClient { return nil } +func (noBackend) CLI() command.Cli { return nil } diff --git a/internal/projectloader/projectloader.go b/internal/projectloader/projectloader.go new file mode 100644 index 00000000000..1c50a03031c --- /dev/null +++ b/internal/projectloader/projectloader.go @@ -0,0 +1,43 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Package projectloader obtains a Compose project: either by loading and +// validating the Compose files (LoadProject), or by generating one from +// existing containers (Generate). +package projectloader + +import ( + "github.com/docker/cli/cli/command" + "github.com/moby/moby/client" +) + +// Backend is what loading or generating a project needs from the engine. +type Backend interface { + // APIClient talks to the Docker daemon, to read the containers to generate a project from. + APIClient() client.APIClient + // CLI is the Docker CLI, which resolves the remote (Git, OCI) Compose files. + CLI() command.Cli +} + +// Service loads and generates projects on a Backend. +type Service struct { + backend Backend +} + +// New creates a Service working with the given Backend. +func New(backend Backend) *Service { + return &Service{backend: backend} +} diff --git a/pkg/compose/unsupported_attributes.go b/internal/projectloader/unsupported_attributes.go similarity index 99% rename from pkg/compose/unsupported_attributes.go rename to internal/projectloader/unsupported_attributes.go index 64c8ebade41..b56a006d663 100644 --- a/pkg/compose/unsupported_attributes.go +++ b/internal/projectloader/unsupported_attributes.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package projectloader import ( "cmp" diff --git a/pkg/compose/unsupported_attributes_test.go b/internal/projectloader/unsupported_attributes_test.go similarity index 97% rename from pkg/compose/unsupported_attributes_test.go rename to internal/projectloader/unsupported_attributes_test.go index 12bd08e4b83..5ad601b6704 100644 --- a/pkg/compose/unsupported_attributes_test.go +++ b/internal/projectloader/unsupported_attributes_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package projectloader import ( "cmp" @@ -65,11 +65,10 @@ func loadWithFiles(t *testing.T, files map[string]string) []api.UnsupportedAttri assert.NilError(t, os.WriteFile(path, []byte(content), 0o644)) } - service, err := NewComposeService(nil) - assert.NilError(t, err) + service := New(noBackend{}) var findings []api.UnsupportedAttribute - _, err = service.LoadProject(t.Context(), api.ProjectLoadOptions{ + _, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{ ConfigPaths: []string{filepath.Join(tmpDir, "compose.yaml")}, OnUnsupportedAttribute: func(f []api.UnsupportedAttribute) { findings = f @@ -229,8 +228,7 @@ services: ` assert.NilError(t, os.WriteFile(filepath.Join(tmpDir, "compose.yaml"), []byte(content), 0o644)) - service, err := NewComposeService(nil) - assert.NilError(t, err) + service := New(noBackend{}) var got []api.UnsupportedAttribute project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{ ConfigPaths: []string{filepath.Join(tmpDir, "compose.yaml")}, @@ -657,8 +655,7 @@ services: ` assert.NilError(t, os.WriteFile(composeFile, []byte(content), 0o644)) - service, err := NewComposeService(nil) - assert.NilError(t, err) - _, err = service.LoadProject(t.Context(), api.ProjectLoadOptions{ConfigPaths: []string{composeFile}}) + service := New(noBackend{}) + _, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{ConfigPaths: []string{composeFile}}) assert.ErrorContains(t, err, "condition") } diff --git a/pkg/compose/projectloader.go b/pkg/compose/projectloader.go new file mode 100644 index 00000000000..049a4f99c40 --- /dev/null +++ b/pkg/compose/projectloader.go @@ -0,0 +1,60 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "context" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli/command" + "github.com/moby/moby/client" + + "github.com/docker/compose/v5/internal/projectloader" + "github.com/docker/compose/v5/pkg/api" +) + +// Loading a project, or generating one from containers, is done in +// internal/projectloader: composeService hands it the engine it works with. + +func (s *composeService) projectLoader() *projectloader.Service { + return projectloader.New(projectLoaderBackend{s}) +} + +// LoadProject implements api.Compose.LoadProject +// It loads and validates a Compose project from configuration files. +func (s *composeService) LoadProject(ctx context.Context, options api.ProjectLoadOptions) (*types.Project, error) { + return s.projectLoader().LoadProject(ctx, options) +} + +// Generate implements api.Compose.Generate +// It generates a Compose project from existing containers. +func (s *composeService) Generate(ctx context.Context, options api.GenerateOptions) (*types.Project, error) { + return s.projectLoader().Generate(ctx, options) +} + +// projectLoaderBackend is the engine of a composeService, as internal/projectloader sees it. +type projectLoaderBackend struct { + s *composeService +} + +func (b projectLoaderBackend) APIClient() client.APIClient { + return b.s.apiClient() +} + +func (b projectLoaderBackend) CLI() command.Cli { + return b.s.dockerCli +}