Repository: docker/compose-cli
Pull request #1028 (2020-12-08 11:01:02)

Split compose.go into command-focussed go files
**What I did**
Split loooooong `compose.go` file into command-centric files


**(not mandatory) A picture of a cute animal, if possible in relation with what you did**
![image](https://user-images.githubusercontent.com/132757/101475694-fffcfe00-394c-11eb-8145-d89fbc677dbc.png)



Patch:
diff --git a/local/backend.go b/local/backend.go
index de0380e1..8e25d8b5 100644
--- a/local/backend.go
+++ b/local/backend.go
@@ -28,12 +28,13 @@ import (
 	"github.com/docker/compose-cli/api/volumes"
 	"github.com/docker/compose-cli/backend"
 	"github.com/docker/compose-cli/context/cloud"
+	local_compose "github.com/docker/compose-cli/local/compose"
 )
 
 type local struct {
-	*containerService
-	*volumeService
-	*composeService
+	containerService *containerService
+	volumeService    *volumeService
+	composeService   compose.Service
 }
 
 func init() {
@@ -49,7 +50,7 @@ func service(ctx context.Context) (backend.Service, error) {
 	return &local{
 		containerService: &containerService{apiClient},
 		volumeService:    &volumeService{apiClient},
-		composeService:   &composeService{apiClient},
+		composeService:   local_compose.NewComposeService(apiClient),
 	}, nil
 }
 
diff --git a/local/compose.go b/local/compose.go
deleted file mode 100644
index 7a03c535..00000000
--- a/local/compose.go
+++ /dev/null
@@ -1,1109 +0,0 @@
-/*
-   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 local
-
-import (
-	"bytes"
-	"context"
-	"encoding/base64"
-	"encoding/json"
-	"fmt"
-	"io"
-	"path/filepath"
-	"sort"
-	"strconv"
-	"strings"
-
-	"github.com/compose-spec/compose-go/cli"
-	"github.com/compose-spec/compose-go/types"
-	"github.com/docker/buildx/build"
-	cliconfig "github.com/docker/cli/cli/config"
-	"github.com/docker/distribution/reference"
-	moby "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/mount"
-	"github.com/docker/docker/api/types/network"
-	"github.com/docker/docker/api/types/strslice"
-	mobyvolume "github.com/docker/docker/api/types/volume"
-	"github.com/docker/docker/client"
-	"github.com/docker/docker/errdefs"
-	"github.com/docker/docker/pkg/jsonmessage"
-	"github.com/docker/docker/pkg/stdcopy"
-	"github.com/docker/docker/registry"
-	"github.com/docker/go-connections/nat"
-	"github.com/pkg/errors"
-	"github.com/sanathkr/go-yaml"
-	"golang.org/x/sync/errgroup"
-
-	"github.com/docker/compose-cli/api/compose"
-	"github.com/docker/compose-cli/config"
-	errdefs2 "github.com/docker/compose-cli/errdefs"
-	"github.com/docker/compose-cli/progress"
-)
-
-type composeService struct {
-	apiClient *client.Client
-}
-
-func (s *composeService) Build(ctx context.Context, project *types.Project) error {
-	opts := map[string]build.Options{}
-	for _, service := range project.Services {
-		if service.Build != nil {
-			imageName := service.Image
-			if imageName == "" {
-				imageName = project.Name + "_" + service.Name
-			}
-			opts[imageName] = s.toBuildOptions(service, project.WorkingDir, imageName)
-		}
-	}
-
-	return s.build(ctx, project, opts)
-}
-
-func (s *composeService) Push(ctx context.Context, project *types.Project) error {
-	configFile, err := cliconfig.Load(config.Dir(ctx))
-	if err != nil {
-		return err
-	}
-	eg, ctx := errgroup.WithContext(ctx)
-
-	info, err := s.apiClient.Info(ctx)
-	if err != nil {
-		return err
-	}
-	if info.IndexServerAddress == "" {
-		info.IndexServerAddress = registry.IndexServer
-	}
-
-	for _, service := range project.Services {
-		if service.Build == nil {
-			continue
-		}
-		service := service
-		eg.Go(func() error {
-			w := progress.ContextWriter(ctx)
-
-			ref, err := reference.ParseNormalizedNamed(service.Image)
-			if err != nil {
-				return err
-			}
-
-			repoInfo, err := registry.ParseRepositoryInfo(ref)
-			if err != nil {
-				return err
-			}
-
-			key := repoInfo.Index.Name
-			if repoInfo.Index.Official {
-				key = info.IndexServerAddress
-			}
-			authConfig, err := configFile.GetAuthConfig(key)
-			if err != nil {
-				return err
-			}
-
-			buf, err := json.Marshal(authConfig)
-			if err != nil {
-				return err
-			}
-
-			stream, err := s.apiClient.ImagePush(ctx, service.Image, moby.ImagePushOptions{
-				RegistryAuth: base64.URLEncoding.EncodeToString(buf),
-			})
-			if err != nil {
-				return err
-			}
-			dec := json.NewDecoder(stream)
-			for {
-				var jm jsonmessage.JSONMessage
-				if err := dec.Decode(&jm); err != nil {
-					if err == io.EOF {
-						break
-					}
-					return err
-				}
-				if jm.Error != nil {
-					return errors.New(jm.Error.Message)
-				}
-				toProgressEvent("Pushing "+service.Name, jm, w)
-			}
-			return nil
-		})
-	}
-	return eg.Wait()
-}
-
-func (s *composeService) Pull(ctx context.Context, project *types.Project) error {
-	configFile, err := cliconfig.Load(config.Dir(ctx))
-	if err != nil {
-		return err
-	}
-	info, err := s.apiClient.Info(ctx)
-	if err != nil {
-		return err
-	}
-
-	if info.IndexServerAddress == "" {
-		info.IndexServerAddress = registry.IndexServer
-	}
-
-	w := progress.ContextWriter(ctx)
-	eg, ctx := errgroup.WithContext(ctx)
-
-	for _, srv := range project.Services {
-		service := srv
-		eg.Go(func() error {
-			w.Event(progress.Event{
-				ID:     service.Name,
-				Status: progress.Working,
-				Text:   "Pulling",
-			})
-			ref, err := reference.ParseNormalizedNamed(service.Image)
-			if err != nil {
-				return err
-			}
-
-			repoInfo, err := registry.ParseRepositoryInfo(ref)
-			if err != nil {
-				return err
-			}
-
-			key := repoInfo.Index.Name
-			if repoInfo.Index.Official {
-				key = info.IndexServerAddress
-			}
-
-			authConfig, err := configFile.GetAuthConfig(key)
-			if err != nil {
-				return err
-			}
-
-			buf, err := json.Marshal(authConfig)
-			if err != nil {
-				return err
-			}
-
-			stream, err := s.apiClient.ImagePull(ctx, service.Image, moby.ImagePullOptions{
-				RegistryAuth: base64.URLEncoding.EncodeToString(buf),
-			})
-			if err != nil {
-				w.Event(progress.Event{
-					ID:     service.Name,
-					Status: progress.Error,
-					Text:   "Error",
-				})
-				return err
-			}
-
-			dec := json.NewDecoder(stream)
-			for {
-				var jm jsonmessage.JSONMessage
-				if err := dec.Decode(&jm); err != nil {
-					if err == io.EOF {
-						break
-					}
-					return err
-				}
-				if jm.Error != nil {
-					return errors.New(jm.Error.Message)
-				}
-				toPullProgressEvent(service.Name, jm, w)
-			}
-			w.Event(progress.Event{
-				ID:     service.Name,
-				Status: progress.Done,
-				Text:   "Pulled",
-			})
-			return nil
-		})
-	}
-
-	return eg.Wait()
-}
-
-func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, w progress.Writer) {
-	if jm.ID == "" || jm.Progress == nil {
-		return
-	}
-
-	var (
-		text   string
-		status = progress.Working
-	)
-
-	text = jm.Progress.String()
-
-	if jm.Status == "Pull complete" ||
-		jm.Status == "Already exists" ||
-		strings.Contains(jm.Status, "Image is up to date") ||
-		strings.Contains(jm.Status, "Downloaded newer image") {
-		status = progress.Done
-	}
-
-	if jm.Error != nil {
-		status = progress.Error
-		text = jm.Error.Message
-	}
-
-	w.Event(progress.Event{
-		ID:         jm.ID,
-		ParentID:   parent,
-		Text:       jm.Status,
-		Status:     status,
-		StatusText: text,
-	})
-}
-
-func toProgressEvent(prefix string, jm jsonmessage.JSONMessage, w progress.Writer) {
-	if jm.ID == "" {
-		// skipped
-		return
-	}
-	var (
-		text   string
-		status = progress.Working
-	)
-	if jm.Status == "Pull complete" || jm.Status == "Already exists" {
-		status = progress.Done
-	}
-	if jm.Error != nil {
-		status = progress.Error
-		text = jm.Error.Message
-	}
-	if jm.Progress != nil {
-		text = jm.Progress.String()
-	}
-	w.Event(progress.Event{
-		ID:         fmt.Sprintf("Pushing %s: %s", prefix, jm.ID),
-		Text:       jm.Status,
-		Status:     status,
-		StatusText: text,
-	})
-}
-
-func (s *composeService) Up(ctx context.Context, project *types.Project, detach bool) error {
-	return errdefs2.ErrNotImplemented
-}
-
-func (s *composeService) Create(ctx context.Context, project *types.Project) error {
-	err := s.ensureImagesExists(ctx, project)
-	if err != nil {
-		return err
-	}
-
-	for k, network := range project.Networks {
-		if !network.External.External && network.Name != "" {
-			network.Name = fmt.Sprintf("%s_%s", project.Name, k)
-			project.Networks[k] = network
-		}
-		network.Labels = network.Labels.Add(networkLabel, k)
-		network.Labels = network.Labels.Add(projectLabel, project.Name)
-		network.Labels = network.Labels.Add(versionLabel, ComposeVersion)
-		err := s.ensureNetwork(ctx, network)
-		if err != nil {
-			return err
-		}
-	}
-
-	for k, volume := range project.Volumes {
-		if !volume.External.External && volume.Name != "" {
-			volume.Name = fmt.Sprintf("%s_%s", project.Name, k)
-			project.Volumes[k] = volume
-		}
-		volume.Labels = volume.Labels.Add(volumeLabel, k)
-		volume.Labels = volume.Labels.Add(projectLabel, project.Name)
-		volume.Labels = volume.Labels.Add(versionLabel, ComposeVersion)
-		err := s.ensureVolume(ctx, volume)
-		if err != nil {
-			return err
-		}
-	}
-
-	return InDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error {
-		return s.ensureService(c, project, service)
-	})
-}
-
-func (s *composeService) Start(ctx context.Context, project *types.Project, consumer compose.LogConsumer) error {
-	var group *errgroup.Group
-	if consumer != nil {
-		eg, err := s.attach(ctx, project, consumer)
-		if err != nil {
-			return err
-		}
-		group = eg
-	}
-
-	err := InDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error {
-		return s.startService(ctx, project, service)
-	})
-	if err != nil {
-		return err
-	}
-	if group != nil {
-		return group.Wait()
-	}
-	return nil
-}
-
-func (s *composeService) attach(ctx context.Context, project *types.Project, consumer compose.LogConsumer) (*errgroup.Group, error) {
-	containers, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
-		Filters: filters.NewArgs(
-			projectFilter(project.Name),
-		),
-		All: true,
-	})
-	if err != nil {
-		return nil, err
-	}
-
-	var names []string
-	for _, c := range containers {
-		names = append(names, getContainerName(c))
-	}
-	fmt.Printf("Attaching to %s\n", strings.Join(names, ", "))
-
-	eg, ctx := errgroup.WithContext(ctx)
-	for _, c := range containers {
-		container := c
-		eg.Go(func() error {
-			return s.attachContainer(ctx, container, consumer, project)
-		})
-	}
-	return eg, nil
-}
-
-func (s *composeService) attachContainer(ctx context.Context, container moby.Container, consumer compose.LogConsumer, project *types.Project) error {
-	serviceName := container.Labels[serviceLabel]
-	w := getWriter(serviceName, container.ID, consumer)
-
-	service, err := project.GetService(serviceName)
-	if err != nil {
-		return err
-	}
-
-	return s.attachContainerStreams(ctx, container, service.Tty, nil, w)
-}
-
-func (s *composeService) attachContainerStreams(ctx context.Context, container moby.Container, tty bool, r io.Reader, w io.Writer) error {
-	stdin, stdout, err := s.getContainerStreams(ctx, container)
-	if err != nil {
-		return err
-	}
-
-	go func() {
-		<-ctx.Done()
-		stdout.Close() //nolint:errcheck
-		stdin.Close()  //nolint:errcheck
-	}()
-
-	if r != nil && stdin != nil {
-		go func() {
-			io.Copy(stdin, r) //nolint:errcheck
-		}()
-	}
-
-	if w != nil {
-		if tty {
-			_, err = io.Copy(w, stdout)
-		} else {
-			_, err = stdcopy.StdCopy(w, w, stdout)
-		}
-	}
-	return err
-}
-
-func (s *composeService) getContainerStreams(ctx context.Context, container moby.Container) (io.WriteCloser, io.ReadCloser, error) {
-	var stdout io.ReadCloser
-	var stdin io.WriteCloser
-	if container.State == containerRunning {
-		logs, err := s.apiClient.ContainerLogs(ctx, container.ID, moby.ContainerLogsOptions{
-			ShowStdout: true,
-			ShowStderr: true,
-			Follow:     true,
-		})
-		if err != nil {
-			return nil, nil, err
-		}
-		stdout = logs
-	} else {
-		cnx, err := s.apiClient.ContainerAttach(ctx, container.ID, moby.ContainerAttachOptions{
-			Stream: true,
-			Stdin:  true,
-			Stdout: true,
-			Stderr: true,
-		})
-		if err != nil {
-			return nil, nil, err
-		}
-		stdout = containerStdout{cnx}
-		stdin = containerStdin{cnx}
-	}
-	return stdin, stdout, nil
-}
-
-func getContainerName(c moby.Container) string {
-	// 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 c.Names[0][1:]
-}
-
-func (s *composeService) Down(ctx context.Context, projectName string) error {
-	eg, _ := errgroup.WithContext(ctx)
-	w := progress.ContextWriter(ctx)
-
-	project, err := s.projectFromContainerLabels(ctx, projectName)
-	if err != nil {
-		return err
-	}
-
-	err = InReverseDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error {
-		filter := filters.NewArgs(projectFilter(project.Name), serviceFilter(service.Name))
-		return s.removeContainers(ctx, w, eg, filter)
-	})
-
-	if err != nil {
-		return err
-	}
-	err = eg.Wait()
-	if err != nil {
-		return err
-	}
-	networks, err := s.apiClient.NetworkList(ctx, moby.NetworkListOptions{
-		Filters: filters.NewArgs(
-			projectFilter(projectName),
-		),
-	})
-	if err != nil {
-		return err
-	}
-	for _, n := range networks {
-		networkID := n.ID
-		networkName := n.Name
-		eg.Go(func() error {
-			return s.ensureNetworkDown(ctx, networkID, networkName)
-		})
-	}
-
-	return eg.Wait()
-}
-
-func (s *composeService) removeContainers(ctx context.Context, w progress.Writer, eg *errgroup.Group, filter filters.Args) error {
-	containers, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
-		Filters: filter,
-		All:     true,
-	})
-	if err != nil {
-		return err
-	}
-	for _, container := range containers {
-		eg.Go(func() error {
-			eventName := "Container " + getContainerName(container)
-			w.Event(progress.StoppingEvent(eventName))
-			err := s.apiClient.ContainerStop(ctx, container.ID, nil)
-			if err != nil {
-				w.Event(progress.ErrorMessageEvent(eventName, "Error while Stopping"))
-				return err
-			}
-			w.Event(progress.RemovingEvent(eventName))
-			err = s.apiClient.ContainerRemove(ctx, container.ID, moby.ContainerRemoveOptions{})
-			if err != nil {
-				w.Event(progress.ErrorMessageEvent(eventName, "Error while Removing"))
-				return err
-			}
-			w.Event(progress.RemovedEvent(eventName))
-			return nil
-		})
-	}
-	return nil
-}
-
-func (s *composeService) projectFromContainerLabels(ctx context.Context, projectName string) (*types.Project, error) {
-	containers, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
-		Filters: filters.NewArgs(
-			projectFilter(projectName),
-		),
-		All: true,
-	})
-	if err != nil {
-		return nil, err
-	}
-	fakeProject := &types.Project{
-		Name: projectName,
-	}
-	if len(containers) == 0 {
-		return fakeProject, nil
-	}
-	options, err := loadProjectOptionsFromLabels(containers[0])
-	if err != nil {
-		return nil, err
-	}
-	if options.ConfigPaths[0] == "-" {
-		for _, container := range containers {
-			fakeProject.Services = append(fakeProject.Services, types.ServiceConfig{
-				Name: container.Labels[serviceLabel],
-			})
-		}
-		return fakeProject, nil
-	}
-	project, err := cli.ProjectFromOptions(options)
-	if err != nil {
-		return nil, err
-	}
-
-	return project, nil
-}
-
-func loadProjectOptionsFromLabels(c moby.Container) (*cli.ProjectOptions, error) {
-	var configFiles []string
-	relativePathConfigFiles := strings.Split(c.Labels[configFilesLabel], ",")
-	for _, c := range relativePathConfigFiles {
-		configFiles = append(configFiles, filepath.Base(c))
-	}
-	return cli.NewProjectOptions(configFiles,
-		cli.WithOsEnv,
-		cli.WithWorkingDirectory(c.Labels[workingDirLabel]),
-		cli.WithName(c.Labels[projectLabel]))
-}
-
-func (s *composeService) Logs(ctx context.Context, projectName string, consumer compose.LogConsumer) error {
-	list, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
-		Filters: filters.NewArgs(
-			projectFilter(projectName),
-		),
-	})
-	if err != nil {
-		return err
-	}
-	eg, ctx := errgroup.WithContext(ctx)
-	for _, c := range list {
-		service := c.Labels[serviceLabel]
-		container, err := s.apiClient.ContainerInspect(ctx, c.ID)
-		if err != nil {
-			return err
-		}
-
-		eg.Go(func() error {
-			r, err := s.apiClient.ContainerLogs(ctx, container.ID, moby.ContainerLogsOptions{
-				ShowStdout: true,
-				ShowStderr: true,
-				Follow:     true,
-			})
-			defer r.Close() // nolint errcheck
-
-			if err != nil {
-				return err
-			}
-			w := getWriter(service, container.ID, consumer)
-			if container.Config.Tty {
-				_, err = io.Copy(w, r)
-			} else {
-				_, err = stdcopy.StdCopy(w, w, r)
-			}
-			return err
-		})
-	}
-	return eg.Wait()
-}
-
-type splitBuffer struct {
-	service   string
-	container string
-	consumer  compose.LogConsumer
-}
-
-// getWriter creates a io.Writer that will actually split by line and format by LogConsumer
-func getWriter(service, container string, l compose.LogConsumer) io.Writer {
-	return splitBuffer{
-		service:   service,
-		container: container,
-		consumer:  l,
-	}
-}
-
-func (s splitBuffer) Write(b []byte) (n int, err error) {
-	split := bytes.Split(b, []byte{'\n'})
-	for _, line := range split {
-		if len(line) != 0 {
-			s.consumer.Log(s.service, s.container, string(line))
-		}
-	}
-	return len(b), nil
-}
-
-func (s *composeService) Ps(ctx context.Context, projectName string) ([]compose.ServiceStatus, error) {
-	list, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
-		Filters: filters.NewArgs(
-			projectFilter(projectName),
-		),
-	})
-	if err != nil {
-		return nil, err
-	}
-	return containersToServiceStatus(list)
-}
-
-func containersToServiceStatus(containers []moby.Container) ([]compose.ServiceStatus, error) {
-	containersByLabel, keys, err := groupContainerByLabel(containers, serviceLabel)
-	if err != nil {
-		return nil, err
-	}
-	var services []compose.ServiceStatus
-	for _, service := range keys {
-		containers := containersByLabel[service]
-		runnningContainers := []moby.Container{}
-		for _, container := range containers {
-			if container.State == containerRunning {
-				runnningContainers = append(runnningContainers, container)
-			}
-		}
-		services = append(services, compose.ServiceStatus{
-			ID:       service,
-			Name:     service,
-			Desired:  len(containers),
-			Replicas: len(runnningContainers),
-		})
-	}
-	return services, nil
-}
-
-func groupContainerByLabel(containers []moby.Container, labelName string) (map[string][]moby.Container, []string, error) {
-	containersByLabel := map[string][]moby.Container{}
-	keys := []string{}
-	for _, c := range containers {
-		label, ok := c.Labels[labelName]
-		if !ok {
-			return nil, nil, fmt.Errorf("No label %q set on container %q of compose project", labelName, c.ID)
-		}
-		labelContainers, ok := containersByLabel[label]
-		if !ok {
-			labelContainers = []moby.Container{}
-			keys = append(keys, label)
-		}
-		labelContainers = append(labelContainers, c)
-		containersByLabel[label] = labelContainers
-	}
-	sort.Strings(keys)
-	return containersByLabel, keys, nil
-}
-
-func (s *composeService) List(ctx context.Context, projectName string) ([]compose.Stack, error) {
-	list, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
-		Filters: filters.NewArgs(hasProjectLabelFilter()),
-	})
-	if err != nil {
-		return nil, err
-	}
-
-	return containersToStacks(list)
-}
-
-func containersToStacks(containers []moby.Container) ([]compose.Stack, error) {
-	containersByLabel, keys, err := groupContainerByLabel(containers, projectLabel)
-	if err != nil {
-		return nil, err
-	}
-	var projects []compose.Stack
-	for _, project := range keys {
-		projects = append(projects, compose.Stack{
-			ID:     project,
-			Name:   project,
-			Status: combinedStatus(containerToState(containersByLabel[project])),
-		})
-	}
-	return projects, nil
-}
-
-func containerToState(containers []moby.Container) []string {
-	statuses := []string{}
-	for _, c := range containers {
-		statuses = append(statuses, c.State)
-	}
-	return statuses
-}
-
-func combinedStatus(statuses []string) string {
-	nbByStatus := map[string]int{}
-	keys := []string{}
-	for _, status := range statuses {
-		nb, ok := nbByStatus[status]
-		if !ok {
-			nb = 0
-			keys = append(keys, status)
-		}
-		nbByStatus[status] = nb + 1
-	}
-	sort.Strings(keys)
-	result := ""
-	for _, status := range keys {
-		nb := nbByStatus[status]
-		if result != "" {
-			result = result + ", "
-		}
-		result = result + fmt.Sprintf("%s(%d)", status, nb)
-	}
-	return result
-}
-
-func (s *composeService) Convert(ctx context.Context, project *types.Project, format string) ([]byte, error) {
-	switch format {
-	case "json":
-		return json.MarshalIndent(project, "", "  ")
-	case "yaml":
-		return yaml.Marshal(project)
-	default:
-		return nil, fmt.Errorf("unsupported format %q", format)
-	}
-}
-
-func getContainerCreateOptions(p *types.Project, s types.ServiceConfig, number int, inherit *moby.Container) (*container.Config, *container.HostConfig, *network.NetworkingConfig, error) {
-	hash, err := jsonHash(s)
-	if err != nil {
-		return nil, nil, nil, err
-	}
-	// TODO: change oneoffLabel value for containers started with `docker compose run`
-	labels := map[string]string{
-		projectLabel:         p.Name,
-		serviceLabel:         s.Name,
-		versionLabel:         ComposeVersion,
-		oneoffLabel:          "False",
-		configHashLabel:      hash,
-		workingDirLabel:      p.WorkingDir,
-		configFilesLabel:     strings.Join(p.ComposeFiles, ","),
-		containerNumberLabel: strconv.Itoa(number),
-	}
-
-	var (
-		runCmd     strslice.StrSlice
-		entrypoint strslice.StrSlice
-	)
-	if len(s.Command) > 0 {
-		runCmd = strslice.StrSlice(s.Command)
-	}
-	if len(s.Entrypoint) > 0 {
-		entrypoint = strslice.StrSlice(s.Entrypoint)
-	}
-	image := s.Image
-	if s.Image == "" {
-		image = fmt.Sprintf("%s_%s", p.Name, s.Name)
-	}
-
-	var (
-		tty         = s.Tty
-		stdinOpen   = s.StdinOpen
-		attachStdin = false
-	)
-
-	containerConfig := container.Config{
-		Hostname:        s.Hostname,
-		Domainname:      s.DomainName,
-		User:            s.User,
-		ExposedPorts:    buildContainerPorts(s),
-		Tty:             tty,
-		OpenStdin:       stdinOpen,
-		StdinOnce:       true,
-		AttachStdin:     attachStdin,
-		AttachStderr:    true,
-		AttachStdout:    true,
-		Cmd:             runCmd,
-		Image:           image,
-		WorkingDir:      s.WorkingDir,
-		Entrypoint:      entrypoint,
-		NetworkDisabled: s.NetworkMode == "disabled",
-		MacAddress:      s.MacAddress,
-		Labels:          labels,
-		StopSignal:      s.StopSignal,
-		Env:             toMobyEnv(s.Environment),
-		Healthcheck:     toMobyHealthCheck(s.HealthCheck),
-		// Volumes:         // FIXME unclear to me the overlap with HostConfig.Mounts
-		StopTimeout: toSeconds(s.StopGracePeriod),
-	}
-
-	mountOptions, err := buildContainerMountOptions(s, inherit)
-	if err != nil {
-		return nil, nil, nil, err
-	}
-	bindings := buildContainerBindingOptions(s)
-
-	networkMode := getNetworkMode(p, s)
-	hostConfig := container.HostConfig{
-		Mounts:         mountOptions,
-		CapAdd:         strslice.StrSlice(s.CapAdd),
-		CapDrop:        strslice.StrSlice(s.CapDrop),
-		NetworkMode:    networkMode,
-		Init:           s.Init,
-		ReadonlyRootfs: s.ReadOnly,
-		// ShmSize: , TODO
-		Sysctls:      s.Sysctls,
-		PortBindings: bindings,
-	}
-
-	networkConfig := buildDefaultNetworkConfig(s, networkMode)
-	return &containerConfig, &hostConfig, networkConfig, nil
-}
-
-func buildContainerPorts(s types.ServiceConfig) nat.PortSet {
-	ports := nat.PortSet{}
-	for _, p := range s.Ports {
-		p := nat.Port(fmt.Sprintf("%d/%s", p.Target, p.Protocol))
-		ports[p] = struct{}{}
-	}
-	return ports
-}
-
-func buildContainerBindingOptions(s types.ServiceConfig) nat.PortMap {
-	bindings := nat.PortMap{}
-	for _, port := range s.Ports {
-		p := nat.Port(fmt.Sprintf("%d/%s", port.Target, port.Protocol))
-		bind := []nat.PortBinding{}
-		binding := nat.PortBinding{}
-		if port.Published > 0 {
-			binding.HostPort = fmt.Sprint(port.Published)
-		}
-		bind = append(bind, binding)
-		bindings[p] = bind
-	}
-	return bindings
-}
-
-func buildContainerMountOptions(s types.ServiceConfig, inherit *moby.Container) ([]mount.Mount, error) {
-	mounts := []mount.Mount{}
-	var inherited []string
-	if inherit != nil {
-		for _, m := range inherit.Mounts {
-			if m.Type == "tmpfs" {
-				continue
-			}
-			src := m.Source
-			if m.Type == "volume" {
-				src = m.Name
-			}
-			mounts = append(mounts, mount.Mount{
-				Type:     m.Type,
-				Source:   src,
-				Target:   m.Destination,
-				ReadOnly: !m.RW,
-			})
-			inherited = append(inherited, m.Destination)
-		}
-	}
-
-	for _, v := range s.Volumes {
-		if contains(inherited, v.Target) {
-			continue
-		}
-		mount, err := buildMount(v)
-		if err != nil {
-			return nil, err
-		}
-		mounts = append(mounts, mount)
-	}
-	return mounts, nil
-}
-
-func buildMount(volume types.ServiceVolumeConfig) (mount.Mount, error) {
-	source := volume.Source
-	if volume.Type == "bind" && !filepath.IsAbs(source) {
-		// volume source has already been prefixed with workdir if required, by compose-go project loader
-		var err error
-		source, err = filepath.Abs(source)
-		if err != nil {
-			return mount.Mount{}, err
-		}
-	}
-
-	return mount.Mount{
-		Type:          mount.Type(volume.Type),
-		Source:        source,
-		Target:        volume.Target,
-		ReadOnly:      volume.ReadOnly,
-		Consistency:   mount.Consistency(volume.Consistency),
-		BindOptions:   buildBindOption(volume.Bind),
-		VolumeOptions: buildVolumeOptions(volume.Volume),
-		TmpfsOptions:  buildTmpfsOptions(volume.Tmpfs),
-	}, nil
-}
-
-func buildBindOption(bind *types.ServiceVolumeBind) *mount.BindOptions {
-	if bind == nil {
-		return nil
-	}
-	return &mount.BindOptions{
-		Propagation: mount.Propagation(bind.Propagation),
-		// NonRecursive: false, FIXME missing from model ?
-	}
-}
-
-func buildVolumeOptions(vol *types.ServiceVolumeVolume) *mount.VolumeOptions {
-	if vol == nil {
-		return nil
-	}
-	return &mount.VolumeOptions{
-		NoCopy: vol.NoCopy,
-		// Labels:       , // FIXME missing from model ?
-		// DriverConfig: , // FIXME missing from model ?
-	}
-}
-
-func buildTmpfsOptions(tmpfs *types.ServiceVolumeTmpfs) *mount.TmpfsOptions {
-	if tmpfs == nil {
-		return nil
-	}
-	return &mount.TmpfsOptions{
-		SizeBytes: tmpfs.Size,
-		// Mode:      , // FIXME missing from model ?
-	}
-}
-
-func buildDefaultNetworkConfig(s types.ServiceConfig, networkMode container.NetworkMode) *network.NetworkingConfig {
-	config := map[string]*network.EndpointSettings{}
-	net := string(networkMode)
-	config[net] = &network.EndpointSettings{
-		Aliases: getAliases(s, s.Networks[net]),
-	}
-
-	return &network.NetworkingConfig{
-		EndpointsConfig: config,
-	}
-}
-
-func getAliases(s types.ServiceConfig, c *types.ServiceNetworkConfig) []string {
-	aliases := []string{s.Name}
-	if c != nil {
-		aliases = append(aliases, c.Aliases...)
-	}
-	return aliases
-}
-
-func getNetworkMode(p *types.Project, service types.ServiceConfig) container.NetworkMode {
-	mode := service.NetworkMode
-	if mode == "" {
-		if len(p.Networks) > 0 {
-			for name := range getNetworksForService(service) {
-				return container.NetworkMode(p.Networks[name].Name)
-			}
-		}
-		return container.NetworkMode("none")
-	}
-
-	// FIXME incomplete implementation
-	if strings.HasPrefix(mode, "service:") {
-		panic("Not yet implemented")
-	}
-	if strings.HasPrefix(mode, "container:") {
-		panic("Not yet implemented")
-	}
-
-	return container.NetworkMode(mode)
-}
-
-func getNetworksForService(s types.ServiceConfig) map[string]*types.ServiceNetworkConfig {
-	if len(s.Networks) > 0 {
-		return s.Networks
-	}
-	return map[string]*types.ServiceNetworkConfig{"default": nil}
-}
-
-func (s *composeService) ensureNetwork(ctx context.Context, n types.NetworkConfig) error {
-	_, err := s.apiClient.NetworkInspect(ctx, n.Name, moby.NetworkInspectOptions{})
-	if err != nil {
-		if errdefs.IsNotFound(err) {
-			if n.External.External {
-				return fmt.Errorf("network %s declared as external, but could not be found", n.Name)
-			}
-			createOpts := moby.NetworkCreate{
-				// TODO NameSpace Labels
-				Labels:     n.Labels,
-				Driver:     n.Driver,
-				Options:    n.DriverOpts,
-				Internal:   n.Internal,
-				Attachable: n.Attachable,
-			}
-
-			if n.Ipam.Driver != "" || len(n.Ipam.Config) > 0 {
-				createOpts.IPAM = &network.IPAM{}
-			}
-
-			if n.Ipam.Driver != "" {
-				createOpts.IPAM.Driver = n.Ipam.Driver
-			}
-
-			for _, ipamConfig := range n.Ipam.Config {
-				config := network.IPAMConfig{
-					Subnet: ipamConfig.Subnet,
-				}
-				createOpts.IPAM.Config = append(createOpts.IPAM.Config, config)
-			}
-			networkEventName := fmt.Sprintf("Network %q", n.Name)
-			w := progress.ContextWriter(ctx)
-			w.Event(progress.CreatingEvent(networkEventName))
-			if _, err := s.apiClient.NetworkCreate(ctx, n.Name, createOpts); err != nil {
-				w.Event(progress.ErrorEvent(networkEventName))
-				return errors.Wrapf(err, "failed to create network %s", n.Name)
-			}
-			w.Event(progress.CreatedEvent(networkEventName))
-			return nil
-		}
-		return err
-	}
-	return nil
-}
-
-func (s *composeService) ensureNetworkDown(ctx context.Context, networkID string, networkName string) error {
-	w := progress.ContextWriter(ctx)
-	eventName := fmt.Sprintf("Network %q", networkName)
-	w.Event(progress.RemovingEvent(eventName))
-
-	if err := s.apiClient.NetworkRemove(ctx, networkID); err != nil {
-		w.Event(progress.ErrorEvent(eventName))
-		return errors.Wrapf(err, fmt.Sprintf("failed to create network %s", networkID))
-	}
-
-	w.Event(progress.RemovedEvent(eventName))
-	return nil
-}
-
-func (s *composeService) ensureVolume(ctx context.Context, volume types.VolumeConfig) error {
-	// TODO could identify volume by label vs name
-	_, err := s.apiClient.VolumeInspect(ctx, volume.Name)
-	if err != nil {
-		if !errdefs.IsNotFound(err) {
-			return err
-		}
-		eventName := fmt.Sprintf("Volume %q", volume.Name)
-		w := progress.ContextWriter(ctx)
-		w.Event(progress.CreatingEvent(eventName))
-		// TODO we miss support for driver_opts and labels
-		_, err := s.apiClient.VolumeCreate(ctx, mobyvolume.VolumeCreateBody{
-			Labels:     volume.Labels,
-			Name:       volume.Name,
-			Driver:     volume.Driver,
-			DriverOpts: volume.DriverOpts,
-		})
-		if err != nil {
-			w.Event(progress.ErrorEvent(eventName))
-			return err
-		}
-		w.Event(progress.CreatedEvent(eventName))
-	}
-	return nil
-}
diff --git a/local/compose/attach.go b/local/compose/attach.go
new file mode 100644
index 00000000..37980170
--- /dev/null
+++ b/local/compose/attach.go
@@ -0,0 +1,129 @@
+/*
+   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"
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/docker/compose-cli/api/compose"
+	convert "github.com/docker/compose-cli/local/moby"
+
+	"github.com/compose-spec/compose-go/types"
+	moby "github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/filters"
+	"github.com/docker/docker/pkg/stdcopy"
+	"golang.org/x/sync/errgroup"
+)
+
+func (s *composeService) attach(ctx context.Context, project *types.Project, consumer compose.LogConsumer) (*errgroup.Group, error) {
+	containers, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
+		Filters: filters.NewArgs(
+			projectFilter(project.Name),
+		),
+		All: true,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	var names []string
+	for _, c := range containers {
+		names = append(names, getContainerName(c))
+	}
+	fmt.Printf("Attaching to %s\n", strings.Join(names, ", "))
+
+	eg, ctx := errgroup.WithContext(ctx)
+	for _, c := range containers {
+		container := c
+		eg.Go(func() error {
+			return s.attachContainer(ctx, container, consumer, project)
+		})
+	}
+	return eg, nil
+}
+
+func (s *composeService) attachContainer(ctx context.Context, container moby.Container, consumer compose.LogConsumer, project *types.Project) error {
+	serviceName := container.Labels[serviceLabel]
+	w := getWriter(serviceName, container.ID, consumer)
+
+	service, err := project.GetService(serviceName)
+	if err != nil {
+		return err
+	}
+
+	return s.attachContainerStreams(ctx, container, service.Tty, nil, w)
+}
+
+func (s *composeService) attachContainerStreams(ctx context.Context, container moby.Container, tty bool, r io.Reader, w io.Writer) error {
+	stdin, stdout, err := s.getContainerStreams(ctx, container)
+	if err != nil {
+		return err
+	}
+
+	go func() {
+		<-ctx.Done()
+		stdout.Close() //nolint:errcheck
+		stdin.Close()  //nolint:errcheck
+	}()
+
+	if r != nil && stdin != nil {
+		go func() {
+			io.Copy(stdin, r) //nolint:errcheck
+		}()
+	}
+
+	if w != nil {
+		if tty {
+			_, err = io.Copy(w, stdout)
+		} else {
+			_, err = stdcopy.StdCopy(w, w, stdout)
+		}
+	}
+	return err
+}
+
+func (s *composeService) getContainerStreams(ctx context.Context, container moby.Container) (io.WriteCloser, io.ReadCloser, error) {
+	var stdout io.ReadCloser
+	var stdin io.WriteCloser
+	if container.State == convert.ContainerRunning {
+		logs, err := s.apiClient.ContainerLogs(ctx, container.ID, moby.ContainerLogsOptions{
+			ShowStdout: true,
+			ShowStderr: true,
+			Follow:     true,
+		})
+		if err != nil {
+			return nil, nil, err
+		}
+		stdout = logs
+	} else {
+		cnx, err := s.apiClient.ContainerAttach(ctx, container.ID, moby.ContainerAttachOptions{
+			Stream: true,
+			Stdin:  true,
+			Stdout: true,
+			Stderr: true,
+		})
+		if err != nil {
+			return nil, nil, err
+		}
+		stdout = convert.ContainerStdout{HijackedResponse: cnx}
+		stdin = convert.ContainerStdin{HijackedResponse: cnx}
+	}
+	return stdin, stdout, nil
+}
diff --git a/local/build.go b/local/compose/build.go
similarity index 90%
rename from local/build.go
rename to local/compose/build.go
index 21abaf38..04137239 100644
--- a/local/build.go
+++ b/local/compose/build.go
@@ -14,7 +14,7 @@
    limitations under the License.
 */
 
-package local
+package compose
 
 import (
 	"context"
@@ -23,15 +23,29 @@ import (
 	"path"
 	"strings"
 
-	"github.com/docker/docker/errdefs"
-
 	"github.com/compose-spec/compose-go/types"
 	"github.com/docker/buildx/build"
 	"github.com/docker/buildx/driver"
 	_ "github.com/docker/buildx/driver/docker" // required to get default driver registered
 	"github.com/docker/buildx/util/progress"
+	"github.com/docker/docker/errdefs"
 )
 
+func (s *composeService) Build(ctx context.Context, project *types.Project) error {
+	opts := map[string]build.Options{}
+	for _, service := range project.Services {
+		if service.Build != nil {
+			imageName := service.Image
+			if imageName == "" {
+				imageName = project.Name + "_" + service.Name
+			}
+			opts[imageName] = s.toBuildOptions(service, project.WorkingDir, imageName)
+		}
+	}
+
+	return s.build(ctx, project, opts)
+}
+
 func (s *composeService) ensureImagesExists(ctx context.Context, project *types.Project) error {
 	opts := map[string]build.Options{}
 	for _, service := range project.Services {
diff --git a/local/compose/compose.go b/local/compose/compose.go
new file mode 100644
index 00000000..6d220c64
--- /dev/null
+++ b/local/compose/compose.go
@@ -0,0 +1,66 @@
+/*
+   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"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/docker/compose-cli/api/compose"
+
+	"github.com/compose-spec/compose-go/types"
+	errdefs2 "github.com/docker/compose-cli/errdefs"
+	moby "github.com/docker/docker/api/types"
+	"github.com/docker/docker/client"
+	"github.com/sanathkr/go-yaml"
+)
+
+// NewComposeService create a local implementation of the compose.Service API
+func NewComposeService(apiClient *client.Client) compose.Service {
+	return &composeService{apiClient: apiClient}
+}
+
+type composeService struct {
+	apiClient *client.Client
+}
+
+func (s *composeService) Up(ctx context.Context, project *types.Project, detach bool) error {
+	return errdefs2.ErrNotImplemented
+}
+
+func getContainerName(c moby.Container) string {
+	// 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 c.Names[0][1:]
+}
+
+func (s *composeService) Convert(ctx context.Context, project *types.Project, format string) ([]byte, error) {
+	switch format {
+	case "json":
+		return json.MarshalIndent(project, "", "  ")
+	case "yaml":
+		return yaml.Marshal(project)
+	default:
+		return nil, fmt.Errorf("unsupported format %q", format)
+	}
+}
diff --git a/local/convergence.go b/local/compose/convergence.go
similarity index 97%
rename from local/convergence.go
rename to local/compose/convergence.go
index e62a566f..53df364d 100644
--- a/local/convergence.go
+++ b/local/compose/convergence.go
@@ -14,7 +14,7 @@
    limitations under the License.
 */
 
-package local
+package compose
 
 import (
 	"context"
@@ -28,6 +28,7 @@ import (
 	"github.com/docker/docker/api/types/network"
 	"golang.org/x/sync/errgroup"
 
+	status "github.com/docker/compose-cli/local/moby"
 	"github.com/docker/compose-cli/progress"
 )
 
@@ -99,10 +100,10 @@ func (s *composeService) ensureService(ctx context.Context, project *types.Proje
 
 		w := progress.ContextWriter(ctx)
 		switch container.State {
-		case containerRunning:
+		case status.ContainerRunning:
 			w.Event(progress.RunningEvent(name))
-		case containerCreated:
-		case containerRestarting:
+		case status.ContainerCreated:
+		case status.ContainerRestarting:
 			w.Event(progress.CreatedEvent(name))
 		default:
 			eg.Go(func() error {
@@ -304,7 +305,7 @@ func (s *composeService) startService(ctx context.Context, project *types.Projec
 	eg, ctx := errgroup.WithContext(ctx)
 	for _, c := range containers {
 		container := c
-		if container.State == containerRunning {
+		if container.State == status.ContainerRunning {
 			continue
 		}
 		eg.Go(func() error {
diff --git a/local/compose/create.go b/local/compose/create.go
new file mode 100644
index 00000000..49b08e1d
--- /dev/null
+++ b/local/compose/create.go
@@ -0,0 +1,411 @@
+/*
+   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"
+	"fmt"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	convert "github.com/docker/compose-cli/local/moby"
+	"github.com/docker/compose-cli/progress"
+
+	"github.com/compose-spec/compose-go/types"
+	moby "github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/mount"
+	"github.com/docker/docker/api/types/network"
+	"github.com/docker/docker/api/types/strslice"
+	volume_api "github.com/docker/docker/api/types/volume"
+	"github.com/docker/docker/errdefs"
+	"github.com/docker/go-connections/nat"
+	"github.com/pkg/errors"
+)
+
+func (s *composeService) Create(ctx context.Context, project *types.Project) error {
+	err := s.ensureImagesExists(ctx, project)
+	if err != nil {
+		return err
+	}
+
+	for k, network := range project.Networks {
+		if !network.External.External && network.Name != "" {
+			network.Name = fmt.Sprintf("%s_%s", project.Name, k)
+			project.Networks[k] = network
+		}
+		network.Labels = network.Labels.Add(networkLabel, k)
+		network.Labels = network.Labels.Add(projectLabel, project.Name)
+		network.Labels = network.Labels.Add(versionLabel, ComposeVersion)
+		err := s.ensureNetwork(ctx, network)
+		if err != nil {
+			return err
+		}
+	}
+
+	for k, volume := range project.Volumes {
+		if !volume.External.External && volume.Name != "" {
+			volume.Name = fmt.Sprintf("%s_%s", project.Name, k)
+			project.Volumes[k] = volume
+		}
+		volume.Labels = volume.Labels.Add(volumeLabel, k)
+		volume.Labels = volume.Labels.Add(projectLabel, project.Name)
+		volume.Labels = volume.Labels.Add(versionLabel, ComposeVersion)
+		err := s.ensureVolume(ctx, volume)
+		if err != nil {
+			return err
+		}
+	}
+
+	return InDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error {
+		return s.ensureService(c, project, service)
+	})
+}
+
+func getContainerCreateOptions(p *types.Project, s types.ServiceConfig, number int, inherit *moby.Container) (*container.Config, *container.HostConfig, *network.NetworkingConfig, error) {
+	hash, err := jsonHash(s)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+	// TODO: change oneoffLabel value for containers started with `docker compose run`
+	labels := map[string]string{
+		projectLabel:         p.Name,
+		serviceLabel:         s.Name,
+		versionLabel:         ComposeVersion,
+		oneoffLabel:          "False",
+		configHashLabel:      hash,
+		workingDirLabel:      p.WorkingDir,
+		configFilesLabel:     strings.Join(p.ComposeFiles, ","),
+		containerNumberLabel: strconv.Itoa(number),
+	}
+
+	var (
+		runCmd     strslice.StrSlice
+		entrypoint strslice.StrSlice
+	)
+	if len(s.Command) > 0 {
+		runCmd = strslice.StrSlice(s.Command)
+	}
+	if len(s.Entrypoint) > 0 {
+		entrypoint = strslice.StrSlice(s.Entrypoint)
+	}
+	image := s.Image
+	if s.Image == "" {
+		image = fmt.Sprintf("%s_%s", p.Name, s.Name)
+	}
+
+	var (
+		tty         = s.Tty
+		stdinOpen   = s.StdinOpen
+		attachStdin = false
+	)
+
+	containerConfig := container.Config{
+		Hostname:        s.Hostname,
+		Domainname:      s.DomainName,
+		User:            s.User,
+		ExposedPorts:    buildContainerPorts(s),
+		Tty:             tty,
+		OpenStdin:       stdinOpen,
+		StdinOnce:       true,
+		AttachStdin:     attachStdin,
+		AttachStderr:    true,
+		AttachStdout:    true,
+		Cmd:             runCmd,
+		Image:           image,
+		WorkingDir:      s.WorkingDir,
+		Entrypoint:      entrypoint,
+		NetworkDisabled: s.NetworkMode == "disabled",
+		MacAddress:      s.MacAddress,
+		Labels:          labels,
+		StopSignal:      s.StopSignal,
+		Env:             convert.ToMobyEnv(s.Environment),
+		Healthcheck:     convert.ToMobyHealthCheck(s.HealthCheck),
+		// Volumes:         // FIXME unclear to me the overlap with HostConfig.Mounts
+		StopTimeout: convert.ToSeconds(s.StopGracePeriod),
+	}
+
+	mountOptions, err := buildContainerMountOptions(s, inherit)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+	bindings := buildContainerBindingOptions(s)
+
+	networkMode := getNetworkMode(p, s)
+	hostConfig := container.HostConfig{
+		Mounts:         mountOptions,
+		CapAdd:         strslice.StrSlice(s.CapAdd),
+		CapDrop:        strslice.StrSlice(s.CapDrop),
+		NetworkMode:    networkMode,
+		Init:           s.Init,
+		ReadonlyRootfs: s.ReadOnly,
+		// ShmSize: , TODO
+		Sysctls:      s.Sysctls,
+		PortBindings: bindings,
+	}
+
+	networkConfig := buildDefaultNetworkConfig(s, networkMode)
+	return &containerConfig, &hostConfig, networkConfig, nil
+}
+
+func buildContainerPorts(s types.ServiceConfig) nat.PortSet {
+	ports := nat.PortSet{}
+	for _, p := range s.Ports {
+		p := nat.Port(fmt.Sprintf("%d/%s", p.Target, p.Protocol))
+		ports[p] = struct{}{}
+	}
+	return ports
+}
+
+func buildContainerBindingOptions(s types.ServiceConfig) nat.PortMap {
+	bindings := nat.PortMap{}
+	for _, port := range s.Ports {
+		p := nat.Port(fmt.Sprintf("%d/%s", port.Target, port.Protocol))
+		bind := []nat.PortBinding{}
+		binding := nat.PortBinding{}
+		if port.Published > 0 {
+			binding.HostPort = fmt.Sprint(port.Published)
+		}
+		bind = append(bind, binding)
+		bindings[p] = bind
+	}
+	return bindings
+}
+
+func buildContainerMountOptions(s types.ServiceConfig, inherit *moby.Container) ([]mount.Mount, error) {
+	mounts := []mount.Mount{}
+	var inherited []string
+	if inherit != nil {
+		for _, m := range inherit.Mounts {
+			if m.Type == "tmpfs" {
+				continue
+			}
+			src := m.Source
+			if m.Type == "volume" {
+				src = m.Name
+			}
+			mounts = append(mounts, mount.Mount{
+				Type:     m.Type,
+				Source:   src,
+				Target:   m.Destination,
+				ReadOnly: !m.RW,
+			})
+			inherited = append(inherited, m.Destination)
+		}
+	}
+
+	for _, v := range s.Volumes {
+		if contains(inherited, v.Target) {
+			continue
+		}
+		mount, err := buildMount(v)
+		if err != nil {
+			return nil, err
+		}
+		mounts = append(mounts, mount)
+	}
+	return mounts, nil
+}
+
+func buildMount(volume types.ServiceVolumeConfig) (mount.Mount, error) {
+	source := volume.Source
+	if volume.Type == "bind" && !filepath.IsAbs(source) {
+		// volume source has already been prefixed with workdir if required, by compose-go project loader
+		var err error
+		source, err = filepath.Abs(source)
+		if err != nil {
+			return mount.Mount{}, err
+		}
+	}
+
+	return mount.Mount{
+		Type:          mount.Type(volume.Type),
+		Source:        source,
+		Target:        volume.Target,
+		ReadOnly:      volume.ReadOnly,
+		Consistency:   mount.Consistency(volume.Consistency),
+		BindOptions:   buildBindOption(volume.Bind),
+		VolumeOptions: buildVolumeOptions(volume.Volume),
+		TmpfsOptions:  buildTmpfsOptions(volume.Tmpfs),
+	}, nil
+}
+
+func buildBindOption(bind *types.ServiceVolumeBind) *mount.BindOptions {
+	if bind == nil {
+		return nil
+	}
+	return &mount.BindOptions{
+		Propagation: mount.Propagation(bind.Propagation),
+		// NonRecursive: false, FIXME missing from model ?
+	}
+}
+
+func buildVolumeOptions(vol *types.ServiceVolumeVolume) *mount.VolumeOptions {
+	if vol == nil {
+		return nil
+	}
+	return &mount.VolumeOptions{
+		NoCopy: vol.NoCopy,
+		// Labels:       , // FIXME missing from model ?
+		// DriverConfig: , // FIXME missing from model ?
+	}
+}
+
+func buildTmpfsOptions(tmpfs *types.ServiceVolumeTmpfs) *mount.TmpfsOptions {
+	if tmpfs == nil {
+		return nil
+	}
+	return &mount.TmpfsOptions{
+		SizeBytes: tmpfs.Size,
+		// Mode:      , // FIXME missing from model ?
+	}
+}
+
+func buildDefaultNetworkConfig(s types.ServiceConfig, networkMode container.NetworkMode) *network.NetworkingConfig {
+	config := map[string]*network.EndpointSettings{}
+	net := string(networkMode)
+	config[net] = &network.EndpointSettings{
+		Aliases: getAliases(s, s.Networks[net]),
+	}
+
+	return &network.NetworkingConfig{
+		EndpointsConfig: config,
+	}
+}
+
+func getAliases(s types.ServiceConfig, c *types.ServiceNetworkConfig) []string {
+	aliases := []string{s.Name}
+	if c != nil {
+		aliases = append(aliases, c.Aliases...)
+	}
+	return aliases
+}
+
+func getNetworkMode(p *types.Project, service types.ServiceConfig) container.NetworkMode {
+	mode := service.NetworkMode
+	if mode == "" {
+		if len(p.Networks) > 0 {
+			for name := range getNetworksForService(service) {
+				return container.NetworkMode(p.Networks[name].Name)
+			}
+		}
+		return container.NetworkMode("none")
+	}
+
+	// FIXME incomplete implementation
+	if strings.HasPrefix(mode, "service:") {
+		panic("Not yet implemented")
+	}
+	if strings.HasPrefix(mode, "container:") {
+		panic("Not yet implemented")
+	}
+
+	return container.NetworkMode(mode)
+}
+
+func getNetworksForService(s types.ServiceConfig) map[string]*types.ServiceNetworkConfig {
+	if len(s.Networks) > 0 {
+		return s.Networks
+	}
+	return map[string]*types.ServiceNetworkConfig{"default": nil}
+}
+
+func (s *composeService) ensureNetwork(ctx context.Context, n types.NetworkConfig) error {
+	_, err := s.apiClient.NetworkInspect(ctx, n.Name, moby.NetworkInspectOptions{})
+	if err != nil {
+		if errdefs.IsNotFound(err) {
+			if n.External.External {
+				return fmt.Errorf("network %s declared as external, but could not be found", n.Name)
+			}
+			createOpts := moby.NetworkCreate{
+				// TODO NameSpace Labels
+				Labels:     n.Labels,
+				Driver:     n.Driver,
+				Options:    n.DriverOpts,
+				Internal:   n.Internal,
+				Attachable: n.Attachable,
+			}
+
+			if n.Ipam.Driver != "" || len(n.Ipam.Config) > 0 {
+				createOpts.IPAM = &network.IPAM{}
+			}
+
+			if n.Ipam.Driver != "" {
+				createOpts.IPAM.Driver = n.Ipam.Driver
+			}
+
+			for _, ipamConfig := range n.Ipam.Config {
+				config := network.IPAMConfig{
+					Subnet: ipamConfig.Subnet,
+				}
+				createOpts.IPAM.Config = append(createOpts.IPAM.Config, config)
+			}
+			networkEventName := fmt.Sprintf("Network %q", n.Name)
+			w := progress.ContextWriter(ctx)
+			w.Event(progress.CreatingEvent(networkEventName))
+			if _, err := s.apiClient.NetworkCreate(ctx, n.Name, createOpts); err != nil {
+				w.Event(progress.ErrorEvent(networkEventName))
+				return errors.Wrapf(err, "failed to create network %s", n.Name)
+			}
+			w.Event(progress.CreatedEvent(networkEventName))
+			return nil
+		}
+		return err
+	}
+	return nil
+}
+
+func (s *composeService) ensureNetworkDown(ctx context.Context, networkID string, networkName string) error {
+	w := progress.ContextWriter(ctx)
+	eventName := fmt.Sprintf("Network %q", networkName)
+	w.Event(progress.RemovingEvent(eventName))
+
+	if err := s.apiClient.NetworkRemove(ctx, networkID); err != nil {
+		w.Event(progress.ErrorEvent(eventName))
+		return errors.Wrapf(err, fmt.Sprintf("failed to create network %s", networkID))
+	}
+
+	w.Event(progress.RemovedEvent(eventName))
+	return nil
+}
+
+func (s *composeService) ensureVolume(ctx context.Context, volume types.VolumeConfig) error {
+	// TODO could identify volume by label vs name
+	_, err := s.apiClient.VolumeInspect(ctx, volume.Name)
+	if err != nil {
+		if !errdefs.IsNotFound(err) {
+			return err
+		}
+		eventName := fmt.Sprintf("Volume %q", volume.Name)
+		w := progress.ContextWriter(ctx)
+		w.Event(progress.CreatingEvent(eventName))
+		// TODO we miss support for driver_opts and labels
+		_, err := s.apiClient.VolumeCreate(ctx, volume_api.VolumeCreateBody{
+			Labels:     volume.Labels,
+			Name:       volume.Name,
+			Driver:     volume.Driver,
+			DriverOpts: volume.DriverOpts,
+		})
+		if err != nil {
+			w.Event(progress.ErrorEvent(eventName))
+			return err
+		}
+		w.Event(progress.CreatedEvent(eventName))
+	}
+	return nil
+}
diff --git a/local/dependencies.go b/local/compose/dependencies.go
similarity index 99%
rename from local/dependencies.go
rename to local/compose/dependencies.go
index ecfe06b4..91a0c033 100644
--- a/local/dependencies.go
+++ b/local/compose/dependencies.go
@@ -14,7 +14,7 @@
    limitations under the License.
 */
 
-package local
+package compose
 
 import (
 	"context"
diff --git a/local/compose/down.go b/local/compose/down.go
new file mode 100644
index 00000000..a9ada09e
--- /dev/null
+++ b/local/compose/down.go
@@ -0,0 +1,149 @@
+/*
+   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"
+	"path/filepath"
+	"strings"
+
+	"github.com/docker/compose-cli/progress"
+
+	"github.com/compose-spec/compose-go/cli"
+	"github.com/compose-spec/compose-go/types"
+	moby "github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/filters"
+	"golang.org/x/sync/errgroup"
+)
+
+func (s *composeService) Down(ctx context.Context, projectName string) error {
+	eg, _ := errgroup.WithContext(ctx)
+	w := progress.ContextWriter(ctx)
+
+	project, err := s.projectFromContainerLabels(ctx, projectName)
+	if err != nil {
+		return err
+	}
+
+	err = InReverseDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error {
+		filter := filters.NewArgs(projectFilter(project.Name), serviceFilter(service.Name))
+		return s.removeContainers(ctx, w, eg, filter)
+	})
+
+	if err != nil {
+		return err
+	}
+	err = eg.Wait()
+	if err != nil {
+		return err
+	}
+	networks, err := s.apiClient.NetworkList(ctx, moby.NetworkListOptions{
+		Filters: filters.NewArgs(
+			projectFilter(projectName),
+		),
+	})
+	if err != nil {
+		return err
+	}
+	for _, n := range networks {
+		networkID := n.ID
+		networkName := n.Name
+		eg.Go(func() error {
+			return s.ensureNetworkDown(ctx, networkID, networkName)
+		})
+	}
+
+	return eg.Wait()
+}
+
+func (s *composeService) removeContainers(ctx context.Context, w progress.Writer, eg *errgroup.Group, filter filters.Args) error {
+	containers, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
+		Filters: filter,
+		All:     true,
+	})
+	if err != nil {
+		return err
+	}
+	for _, container := range containers {
+		eg.Go(func() error {
+			eventName := "Container " + getContainerName(container)
+			w.Event(progress.StoppingEvent(eventName))
+			err := s.apiClient.ContainerStop(ctx, container.ID, nil)
+			if err != nil {
+				w.Event(progress.ErrorMessageEvent(eventName, "Error while Stopping"))
+				return err
+			}
+			w.Event(progress.RemovingEvent(eventName))
+			err = s.apiClient.ContainerRemove(ctx, container.ID, moby.ContainerRemoveOptions{})
+			if err != nil {
+				w.Event(progress.ErrorMessageEvent(eventName, "Error while Removing"))
+				return err
+			}
+			w.Event(progress.RemovedEvent(eventName))
+			return nil
+		})
+	}
+	return nil
+}
+
+func (s *composeService) projectFromContainerLabels(ctx context.Context, projectName string) (*types.Project, error) {
+	containers, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
+		Filters: filters.NewArgs(
+			projectFilter(projectName),
+		),
+		All: true,
+	})
+	if err != nil {
+		return nil, err
+	}
+	fakeProject := &types.Project{
+		Name: projectName,
+	}
+	if len(containers) == 0 {
+		return fakeProject, nil
+	}
+	options, err := loadProjectOptionsFromLabels(containers[0])
+	if err != nil {
+		return nil, err
+	}
+	if options.ConfigPaths[0] == "-" {
+		for _, container := range containers {
+			fakeProject.Services = append(fakeProject.Services, types.ServiceConfig{
+				Name: container.Labels[serviceLabel],
+			})
+		}
+		return fakeProject, nil
+	}
+	project, err := cli.ProjectFromOptions(options)
+	if err != nil {
+		return nil, err
+	}
+
+	return project, nil
+}
+
+func loadProjectOptionsFromLabels(c moby.Container) (*cli.ProjectOptions, error) {
+	var configFiles []string
+	relativePathConfigFiles := strings.Split(c.Labels[configFilesLabel], ",")
+	for _, c := range relativePathConfigFiles {
+		configFiles = append(configFiles, filepath.Base(c))
+	}
+	return cli.NewProjectOptions(configFiles,
+		cli.WithOsEnv,
+		cli.WithWorkingDirectory(c.Labels[workingDirLabel]),
+		cli.WithName(c.Labels[projectLabel]))
+}
diff --git a/local/labels.go b/local/compose/labels.go
similarity index 99%
rename from local/labels.go
rename to local/compose/labels.go
index 2fd388f5..ce36bf49 100644
--- a/local/labels.go
+++ b/local/compose/labels.go
@@ -14,7 +14,7 @@
    limitations under the License.
 */
 
-package local
+package compose
 
 import (
 	"fmt"
diff --git a/local/compose/logs.go b/local/compose/logs.go
new file mode 100644
index 00000000..6d59a833
--- /dev/null
+++ b/local/compose/logs.go
@@ -0,0 +1,95 @@
+/*
+   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 (
+	"bytes"
+	"context"
+	"io"
+
+	"github.com/docker/compose-cli/api/compose"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/filters"
+	"github.com/docker/docker/pkg/stdcopy"
+	"golang.org/x/sync/errgroup"
+)
+
+func (s *composeService) Logs(ctx context.Context, projectName string, consumer compose.LogConsumer) error {
+	list, err := s.apiClient.ContainerList(ctx, types.ContainerListOptions{
+		Filters: filters.NewArgs(
+			projectFilter(projectName),
+		),
+	})
+	if err != nil {
+		return err
+	}
+	eg, ctx := errgroup.WithContext(ctx)
+	for _, c := range list {
+		service := c.Labels[serviceLabel]
+		container, err := s.apiClient.ContainerInspect(ctx, c.ID)
+		if err != nil {
+			return err
+		}
+
+		eg.Go(func() error {
+			r, err := s.apiClient.ContainerLogs(ctx, container.ID, types.ContainerLogsOptions{
+				ShowStdout: true,
+				ShowStderr: true,
+				Follow:     true,
+			})
+			defer r.Close() // nolint errcheck
+
+			if err != nil {
+				return err
+			}
+			w := getWriter(service, container.ID, consumer)
+			if container.Config.Tty {
+				_, err = io.Copy(w, r)
+			} else {
+				_, err = stdcopy.StdCopy(w, w, r)
+			}
+			return err
+		})
+	}
+	return eg.Wait()
+}
+
+type splitBuffer struct {
+	service   string
+	container string
+	consumer  compose.LogConsumer
+}
+
+// getWriter creates a io.Writer that will actually split by line and format by LogConsumer
+func getWriter(service, container string, l compose.LogConsumer) io.Writer {
+	return splitBuffer{
+		service:   service,
+		container: container,
+		consumer:  l,
+	}
+}
+
+func (s splitBuffer) Write(b []byte) (n int, err error) {
+	split := bytes.Split(b, []byte{'\n'})
+	for _, line := range split {
+		if len(line) != 0 {
+			s.consumer.Log(s.service, s.container, string(line))
+		}
+	}
+	return len(b), nil
+}
diff --git a/local/compose/ls.go b/local/compose/ls.go
new file mode 100644
index 00000000..322ad5b6
--- /dev/null
+++ b/local/compose/ls.go
@@ -0,0 +1,86 @@
+/*
+   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"
+	"fmt"
+	"sort"
+
+	"github.com/docker/compose-cli/api/compose"
+
+	moby "github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/filters"
+)
+
+func (s *composeService) List(ctx context.Context, projectName string) ([]compose.Stack, error) {
+	list, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
+		Filters: filters.NewArgs(hasProjectLabelFilter()),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return containersToStacks(list)
+}
+
+func containersToStacks(containers []moby.Container) ([]compose.Stack, error) {
+	containersByLabel, keys, err := groupContainerByLabel(containers, projectLabel)
+	if err != nil {
+		return nil, err
+	}
+	var projects []compose.Stack
+	for _, project := range keys {
+		projects = append(projects, compose.Stack{
+			ID:     project,
+			Name:   project,
+			Status: combinedStatus(containerToState(containersByLabel[project])),
+		})
+	}
+	return projects, nil
+}
+
+func containerToState(containers []moby.Container) []string {
+	statuses := []string{}
+	for _, c := range containers {
+		statuses = append(statuses, c.State)
+	}
+	return statuses
+}
+
+func combinedStatus(statuses []string) string {
+	nbByStatus := map[string]int{}
+	keys := []string{}
+	for _, status := range statuses {
+		nb, ok := nbByStatus[status]
+		if !ok {
+			nb = 0
+			keys = append(keys, status)
+		}
+		nbByStatus[status] = nb + 1
+	}
+	sort.Strings(keys)
+	result := ""
+	for _, status := range keys {
+		nb := nbByStatus[status]
+		if result != "" {
+			result = result + ", "
+		}
+		result = result + fmt.Sprintf("%s(%d)", status, nb)
+	}
+	return result
+}
diff --git a/local/compose/ps.go b/local/compose/ps.go
new file mode 100644
index 00000000..39b1ecfa
--- /dev/null
+++ b/local/compose/ps.go
@@ -0,0 +1,85 @@
+/*
+   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"
+	"fmt"
+	"sort"
+
+	convert "github.com/docker/compose-cli/local/moby"
+
+	"github.com/docker/compose-cli/api/compose"
+	moby "github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/filters"
+)
+
+func (s *composeService) Ps(ctx context.Context, projectName string) ([]compose.ServiceStatus, error) {
+	list, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
+		Filters: filters.NewArgs(
+			projectFilter(projectName),
+		),
+	})
+	if err != nil {
+		return nil, err
+	}
+	return containersToServiceStatus(list)
+}
+
+func containersToServiceStatus(containers []moby.Container) ([]compose.ServiceStatus, error) {
+	containersByLabel, keys, err := groupContainerByLabel(containers, serviceLabel)
+	if err != nil {
+		return nil, err
+	}
+	var services []compose.ServiceStatus
+	for _, service := range keys {
+		containers := containersByLabel[service]
+		runnningContainers := []moby.Container{}
+		for _, container := range containers {
+			if container.State == convert.ContainerRunning {
+				runnningContainers = append(runnningContainers, container)
+			}
+		}
+		services = append(services, compose.ServiceStatus{
+			ID:       service,
+			Name:     service,
+			Desired:  len(containers),
+			Replicas: len(runnningContainers),
+		})
+	}
+	return services, nil
+}
+
+func groupContainerByLabel(containers []moby.Container, labelName string) (map[string][]moby.Container, []string, error) {
+	containersByLabel := map[string][]moby.Container{}
+	keys := []string{}
+	for _, c := range containers {
+		label, ok := c.Labels[labelName]
+		if !ok {
+			return nil, nil, fmt.Errorf("No label %q set on container %q of compose project", labelName, c.ID)
+		}
+		labelContainers, ok := containersByLabel[label]
+		if !ok {
+			labelContainers = []moby.Container{}
+			keys = append(keys, label)
+		}
+		labelContainers = append(labelContainers, c)
+		containersByLabel[label] = labelContainers
+	}
+	sort.Strings(keys)
+	return containersByLabel, keys, nil
+}
diff --git a/local/compose/pull.go b/local/compose/pull.go
new file mode 100644
index 00000000..2ee181b1
--- /dev/null
+++ b/local/compose/pull.go
@@ -0,0 +1,158 @@
+/*
+   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"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"io"
+	"strings"
+
+	"github.com/compose-spec/compose-go/types"
+	cliconfig "github.com/docker/cli/cli/config"
+	"github.com/docker/distribution/reference"
+	moby "github.com/docker/docker/api/types"
+	"github.com/docker/docker/pkg/jsonmessage"
+	"github.com/docker/docker/registry"
+	"golang.org/x/sync/errgroup"
+
+	"github.com/docker/compose-cli/config"
+	"github.com/docker/compose-cli/progress"
+)
+
+func (s *composeService) Pull(ctx context.Context, project *types.Project) error {
+	configFile, err := cliconfig.Load(config.Dir(ctx))
+	if err != nil {
+		return err
+	}
+	info, err := s.apiClient.Info(ctx)
+	if err != nil {
+		return err
+	}
+
+	if info.IndexServerAddress == "" {
+		info.IndexServerAddress = registry.IndexServer
+	}
+
+	w := progress.ContextWriter(ctx)
+	eg, ctx := errgroup.WithContext(ctx)
+
+	for _, srv := range project.Services {
+		service := srv
+		eg.Go(func() error {
+			w.Event(progress.Event{
+				ID:     service.Name,
+				Status: progress.Working,
+				Text:   "Pulling",
+			})
+			ref, err := reference.ParseNormalizedNamed(service.Image)
+			if err != nil {
+				return err
+			}
+
+			repoInfo, err := registry.ParseRepositoryInfo(ref)
+			if err != nil {
+				return err
+			}
+
+			key := repoInfo.Index.Name
+			if repoInfo.Index.Official {
+				key = info.IndexServerAddress
+			}
+
+			authConfig, err := configFile.GetAuthConfig(key)
+			if err != nil {
+				return err
+			}
+
+			buf, err := json.Marshal(authConfig)
+			if err != nil {
+				return err
+			}
+
+			stream, err := s.apiClient.ImagePull(ctx, service.Image, moby.ImagePullOptions{
+				RegistryAuth: base64.URLEncoding.EncodeToString(buf),
+			})
+			if err != nil {
+				w.Event(progress.Event{
+					ID:     service.Name,
+					Status: progress.Error,
+					Text:   "Error",
+				})
+				return err
+			}
+
+			dec := json.NewDecoder(stream)
+			for {
+				var jm jsonmessage.JSONMessage
+				if err := dec.Decode(&jm); err != nil {
+					if err == io.EOF {
+						break
+					}
+					return err
+				}
+				if jm.Error != nil {
+					return errors.New(jm.Error.Message)
+				}
+				toPullProgressEvent(service.Name, jm, w)
+			}
+			w.Event(progress.Event{
+				ID:     service.Name,
+				Status: progress.Done,
+				Text:   "Pulled",
+			})
+			return nil
+		})
+	}
+
+	return eg.Wait()
+}
+
+func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, w progress.Writer) {
+	if jm.ID == "" || jm.Progress == nil {
+		return
+	}
+
+	var (
+		text   string
+		status = progress.Working
+	)
+
+	text = jm.Progress.String()
+
+	if jm.Status == "Pull complete" ||
+		jm.Status == "Already exists" ||
+		strings.Contains(jm.Status, "Image is up to date") ||
+		strings.Contains(jm.Status, "Downloaded newer image") {
+		status = progress.Done
+	}
+
+	if jm.Error != nil {
+		status = progress.Error
+		text = jm.Error.Message
+	}
+
+	w.Event(progress.Event{
+		ID:         jm.ID,
+		ParentID:   parent,
+		Text:       jm.Status,
+		Status:     status,
+		StatusText: text,
+	})
+}
diff --git a/local/compose/push.go b/local/compose/push.go
new file mode 100644
index 00000000..9a4cd57e
--- /dev/null
+++ b/local/compose/push.go
@@ -0,0 +1,137 @@
+/*
+   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"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+
+	"github.com/docker/compose-cli/config"
+	"github.com/docker/compose-cli/progress"
+
+	"github.com/compose-spec/compose-go/types"
+	cliconfig "github.com/docker/cli/cli/config"
+	"github.com/docker/distribution/reference"
+	moby "github.com/docker/docker/api/types"
+	"github.com/docker/docker/pkg/jsonmessage"
+	"github.com/docker/docker/registry"
+	"github.com/pkg/errors"
+	"golang.org/x/sync/errgroup"
+)
+
+func (s *composeService) Push(ctx context.Context, project *types.Project) error {
+	configFile, err := cliconfig.Load(config.Dir(ctx))
+	if err != nil {
+		return err
+	}
+	eg, ctx := errgroup.WithContext(ctx)
+
+	info, err := s.apiClient.Info(ctx)
+	if err != nil {
+		return err
+	}
+	if info.IndexServerAddress == "" {
+		info.IndexServerAddress = registry.IndexServer
+	}
+
+	for _, service := range project.Services {
+		if service.Build == nil {
+			continue
+		}
+		service := service
+		eg.Go(func() error {
+			w := progress.ContextWriter(ctx)
+
+			ref, err := reference.ParseNormalizedNamed(service.Image)
+			if err != nil {
+				return err
+			}
+
+			repoInfo, err := registry.ParseRepositoryInfo(ref)
+			if err != nil {
+				return err
+			}
+
+			key := repoInfo.Index.Name
+			if repoInfo.Index.Official {
+				key = info.IndexServerAddress
+			}
+			authConfig, err := configFile.GetAuthConfig(key)
+			if err != nil {
+				return err
+			}
+
+			buf, err := json.Marshal(authConfig)
+			if err != nil {
+				return err
+			}
+
+			stream, err := s.apiClient.ImagePush(ctx, service.Image, moby.ImagePushOptions{
+				RegistryAuth: base64.URLEncoding.EncodeToString(buf),
+			})
+			if err != nil {
+				return err
+			}
+			dec := json.NewDecoder(stream)
+			for {
+				var jm jsonmessage.JSONMessage
+				if err := dec.Decode(&jm); err != nil {
+					if err == io.EOF {
+						break
+					}
+					return err
+				}
+				if jm.Error != nil {
+					return errors.New(jm.Error.Message)
+				}
+				toPushProgressEvent("Pushing "+service.Name, jm, w)
+			}
+			return nil
+		})
+	}
+	return eg.Wait()
+}
+
+func toPushProgressEvent(prefix string, jm jsonmessage.JSONMessage, w progress.Writer) {
+	if jm.ID == "" {
+		// skipped
+		return
+	}
+	var (
+		text   string
+		status = progress.Working
+	)
+	if jm.Status == "Pull complete" || jm.Status == "Already exists" {
+		status = progress.Done
+	}
+	if jm.Error != nil {
+		status = progress.Error
+		text = jm.Error.Message
+	}
+	if jm.Progress != nil {
+		text = jm.Progress.String()
+	}
+	w.Event(progress.Event{
+		ID:         fmt.Sprintf("Pushing %s: %s", prefix, jm.ID),
+		Text:       jm.Status,
+		Status:     status,
+		StatusText: text,
+	})
+}
diff --git a/local/compose/start.go b/local/compose/start.go
new file mode 100644
index 00000000..ab2e7ed5
--- /dev/null
+++ b/local/compose/start.go
@@ -0,0 +1,48 @@
+/*
+   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/docker/compose-cli/api/compose"
+
+	"github.com/compose-spec/compose-go/types"
+	"golang.org/x/sync/errgroup"
+)
+
+func (s *composeService) Start(ctx context.Context, project *types.Project, consumer compose.LogConsumer) error {
+	var group *errgroup.Group
+	if consumer != nil {
+		eg, err := s.attach(ctx, project, consumer)
+		if err != nil {
+			return err
+		}
+		group = eg
+	}
+
+	err := InDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error {
+		return s.startService(ctx, project, service)
+	})
+	if err != nil {
+		return err
+	}
+	if group != nil {
+		return group.Wait()
+	}
+	return nil
+}
diff --git a/local/util.go b/local/compose/util.go
similarity index 98%
rename from local/util.go
rename to local/compose/util.go
index 6aef2070..dd9cbbcf 100644
--- a/local/util.go
+++ b/local/compose/util.go
@@ -14,7 +14,7 @@
    limitations under the License.
 */
 
-package local
+package compose
 
 import (
 	"encoding/json"
diff --git a/local/container.go b/local/container.go
deleted file mode 100644
index a82fd8c0..00000000
--- a/local/container.go
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
-   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 local
-
-import (
-	"io"
-
-	moby "github.com/docker/docker/api/types"
-)
-
-const (
-	containerCreated    = "created"
-	containerRestarting = "restarting"
-	containerRunning    = "running"
-	containerRemoving   = "removing" //nolint
-	containerPaused     = "paused"   //nolint
-	containerExited     = "exited"   //nolint
-	containerDead       = "dead"     //nolint
-)
-
-var _ io.ReadCloser = containerStdout{}
-
-type containerStdout struct {
-	moby.HijackedResponse
-}
-
-func (l containerStdout) Read(p []byte) (n int, err error) {
-	return l.Reader.Read(p)
-}
-
-func (l containerStdout) Close() error {
-	l.HijackedResponse.Close()
-	return nil
-}
-
-var _ io.WriteCloser = containerStdin{}
-
-type containerStdin struct {
-	moby.HijackedResponse
-}
-
-func (c containerStdin) Write(p []byte) (n int, err error) {
-	return c.Conn.Write(p)
-}
-
-func (c containerStdin) Close() error {
-	return c.CloseWrite()
-}
diff --git a/local/containers.go b/local/containers.go
index b05e5699..3c6d7e6b 100644
--- a/local/containers.go
+++ b/local/containers.go
@@ -35,6 +35,7 @@ import (
 
 	"github.com/docker/compose-cli/api/containers"
 	"github.com/docker/compose-cli/errdefs"
+	"github.com/docker/compose-cli/local/moby"
 )
 
 type containerService struct {
@@ -58,8 +59,8 @@ func (cs *containerService) Inspect(ctx context.Context, id string) (containers.
 		command = strings.Join(c.Config.Cmd, " ")
 	}
 
-	rc := toRuntimeConfig(&c)
-	hc := toHostConfig(&c)
+	rc := moby.ToRuntimeConfig(&c)
+	hc := moby.ToHostConfig(&c)
 
 	return containers.Container{
 		ID:         stringid.TruncateID(c.ID),
@@ -92,7 +93,7 @@ func (cs *containerService) List(ctx context.Context, all bool) ([]containers.Co
 			// statuses. We also need to add a `Created` property on the gRPC side.
 			Status:  container.Status,
 			Command: container.Command,
-			Ports:   toPorts(container.Ports),
+			Ports:   moby.ToPorts(container.Ports),
 		})
 	}
 
@@ -100,7 +101,7 @@ func (cs *containerService) List(ctx context.Context, all bool) ([]containers.Co
 }
 
 func (cs *containerService) Run(ctx context.Context, r containers.ContainerConfig) error {
-	exposedPorts, hostBindings, err := fromPorts(r.Ports)
+	exposedPorts, hostBindings, err := moby.FromPorts(r.Ports)
 	if err != nil {
 		return err
 	}
@@ -127,7 +128,7 @@ func (cs *containerService) Run(ctx context.Context, r containers.ContainerConfi
 		PortBindings:  hostBindings,
 		Mounts:        mounts,
 		AutoRemove:    r.AutoRemove,
-		RestartPolicy: toRestartPolicy(r.RestartPolicyCondition),
+		RestartPolicy: moby.ToRestartPolicy(r.RestartPolicyCondition),
 		Resources: container.Resources{
 			NanoCPUs: int64(r.CPULimit * 1e9),
 			Memory:   int64(r.MemLimit),
diff --git a/local/moby/container.go b/local/moby/container.go
new file mode 100644
index 00000000..faaf0baa
--- /dev/null
+++ b/local/moby/container.go
@@ -0,0 +1,75 @@
+/*
+   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 moby
+
+import (
+	"io"
+
+	moby "github.com/docker/docker/api/types"
+)
+
+const (
+	// ContainerCreated created status
+	ContainerCreated = "created"
+	// ContainerRestarting restarting status
+	ContainerRestarting = "restarting"
+	// ContainerRunning running status
+	ContainerRunning = "running"
+	// ContainerRemoving removing status
+	ContainerRemoving = "removing" //nolint
+	// ContainerPaused paused status
+	ContainerPaused = "paused" //nolint
+	// ContainerExited exited status
+	ContainerExited = "exited" //nolint
+	// ContainerDead dead status
+	ContainerDead = "dead" //nolint
+)
+
+var _ io.ReadCloser = ContainerStdout{}
+
+// ContainerStdout implement ReadCloser for moby.HijackedResponse
+type ContainerStdout struct {
+	moby.HijackedResponse
+}
+
+// Read implement io.ReadCloser
+func (l ContainerStdout) Read(p []byte) (n int, err error) {
+	return l.Reader.Read(p)
+}
+
+// Close implement io.ReadCloser
+func (l ContainerStdout) Close() error {
+	l.HijackedResponse.Close()
+	return nil
+}
+
+var _ io.WriteCloser = ContainerStdin{}
+
+// ContainerStdin implement WriteCloser for moby.HijackedResponse
+type ContainerStdin struct {
+	moby.HijackedResponse
+}
+
+// Write implement io.WriteCloser
+func (c ContainerStdin) Write(p []byte) (n int, err error) {
+	return c.Conn.Write(p)
+}
+
+// Close implement io.WriteCloser
+func (c ContainerStdin) Close() error {
+	return c.CloseWrite()
+}
diff --git a/local/convert.go b/local/moby/convert.go
similarity index 82%
rename from local/convert.go
rename to local/moby/convert.go
index 2b567f9d..39443ebe 100644
--- a/local/convert.go
+++ b/local/moby/convert.go
@@ -14,7 +14,7 @@
    limitations under the License.
 */
 
-package local
+package moby
 
 import (
 	"fmt"
@@ -31,7 +31,8 @@ import (
 	"github.com/docker/compose-cli/api/containers"
 )
 
-func toRuntimeConfig(m *types.ContainerJSON) *containers.RuntimeConfig {
+// ToRuntimeConfig convert into containers.RuntimeConfig
+func ToRuntimeConfig(m *types.ContainerJSON) *containers.RuntimeConfig {
 	if m.Config == nil {
 		return nil
 	}
@@ -66,7 +67,8 @@ func toRuntimeConfig(m *types.ContainerJSON) *containers.RuntimeConfig {
 	}
 }
 
-func toHostConfig(m *types.ContainerJSON) *containers.HostConfig {
+// ToHostConfig convert into containers.HostConfig
+func ToHostConfig(m *types.ContainerJSON) *containers.HostConfig {
 	if m.HostConfig == nil {
 		return nil
 	}
@@ -79,7 +81,8 @@ func toHostConfig(m *types.ContainerJSON) *containers.HostConfig {
 	}
 }
 
-func toPorts(ports []types.Port) []containers.Port {
+// ToPorts convert into containers.Port
+func ToPorts(ports []types.Port) []containers.Port {
 	result := []containers.Port{}
 	for _, port := range ports {
 		result = append(result, containers.Port{
@@ -93,7 +96,8 @@ func toPorts(ports []types.Port) []containers.Port {
 	return result
 }
 
-func toMobyEnv(environment compose.MappingWithEquals) []string {
+// ToMobyEnv convert into []string
+func ToMobyEnv(environment compose.MappingWithEquals) []string {
 	var env []string
 	for k, v := range environment {
 		if v == nil {
@@ -105,7 +109,8 @@ func toMobyEnv(environment compose.MappingWithEquals) []string {
 	return env
 }
 
-func toMobyHealthCheck(check *compose.HealthCheckConfig) *container.HealthConfig {
+// ToMobyHealthCheck convert into container.HealthConfig
+func ToMobyHealthCheck(check *compose.HealthCheckConfig) *container.HealthConfig {
 	if check == nil {
 		return nil
 	}
@@ -136,7 +141,8 @@ func toMobyHealthCheck(check *compose.HealthCheckConfig) *container.HealthConfig
 	}
 }
 
-func toSeconds(d *compose.Duration) *int {
+// ToSeconds convert into seconds
+func ToSeconds(d *compose.Duration) *int {
 	if d == nil {
 		return nil
 	}
@@ -144,7 +150,8 @@ func toSeconds(d *compose.Duration) *int {
 	return &s
 }
 
-func fromPorts(ports []containers.Port) (map[nat.Port]struct{}, map[nat.Port][]nat.PortBinding, error) {
+// FromPorts convert to nat.Port / nat.PortBinding
+func FromPorts(ports []containers.Port) (map[nat.Port]struct{}, map[nat.Port][]nat.PortBinding, error) {
 	var (
 		exposedPorts = make(map[nat.Port]struct{}, len(ports))
 		bindings     = make(map[nat.Port][]nat.PortBinding)
@@ -187,7 +194,8 @@ func fromRestartPolicyName(m string) string {
 	}
 }
 
-func toRestartPolicy(p string) container.RestartPolicy {
+// ToRestartPolicy convert to container.RestartPolicy
+func ToRestartPolicy(p string) container.RestartPolicy {
 	switch p {
 	case containers.RestartPolicyAny:
 		return container.RestartPolicy{Name: "always"}


Test patch:
diff --git a/local/compose_test.go b/local/compose/compose_test.go
similarity index 98%
rename from local/compose_test.go
rename to local/compose/compose_test.go
index ea1ec0e5..d57c25b7 100644
--- a/local/compose_test.go
+++ b/local/compose/compose_test.go
@@ -14,7 +14,7 @@
    limitations under the License.
 */
 
-package local
+package compose
 
 import (
 	"os"
@@ -113,7 +113,7 @@ func TestStacksMixedStatus(t *testing.T) {
 func TestBuildBindMount(t *testing.T) {
 	volume := composetypes.ServiceVolumeConfig{
 		Type:   composetypes.VolumeTypeBind,
-		Source: "compose/e2e/volume-test",
+		Source: "e2e/volume-test",
 		Target: "/data",
 	}
 	mount, err := buildMount(volume)
diff --git a/local/dependencies_test.go b/local/compose/dependencies_test.go
similarity index 99%
rename from local/dependencies_test.go
rename to local/compose/dependencies_test.go
index 736bb50c..841274b7 100644
--- a/local/dependencies_test.go
+++ b/local/compose/dependencies_test.go
@@ -14,15 +14,14 @@
    limitations under the License.
 */
 
-package local
+package compose
 
 import (
 	"context"
 	"testing"
 
-	"gotest.tools/v3/assert"
-
 	"github.com/compose-spec/compose-go/types"
+	"gotest.tools/v3/assert"
 )
 
 var project = types.Project{
diff --git a/local/convert_test.go b/local/moby/convert_test.go
similarity index 95%
rename from local/convert_test.go
rename to local/moby/convert_test.go
index fa93138d..a9e5a413 100644
--- a/local/convert_test.go
+++ b/local/moby/convert_test.go
@@ -14,7 +14,7 @@
    limitations under the License.
 */
 
-package local
+package moby
 
 import (
 	"testing"
@@ -34,7 +34,7 @@ func TestToRuntimeConfig(t *testing.T) {
 			Labels: map[string]string{"foo1": "bar1", "foo2": "bar2"},
 		},
 	}
-	rc := toRuntimeConfig(m)
+	rc := ToRuntimeConfig(m)
 	res := &containers.RuntimeConfig{
 		Env:    map[string]string{"FOO1": "BAR1", "FOO2": "BAR2"},
 		Labels: []string{"foo1=bar1", "foo2=bar2"},
@@ -63,7 +63,7 @@ func TestToHostConfig(t *testing.T) {
 		},
 		ContainerJSONBase: base,
 	}
-	hc := toHostConfig(m)
+	hc := ToHostConfig(m)
 	res := &containers.HostConfig{
 		AutoRemove:    true,
 		RestartPolicy: containers.RestartPolicyNone,
@@ -96,6 +96,6 @@ func TestToRestartPolicy(t *testing.T) {
 		{Name: "no"},
 	}
 	for i, p := range ours {
-		assert.Equal(t, toRestartPolicy(p), moby[i])
+		assert.Equal(t, ToRestartPolicy(p), moby[i])
 	}
 }