Repository: docker/compose (go)

Issue:
docker compose exec environment variable precedence in v2.0.0-rc.1 
docker compose exec environment variable precedence is different between version 1 and version 2

### Expected behavior
I expected the exec between docker and docker-compose to be the same but it is not.

### Actual behavior
docker-compose-v1 exec uses the environment variable in the container.
docker-compose exec uses the environment variable from the compose file.


### Information
  - macOS Version: Catalina 10.15.7 (19H1323)
  - Intel chip or Apple chip: 2.6 GHz 6-Core Intel Core i7
  - Docker Desktop Version: 3.6.0 (67351)
  - docker-compose --version: v2.0.0-rc.1
  - docker-compose-v1 --version: 1.29.2, build 5becea4c 

### Steps to reproduce the behavior


.env
```
FOO=i_am_in_dot_env
```

docker-compose.yml
```
version: "3.3"
services:
  playground:
    command:
      - tail
      - -f
      - /dev/null
    image: ubuntu
    environment:
      - FOO
```


```
~/docker_env_cr2_bug $ FOO=commandLine docker-compose up -d
[+] Running 2/2
 ⠿ Network docker_env_cr2_bug_default         Created                                                                                                                  0.0s
 ⠿ Container docker_env_cr2_bug_playground_1  Started                                                                                                                  0.4s

~/docker_env_cr2_bug $ docker ps                                       
CONTAINER ID   IMAGE          COMMAND               CREATED          STATUS          PORTS     NAMES
5f9b5e5ff7af   1318b700e415   "tail -f /dev/null"   30 seconds ago   Up 29 seconds             docker_env_cr2_bug_playground_1

~/docker_env_cr2_bug $ docker exec -it 5f9b5e5ff7af bash
root@5f9b5e5ff7af:/# echo $FOO
commandLine
root@5f9b5e5ff7af:/# exit
exit

~/docker_env_cr2_bug $ docker-compose exec playground bash
root@5f9b5e5ff7af:/# echo $FOO
i_am_in_dot_env
root@5f9b5e5ff7af:/# exit
exit

~/docker_env_cr2_bug $ docker-compose-v1 exec playground bash
root@5f9b5e5ff7af:/# echo $FOO
commandLine
root@5f9b5e5ff7af:/# exit
exit
```


Pull request description:
compose exec doesn't need project
Align `compose exec` behavior with Compose v1 to not compose environment based on compose file, but only pass values explicitly set by `--environment`.
Bonus point: this make `compose exec` able to run without the original compose file

Note: a follow-up PR is required to apply signature change in compose.API to compose-cli

Resolves #8508


Patch:
diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go
index 7f1834e51..4acabd856 100644
--- a/cmd/compose/compose.go
+++ b/cmd/compose/compose.go
@@ -91,12 +91,13 @@ func Adapt(fn Command) func(cmd *cobra.Command, args []string) error {
 var Warning string
 
 type projectOptions struct {
-	ProjectName string
-	Profiles    []string
-	ConfigPaths []string
-	WorkDir     string
-	ProjectDir  string
-	EnvFile     string
+	ProjectName   string
+	Profiles      []string
+	ConfigPaths   []string
+	WorkDir       string
+	ProjectDir    string
+	EnvFile       string
+	Compatibility bool
 }
 
 // ProjectFunc does stuff within a types.Project
@@ -149,9 +150,8 @@ func (o *projectOptions) addProjectFlags(f *pflag.FlagSet) {
 	f.StringVar(&o.EnvFile, "env-file", "", "Specify an alternate environment file.")
 	f.StringVar(&o.ProjectDir, "project-directory", "", "Specify an alternate working directory\n(default: the path of the Compose file)")
 	f.StringVar(&o.WorkDir, "workdir", "", "DEPRECATED! USE --project-directory INSTEAD.\nSpecify an alternate working directory\n(default: the path of the Compose file)")
-	f.Bool("compatibility", false, "DEPRECATED")
+	f.BoolVar(&o.Compatibility, "compatibility", false, "Run compose in backward compatibility mode")
 	_ = f.MarkHidden("workdir")
-	_ = f.MarkHidden("compatibility")
 }
 
 func (o *projectOptions) toProjectName() (string, error) {
@@ -259,6 +259,9 @@ func RootCommand(backend api.Service) *cobra.Command {
 				opts.ProjectDir = opts.WorkDir
 				fmt.Fprint(os.Stderr, aec.Apply("option '--workdir' is DEPRECATED at root level! Please use '--project-directory' instead.\n", aec.RedF))
 			}
+			if opts.Compatibility || os.Getenv("COMPOSE_COMPATIBILITY") == "true" {
+				compose.Separator = "_"
+			}
 			return nil
 		},
 	}
diff --git a/cmd/compose/exec.go b/cmd/compose/exec.go
index 9d2370475..f5be30985 100644
--- a/cmd/compose/exec.go
+++ b/cmd/compose/exec.go
@@ -21,7 +21,6 @@ import (
 	"fmt"
 	"os"
 
-	cgo "github.com/compose-spec/compose-go/cli"
 	"github.com/containerd/console"
 	"github.com/docker/cli/cli"
 	"github.com/spf13/cobra"
@@ -78,7 +77,7 @@ func execCommand(p *projectOptions, backend api.Service) *cobra.Command {
 }
 
 func runExec(ctx context.Context, backend api.Service, opts execOpts) error {
-	project, err := opts.toProject(nil, cgo.WithResolvedPaths(true))
+	project, err := opts.toProjectName()
 	if err != nil {
 		return err
 	}
diff --git a/cmd/compose/ps.go b/cmd/compose/ps.go
index 387c0a100..8e4f76a09 100644
--- a/cmd/compose/ps.go
+++ b/cmd/compose/ps.go
@@ -147,14 +147,7 @@ SERVICES:
 func writter(containers []api.ContainerSummary) func(w io.Writer) {
 	return func(w io.Writer) {
 		for _, container := range containers {
-			var ports []string
-			for _, p := range container.Publishers {
-				if p.URL == "" {
-					ports = append(ports, fmt.Sprintf("%d/%s", p.TargetPort, p.Protocol))
-				} else {
-					ports = append(ports, fmt.Sprintf("%s->%d/%s", p.URL, p.TargetPort, p.Protocol))
-				}
-			}
+			ports := DisplayablePorts(container)
 			status := container.State
 			if status == "running" && container.Health != "" {
 				status = fmt.Sprintf("%s (%s)", container.State, container.Health)
@@ -162,7 +155,7 @@ func writter(containers []api.ContainerSummary) func(w io.Writer) {
 				status = fmt.Sprintf("%s (%d)", container.State, container.ExitCode)
 			}
 			command := formatter2.Ellipsis(container.Command, 20)
-			_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", container.Name, strconv.Quote(command), container.Service, status, strings.Join(ports, ", "))
+			_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", container.Name, strconv.Quote(command), container.Service, status, ports)
 		}
 	}
 }
@@ -182,3 +175,73 @@ func filterByStatus(containers []api.ContainerSummary, status string) []api.Cont
 	}
 	return filtered
 }
+
+type portRange struct {
+	pStart   int
+	pEnd     int
+	tStart   int
+	tEnd     int
+	IP       string
+	protocol string
+}
+
+func (pr portRange) String() string {
+	var (
+		pub string
+		tgt string
+	)
+
+	if pr.pEnd > pr.pStart {
+		pub = fmt.Sprintf("%s:%d-%d->", pr.IP, pr.pStart, pr.pEnd)
+	} else if pr.pStart > 0 {
+		pub = fmt.Sprintf("%s:%d->", pr.IP, pr.pStart)
+	}
+	if pr.tEnd > pr.tStart {
+		tgt = fmt.Sprintf("%d-%d", pr.tStart, pr.tEnd)
+	} else {
+		tgt = fmt.Sprintf("%d", pr.tStart)
+	}
+	return fmt.Sprintf("%s%s/%s", pub, tgt, pr.protocol)
+}
+
+// DisplayablePorts is copy pasted from https://github.com/docker/cli/pull/581/files
+func DisplayablePorts(c api.ContainerSummary) string {
+	if c.Publishers == nil {
+		return ""
+	}
+
+	sort.Sort(c.Publishers)
+
+	pr := portRange{}
+	ports := []string{}
+	for _, p := range c.Publishers {
+		prIsRange := pr.tEnd != pr.tStart
+		tOverlaps := p.TargetPort <= pr.tEnd
+
+		// Start a new port-range if:
+		// - the protocol is different from the current port-range
+		// - published or target port are not consecutive to the current port-range
+		// - the current port-range is a _range_, and the target port overlaps with the current range's target-ports
+		if p.Protocol != pr.protocol || p.URL != pr.IP || p.PublishedPort-pr.pEnd > 1 || p.TargetPort-pr.tEnd > 1 || prIsRange && tOverlaps {
+			// start a new port-range, and print the previous port-range (if any)
+			if pr.pStart > 0 {
+				ports = append(ports, pr.String())
+			}
+			pr = portRange{
+				pStart:   p.PublishedPort,
+				pEnd:     p.PublishedPort,
+				tStart:   p.TargetPort,
+				tEnd:     p.TargetPort,
+				protocol: p.Protocol,
+				IP:       p.URL,
+			}
+			continue
+		}
+		pr.pEnd = p.PublishedPort
+		pr.tEnd = p.TargetPort
+	}
+	if pr.tStart > 0 {
+		ports = append(ports, pr.String())
+	}
+	return strings.Join(ports, ", ")
+}
diff --git a/cmd/compose/run.go b/cmd/compose/run.go
index 7bc3d4ba4..2ad6ad30d 100644
--- a/cmd/compose/run.go
+++ b/cmd/compose/run.go
@@ -141,7 +141,7 @@ func runCommand(p *projectOptions, backend api.Service) *cobra.Command {
 	flags := cmd.Flags()
 	flags.BoolVarP(&opts.Detach, "detach", "d", false, "Run container in background and print container ID")
 	flags.StringArrayVarP(&opts.environment, "env", "e", []string{}, "Set environment variables")
-	flags.StringArrayVarP(&opts.labels, "labels", "l", []string{}, "Add or override a label")
+	flags.StringArrayVarP(&opts.labels, "label", "l", []string{}, "Add or override a label")
 	flags.BoolVar(&opts.Remove, "rm", false, "Automatically remove the container when it exits")
 	flags.BoolVarP(&opts.noTty, "no-TTY", "T", notAtTTY(), "Disable pseudo-noTty allocation. By default docker compose run allocates a TTY")
 	flags.StringVar(&opts.name, "name", "", " Assign a name to the container")
@@ -163,6 +163,8 @@ func normalizeRunFlags(f *pflag.FlagSet, name string) pflag.NormalizedName {
 	switch name {
 	case "volumes":
 		name = "volume"
+	case "labels":
+		name = "label"
 	}
 	return pflag.NormalizedName(name)
 }
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 00f3a3116..992997b8e 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -61,7 +61,7 @@ type Service interface {
 	// Remove executes the equivalent to a `compose rm`
 	Remove(ctx context.Context, project *types.Project, options RemoveOptions) error
 	// Exec executes a command in a running service container
-	Exec(ctx context.Context, project *types.Project, opts RunOptions) (int, error)
+	Exec(ctx context.Context, project string, opts RunOptions) (int, error)
 	// Copy copies a file/folder between a service container and the local filesystem
 	Copy(ctx context.Context, project *types.Project, opts CopyOptions) error
 	// Pause executes the equivalent to a `compose pause`
@@ -297,7 +297,36 @@ type ContainerSummary struct {
 	State      string
 	Health     string
 	ExitCode   int
-	Publishers []PortPublisher
+	Publishers PortPublishers
+}
+
+// PortPublishers is a slice of PortPublisher
+type PortPublishers []PortPublisher
+
+// Len implements sort.Interface
+func (p PortPublishers) Len() int {
+	return len(p)
+}
+
+// Less implements sort.Interface
+func (p PortPublishers) Less(i, j int) bool {
+	left := p[i]
+	right := p[j]
+	if left.URL != right.URL {
+		return left.URL < right.URL
+	}
+	if left.TargetPort != right.TargetPort {
+		return left.TargetPort < right.TargetPort
+	}
+	if left.PublishedPort != right.PublishedPort {
+		return left.PublishedPort < right.PublishedPort
+	}
+	return left.Protocol < right.Protocol
+}
+
+// Swap implements sort.Interface
+func (p PortPublishers) Swap(i, j int) {
+	p[i], p[j] = p[j], p[i]
 }
 
 // ContainerProcSummary holds container processes top data
diff --git a/pkg/api/proxy.go b/pkg/api/proxy.go
index 03163a73e..aff81e1f4 100644
--- a/pkg/api/proxy.go
+++ b/pkg/api/proxy.go
@@ -40,7 +40,7 @@ type ServiceProxy struct {
 	KillFn               func(ctx context.Context, project *types.Project, options KillOptions) error
 	RunOneOffContainerFn func(ctx context.Context, project *types.Project, opts RunOptions) (int, error)
 	RemoveFn             func(ctx context.Context, project *types.Project, options RemoveOptions) error
-	ExecFn               func(ctx context.Context, project *types.Project, opts RunOptions) (int, error)
+	ExecFn               func(ctx context.Context, project string, opts RunOptions) (int, error)
 	CopyFn               func(ctx context.Context, project *types.Project, opts CopyOptions) error
 	PauseFn              func(ctx context.Context, project string, options PauseOptions) error
 	UnPauseFn            func(ctx context.Context, project string, options PauseOptions) error
@@ -261,13 +261,10 @@ func (s *ServiceProxy) Remove(ctx context.Context, project *types.Project, optio
 }
 
 // Exec implements Service interface
-func (s *ServiceProxy) Exec(ctx context.Context, project *types.Project, options RunOptions) (int, error) {
+func (s *ServiceProxy) Exec(ctx context.Context, project string, options RunOptions) (int, error) {
 	if s.ExecFn == nil {
 		return 0, ErrNotImplemented
 	}
-	for _, i := range s.interceptors {
-		i(ctx, project)
-	}
 	return s.ExecFn(ctx, project, options)
 }
 
diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go
index 0cab4f363..16bb53ae3 100644
--- a/pkg/compose/compose.go
+++ b/pkg/compose/compose.go
@@ -32,6 +32,8 @@ import (
 	"github.com/sanathkr/go-yaml"
 )
 
+var Separator = "-"
+
 // NewComposeService create a local implementation of the compose.Service API
 func NewComposeService(apiClient client.APIClient, configFile *configfile.ConfigFile) api.Service {
 	return &composeService{
diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go
index 632726229..50bdf86a6 100644
--- a/pkg/compose/convergence.go
+++ b/pkg/compose/convergence.go
@@ -245,7 +245,7 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project,
 }
 
 func getContainerName(projectName string, service types.ServiceConfig, number int) string {
-	name := fmt.Sprintf("%s_%s_%d", projectName, service.Name, number)
+	name := strings.Join([]string{projectName, service.Name, strconv.Itoa(number)}, Separator)
 	if service.ContainerName != "" {
 		name = service.ContainerName
 	}
diff --git a/pkg/compose/exec.go b/pkg/compose/exec.go
index 74b14a6e2..6bd4b608f 100644
--- a/pkg/compose/exec.go
+++ b/pkg/compose/exec.go
@@ -21,7 +21,6 @@ import (
 	"fmt"
 	"io"
 
-	"github.com/compose-spec/compose-go/types"
 	"github.com/docker/cli/cli/streams"
 	moby "github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/filters"
@@ -31,20 +30,15 @@ import (
 	"github.com/docker/compose/v2/pkg/api"
 )
 
-func (s *composeService) Exec(ctx context.Context, project *types.Project, opts api.RunOptions) (int, error) {
-	service, err := project.GetService(opts.Service)
-	if err != nil {
-		return 0, err
-	}
-
-	container, err := s.getExecTarget(ctx, project, service, opts)
+func (s *composeService) Exec(ctx context.Context, project string, opts api.RunOptions) (int, error) {
+	container, err := s.getExecTarget(ctx, project, opts)
 	if err != nil {
 		return 0, err
 	}
 
 	exec, err := s.apiClient.ContainerExecCreate(ctx, container.ID, moby.ExecConfig{
 		Cmd:        opts.Command,
-		Env:        s.getExecEnvironment(project, service, opts),
+		Env:        opts.Environment,
 		User:       opts.User,
 		Privileged: opts.Privileged,
 		Tty:        opts.Tty,
@@ -145,11 +139,11 @@ func (s *composeService) interactiveExec(ctx context.Context, opts api.RunOption
 	}
 }
 
-func (s *composeService) getExecTarget(ctx context.Context, project *types.Project, service types.ServiceConfig, opts api.RunOptions) (moby.Container, error) {
+func (s *composeService) getExecTarget(ctx context.Context, projectName string, opts api.RunOptions) (moby.Container, error) {
 	containers, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
 		Filters: filters.NewArgs(
-			projectFilter(project.Name),
-			serviceFilter(service.Name),
+			projectFilter(projectName),
+			serviceFilter(opts.Service),
 			containerNumberFilter(opts.Index),
 		),
 	})
@@ -157,25 +151,12 @@ func (s *composeService) getExecTarget(ctx context.Context, project *types.Proje
 		return moby.Container{}, err
 	}
 	if len(containers) < 1 {
-		return moby.Container{}, fmt.Errorf("container %s not running", getContainerName(project.Name, service, opts.Index))
+		return moby.Container{}, fmt.Errorf("service %q is not running container #%d", opts.Service, opts.Index)
 	}
 	container := containers[0]
 	return container, nil
 }
 
-func (s *composeService) getExecEnvironment(project *types.Project, service types.ServiceConfig, opts api.RunOptions) []string {
-	var env []string
-	for k, v := range service.Environment.OverrideBy(types.NewMappingWithEquals(opts.Environment)).
-		Resolve(func(s string) (string, bool) {
-			v, ok := project.Environment[s]
-			return v, ok
-		}).
-		RemoveEmpty() {
-		env = append(env, fmt.Sprintf("%s=%s", k, *v))
-	}
-	return env
-}
-
 func (s *composeService) getExecExitStatus(ctx context.Context, execID string) (int, error) {
 	resp, err := s.apiClient.ContainerExecInspect(ctx, execID)
 	if err != nil {
diff --git a/pkg/compose/logs.go b/pkg/compose/logs.go
index fcf1ca28d..5544c4923 100644
--- a/pkg/compose/logs.go
+++ b/pkg/compose/logs.go
@@ -37,7 +37,7 @@ func (s *composeService) Logs(ctx context.Context, projectName string, consumer
 	if options.Follow {
 		eg.Go(func() error {
 			printer := newLogPrinter(consumer)
-			return s.watchContainers(projectName, options.Services, printer.HandleEvent, containers, func(c types.Container) error {
+			return s.watchContainers(ctx, projectName, options.Services, printer.HandleEvent, containers, func(c types.Container) error {
 				return s.logContainers(ctx, consumer, c, options)
 			})
 		})
diff --git a/pkg/compose/ps.go b/pkg/compose/ps.go
index 1c27f2967..e1a76cbb4 100644
--- a/pkg/compose/ps.go
+++ b/pkg/compose/ps.go
@@ -18,7 +18,6 @@ package compose
 
 import (
 	"context"
-	"fmt"
 	"sort"
 
 	"golang.org/x/sync/errgroup"
@@ -46,12 +45,8 @@ func (s *composeService) Ps(ctx context.Context, projectName string, options api
 				return container.Ports[i].PrivatePort < container.Ports[j].PrivatePort
 			})
 			for _, p := range container.Ports {
-				var url string
-				if p.PublicPort != 0 {
-					url = fmt.Sprintf("%s:%d", p.IP, p.PublicPort)
-				}
 				publishers = append(publishers, api.PortPublisher{
-					URL:           url,
+					URL:           p.IP,
 					TargetPort:    int(p.PrivatePort),
 					PublishedPort: int(p.PublicPort),
 					Protocol:      p.Type,
diff --git a/pkg/compose/run.go b/pkg/compose/run.go
index f40a3f6a1..5ad04d513 100644
--- a/pkg/compose/run.go
+++ b/pkg/compose/run.go
@@ -209,6 +209,6 @@ func applyRunOptions(project *types.Project, service *types.ServiceConfig, opts
 		service.Environment.OverrideBy(projectEnv)
 	}
 	for k, v := range opts.Labels {
-		service.Labels.Add(k, v)
+		service.Labels = service.Labels.Add(k, v)
 	}
 }
diff --git a/pkg/compose/start.go b/pkg/compose/start.go
index cd0aa66bb..273fee6ab 100644
--- a/pkg/compose/start.go
+++ b/pkg/compose/start.go
@@ -47,7 +47,7 @@ func (s *composeService) start(ctx context.Context, project *types.Project, opti
 		}
 
 		eg.Go(func() error {
-			return s.watchContainers(project.Name, options.AttachTo, listener, attached, func(container moby.Container) error {
+			return s.watchContainers(context.Background(), project.Name, options.AttachTo, listener, attached, func(container moby.Container) error {
 				return s.attachContainer(ctx, container, listener, project)
 			})
 		})
@@ -69,13 +69,13 @@ func (s *composeService) start(ctx context.Context, project *types.Project, opti
 type containerWatchFn func(container moby.Container) error
 
 // watchContainers uses engine events to capture container start/die and notify ContainerEventListener
-func (s *composeService) watchContainers(projectName string, services []string, listener api.ContainerEventListener, containers Containers, onStart containerWatchFn) error {
+func (s *composeService) watchContainers(ctx context.Context, projectName string, services []string, listener api.ContainerEventListener, containers Containers, onStart containerWatchFn) error {
 	watched := map[string]int{}
 	for _, c := range containers {
 		watched[c.ID] = 0
 	}
 
-	ctx, stop := context.WithCancel(context.Background())
+	ctx, stop := context.WithCancel(ctx)
 	err := s.Events(ctx, projectName, api.EventsOptions{
 		Services: services,
 		Consumer: func(event api.Event) error {


Test patch:
diff --git a/pkg/compose/ps_test.go b/pkg/compose/ps_test.go
index 0644cc0c4..5de346729 100644
--- a/pkg/compose/ps_test.go
+++ b/pkg/compose/ps_test.go
@@ -54,7 +54,7 @@ func TestPs(t *testing.T) {
 
 	expected := []compose.ContainerSummary{
 		{ID: "123", Name: "123", Project: strings.ToLower(testProject), Service: "service1", State: "running", Health: "healthy", Publishers: nil},
-		{ID: "456", Name: "456", Project: strings.ToLower(testProject), Service: "service1", State: "running", Health: "", Publishers: []compose.PortPublisher{{URL: "localhost:80", TargetPort: 90,
+		{ID: "456", Name: "456", Project: strings.ToLower(testProject), Service: "service1", State: "running", Health: "", Publishers: []compose.PortPublisher{{URL: "localhost", TargetPort: 90,
 			PublishedPort: 80}}},
 		{ID: "789", Name: "789", Project: strings.ToLower(testProject), Service: "service2", State: "exited", Health: "", ExitCode: 130, Publishers: nil},
 	}
diff --git a/pkg/e2e/cascade_stop_test.go b/pkg/e2e/cascade_stop_test.go
index 7b0cb80a1..d6470ff2b 100644
--- a/pkg/e2e/cascade_stop_test.go
+++ b/pkg/e2e/cascade_stop_test.go
@@ -29,13 +29,13 @@ func TestCascadeStop(t *testing.T) {
 
 	t.Run("abort-on-container-exit", func(t *testing.T) {
 		res := c.RunDockerOrExitError("compose", "-f", "./fixtures/cascade-stop-test/compose.yaml", "--project-name", projectName, "up", "--abort-on-container-exit")
-		res.Assert(t, icmd.Expected{ExitCode: 1, Out: `should_fail_1 exited with code 1`})
+		res.Assert(t, icmd.Expected{ExitCode: 1, Out: `should_fail-1 exited with code 1`})
 		res.Assert(t, icmd.Expected{ExitCode: 1, Out: `Aborting on container exit...`})
 	})
 
 	t.Run("exit-code-from", func(t *testing.T) {
 		res := c.RunDockerOrExitError("compose", "-f", "./fixtures/cascade-stop-test/compose.yaml", "--project-name", projectName, "up", "--exit-code-from=sleep")
-		res.Assert(t, icmd.Expected{ExitCode: 137, Out: `should_fail_1 exited with code 1`})
+		res.Assert(t, icmd.Expected{ExitCode: 137, Out: `should_fail-1 exited with code 1`})
 		res.Assert(t, icmd.Expected{ExitCode: 137, Out: `Aborting on container exit...`})
 	})
 
diff --git a/pkg/e2e/compose_exec_test.go b/pkg/e2e/compose_exec_test.go
index 36e6b970e..e3f58548c 100644
--- a/pkg/e2e/compose_exec_test.go
+++ b/pkg/e2e/compose_exec_test.go
@@ -32,17 +32,17 @@ func TestLocalComposeExec(t *testing.T) {
 	c.RunDockerCmd("compose", "--project-directory", "fixtures/simple-composefile", "--project-name", projectName, "up", "-d")
 
 	t.Run("exec true", func(t *testing.T) {
-		res := c.RunDockerOrExitError("exec", "compose-e2e-exec_simple_1", "/bin/true")
+		res := c.RunDockerOrExitError("exec", "compose-e2e-exec-simple-1", "/bin/true")
 		res.Assert(t, icmd.Expected{ExitCode: 0})
 	})
 
 	t.Run("exec false", func(t *testing.T) {
-		res := c.RunDockerOrExitError("exec", "compose-e2e-exec_simple_1", "/bin/false")
+		res := c.RunDockerOrExitError("exec", "compose-e2e-exec-simple-1", "/bin/false")
 		res.Assert(t, icmd.Expected{ExitCode: 1})
 	})
 
 	t.Run("exec with env set", func(t *testing.T) {
-		res := icmd.RunCmd(c.NewDockerCmd("exec", "-e", "FOO", "compose-e2e-exec_simple_1", "/usr/bin/env"),
+		res := icmd.RunCmd(c.NewDockerCmd("exec", "-e", "FOO", "compose-e2e-exec-simple-1", "/usr/bin/env"),
 			func(cmd *icmd.Cmd) {
 				cmd.Env = append(cmd.Env, "FOO=BAR")
 			})
@@ -50,7 +50,7 @@ func TestLocalComposeExec(t *testing.T) {
 	})
 
 	t.Run("exec without env set", func(t *testing.T) {
-		res := c.RunDockerOrExitError("exec", "-e", "FOO", "compose-e2e-exec_simple_1", "/usr/bin/env")
+		res := c.RunDockerOrExitError("exec", "-e", "FOO", "compose-e2e-exec-simple-1", "/usr/bin/env")
 		res.Assert(t, icmd.Expected{ExitCode: 0})
 		assert.Check(t, !strings.Contains(res.Stdout(), "FOO="))
 	})
diff --git a/pkg/e2e/compose_run_test.go b/pkg/e2e/compose_run_test.go
index ce24fc653..c361db516 100644
--- a/pkg/e2e/compose_run_test.go
+++ b/pkg/e2e/compose_run_test.go
@@ -54,7 +54,7 @@ func TestLocalComposeRun(t *testing.T) {
 				truncatedSlug = strings.Replace(containerID, "run-test_back_run_", "", 1)
 				runContainerID = containerID
 			}
-			if strings.HasPrefix(containerID, "run-test_db_1") {
+			if strings.HasPrefix(containerID, "run-test-db-1") {
 				assert.Assert(t, strings.Contains(line, "Up"), line)
 			}
 		}
diff --git a/pkg/e2e/compose_test.go b/pkg/e2e/compose_test.go
index 11a3b947d..6919ce840 100644
--- a/pkg/e2e/compose_test.go
+++ b/pkg/e2e/compose_test.go
@@ -71,7 +71,7 @@ func TestLocalComposeUp(t *testing.T) {
 	})
 
 	t.Run("check compose labels", func(t *testing.T) {
-		res := c.RunDockerCmd("inspect", projectName+"_web_1")
+		res := c.RunDockerCmd("inspect", projectName+"-web-1")
 		res.Assert(t, icmd.Expected{Out: `"com.docker.compose.container-number": "1"`})
 		res.Assert(t, icmd.Expected{Out: `"com.docker.compose.project": "compose-e2e-demo"`})
 		res.Assert(t, icmd.Expected{Out: `"com.docker.compose.oneoff": "False",`})
@@ -88,27 +88,27 @@ func TestLocalComposeUp(t *testing.T) {
 	})
 
 	t.Run("check user labels", func(t *testing.T) {
-		res := c.RunDockerCmd("inspect", projectName+"_web_1")
+		res := c.RunDockerCmd("inspect", projectName+"-web-1")
 		res.Assert(t, icmd.Expected{Out: `"my-label": "test"`})
 
 	})
 
 	t.Run("check healthcheck output", func(t *testing.T) {
 		c.WaitForCmdResult(c.NewDockerCmd("compose", "-p", projectName, "ps", "--format", "json"),
-			StdoutContains(`"Name":"compose-e2e-demo_web_1","Command":"/dispatcher","Project":"compose-e2e-demo","Service":"web","State":"running","Health":"healthy"`),
+			StdoutContains(`"Name":"compose-e2e-demo-web-1","Command":"/dispatcher","Project":"compose-e2e-demo","Service":"web","State":"running","Health":"healthy"`),
 			5*time.Second, 1*time.Second)
 
 		res := c.RunDockerCmd("compose", "-p", projectName, "ps")
 		res.Assert(t, icmd.Expected{Out: `NAME                       COMMAND                  SERVICE             STATUS              PORTS`})
-		res.Assert(t, icmd.Expected{Out: `compose-e2e-demo_web_1     "/dispatcher"            web                 running (healthy)   0.0.0.0:90->80/tcp, :::90->80/tcp`})
-		res.Assert(t, icmd.Expected{Out: `compose-e2e-demo_db_1      "docker-entrypoint.s…"   db                  running             5432/tcp`})
+		res.Assert(t, icmd.Expected{Out: `compose-e2e-demo-web-1     "/dispatcher"            web                 running (healthy)   0.0.0.0:90->80/tcp, :::90->80/tcp`})
+		res.Assert(t, icmd.Expected{Out: `compose-e2e-demo-db-1      "docker-entrypoint.s…"   db                  running             5432/tcp`})
 	})
 
 	t.Run("images", func(t *testing.T) {
 		res := c.RunDockerCmd("compose", "-p", projectName, "images")
-		res.Assert(t, icmd.Expected{Out: `compose-e2e-demo_db_1      gtardif/sentences-db    latest`})
-		res.Assert(t, icmd.Expected{Out: `compose-e2e-demo_web_1     gtardif/sentences-web   latest`})
-		res.Assert(t, icmd.Expected{Out: `compose-e2e-demo_words_1   gtardif/sentences-api   latest`})
+		res.Assert(t, icmd.Expected{Out: `compose-e2e-demo-db-1      gtardif/sentences-db    latest`})
+		res.Assert(t, icmd.Expected{Out: `compose-e2e-demo-web-1     gtardif/sentences-web   latest`})
+		res.Assert(t, icmd.Expected{Out: `compose-e2e-demo-words-1   gtardif/sentences-api   latest`})
 	})
 
 	t.Run("down", func(t *testing.T) {
@@ -161,10 +161,10 @@ func TestAttachRestart(t *testing.T) {
 
 	c.WaitForCondition(func() (bool, string) {
 		debug := res.Combined()
-		return strings.Count(res.Stdout(), "failing_1 exited with code 1") == 3, fmt.Sprintf("'failing_1 exited with code 1' not found 3 times in : \n%s\n", debug)
+		return strings.Count(res.Stdout(), "failing-1 exited with code 1") == 3, fmt.Sprintf("'failing-1 exited with code 1' not found 3 times in : \n%s\n", debug)
 	}, 2*time.Minute, 2*time.Second)
 
-	assert.Equal(t, strings.Count(res.Stdout(), "failing_1  | world"), 3, res.Combined())
+	assert.Equal(t, strings.Count(res.Stdout(), "failing-1  | world"), 3, res.Combined())
 }
 
 func TestInitContainer(t *testing.T) {
@@ -172,7 +172,7 @@ func TestInitContainer(t *testing.T) {
 
 	res := c.RunDockerOrExitError("compose", "--ansi=never", "--project-directory", "./fixtures/init-container", "up")
 	defer c.RunDockerOrExitError("compose", "-p", "init-container", "down")
-	testify.Regexp(t, "foo_1  | hello(?m:.*)bar_1  | world", res.Stdout())
+	testify.Regexp(t, "foo-1  | hello(?m:.*)bar-1  | world", res.Stdout())
 }
 
 func TestRm(t *testing.T) {
@@ -198,3 +198,24 @@ func TestRm(t *testing.T) {
 		c.RunDockerCmd("compose", "-p", projectName, "down")
 	})
 }
+
+func TestCompatibility(t *testing.T) {
+	c := NewParallelE2eCLI(t, binDir)
+
+	const projectName = "compose-e2e-compatibility"
+
+	t.Run("up", func(t *testing.T) {
+		c.RunDockerCmd("compose", "--compatibility", "-f", "./fixtures/sentences/compose.yaml", "--project-name", projectName, "up", "-d")
+	})
+
+	t.Run("check container names", func(t *testing.T) {
+		res := c.RunDockerCmd("ps", "--format", "{{.Names}}")
+		res.Assert(t, icmd.Expected{Out: "compose-e2e-compatibility_web_1"})
+		res.Assert(t, icmd.Expected{Out: "compose-e2e-compatibility_words_1"})
+		res.Assert(t, icmd.Expected{Out: "compose-e2e-compatibility_db_1"})
+	})
+
+	t.Run("down", func(t *testing.T) {
+		c.RunDockerCmd("compose", "-p", projectName, "down")
+	})
+}
diff --git a/pkg/e2e/cp_test.go b/pkg/e2e/cp_test.go
index 67f5c63ea..20bae16b8 100644
--- a/pkg/e2e/cp_test.go
+++ b/pkg/e2e/cp_test.go
@@ -51,7 +51,7 @@ func TestCopy(t *testing.T) {
 		res := c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/compose.yaml", "-p", projectName, "cp", "./fixtures/cp-test/cp-me.txt", "nginx:/tmp/default.txt")
 		res.Assert(t, icmd.Expected{ExitCode: 0})
 
-		output := c.RunDockerCmd("exec", projectName+"_nginx_1", "cat", "/tmp/default.txt").Stdout()
+		output := c.RunDockerCmd("exec", projectName+"-nginx-1", "cat", "/tmp/default.txt").Stdout()
 		assert.Assert(t, strings.Contains(output, `hello world`), output)
 
 		res = c.RunDockerOrExitError("exec", projectName+"_nginx_2", "cat", "/tmp/default.txt")
@@ -62,10 +62,10 @@ func TestCopy(t *testing.T) {
 		res := c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/compose.yaml", "-p", projectName, "cp", "--index=3", "./fixtures/cp-test/cp-me.txt", "nginx:/tmp/indexed.txt")
 		res.Assert(t, icmd.Expected{ExitCode: 0})
 
-		output := c.RunDockerCmd("exec", projectName+"_nginx_3", "cat", "/tmp/indexed.txt").Stdout()
+		output := c.RunDockerCmd("exec", projectName+"-nginx-3", "cat", "/tmp/indexed.txt").Stdout()
 		assert.Assert(t, strings.Contains(output, `hello world`), output)
 
-		res = c.RunDockerOrExitError("exec", projectName+"_nginx_2", "cat", "/tmp/indexed.txt")
+		res = c.RunDockerOrExitError("exec", projectName+"-nginx-2", "cat", "/tmp/indexed.txt")
 		res.Assert(t, icmd.Expected{ExitCode: 1})
 	})
 
@@ -73,13 +73,13 @@ func TestCopy(t *testing.T) {
 		res := c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/compose.yaml", "-p", projectName, "cp", "--all", "./fixtures/cp-test/cp-me.txt", "nginx:/tmp/all.txt")
 		res.Assert(t, icmd.Expected{ExitCode: 0})
 
-		output := c.RunDockerCmd("exec", projectName+"_nginx_1", "cat", "/tmp/all.txt").Stdout()
+		output := c.RunDockerCmd("exec", projectName+"-nginx-1", "cat", "/tmp/all.txt").Stdout()
 		assert.Assert(t, strings.Contains(output, `hello world`), output)
 
-		output = c.RunDockerCmd("exec", projectName+"_nginx_2", "cat", "/tmp/all.txt").Stdout()
+		output = c.RunDockerCmd("exec", projectName+"-nginx-2", "cat", "/tmp/all.txt").Stdout()
 		assert.Assert(t, strings.Contains(output, `hello world`), output)
 
-		output = c.RunDockerCmd("exec", projectName+"_nginx_3", "cat", "/tmp/all.txt").Stdout()
+		output = c.RunDockerCmd("exec", projectName+"-nginx-3", "cat", "/tmp/all.txt").Stdout()
 		assert.Assert(t, strings.Contains(output, `hello world`), output)
 	})
 
@@ -105,7 +105,7 @@ func TestCopy(t *testing.T) {
 		res := c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/compose.yaml", "-p", projectName, "cp", "./fixtures/cp-test/cp-folder", "nginx:/tmp")
 		res.Assert(t, icmd.Expected{ExitCode: 0})
 
-		output := c.RunDockerCmd("exec", projectName+"_nginx_1", "cat", "/tmp/cp-folder/cp-me.txt").Stdout()
+		output := c.RunDockerCmd("exec", projectName+"-nginx-1", "cat", "/tmp/cp-folder/cp-me.txt").Stdout()
 		assert.Assert(t, strings.Contains(output, `hello world from folder`), output)
 
 		res = c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/compose.yaml", "-p", projectName, "cp", "nginx:/tmp/cp-folder", "./fixtures/cp-test/cp-folder2")
diff --git a/pkg/e2e/ipc_test.go b/pkg/e2e/ipc_test.go
index 4bf2d709e..52aea7454 100644
--- a/pkg/e2e/ipc_test.go
+++ b/pkg/e2e/ipc_test.go
@@ -44,13 +44,13 @@ func TestIPC(t *testing.T) {
 	})
 
 	t.Run("check ipcmode in container inspect", func(t *testing.T) {
-		res := c.RunDockerCmd("inspect", projectName+"_shareable_1")
+		res := c.RunDockerCmd("inspect", projectName+"-shareable-1")
 		res.Assert(t, icmd.Expected{Out: `"IpcMode": "shareable",`})
 
-		res = c.RunDockerCmd("inspect", projectName+"_service_1")
+		res = c.RunDockerCmd("inspect", projectName+"-service-1")
 		res.Assert(t, icmd.Expected{Out: `"IpcMode": "container:`})
 
-		res = c.RunDockerCmd("inspect", projectName+"_container_1")
+		res = c.RunDockerCmd("inspect", projectName+"-container-1")
 		res.Assert(t, icmd.Expected{Out: fmt.Sprintf(`"IpcMode": "container:%s",`, cid)})
 	})
 
diff --git a/pkg/e2e/networks_test.go b/pkg/e2e/networks_test.go
index 20d1f15e7..3ebaedea1 100644
--- a/pkg/e2e/networks_test.go
+++ b/pkg/e2e/networks_test.go
@@ -107,7 +107,7 @@ func TestIPAMConfig(t *testing.T) {
 	})
 
 	t.Run("ensure service get fixed IP assigned", func(t *testing.T) {
-		res := c.RunDockerCmd("inspect", projectName+"_foo_1", "-f", "{{ .NetworkSettings.Networks."+projectName+"_default.IPAddress }}")
+		res := c.RunDockerCmd("inspect", projectName+"-foo-1", "-f", "{{ .NetworkSettings.Networks."+projectName+"_default.IPAddress }}")
 		res.Assert(t, icmd.Expected{Out: "10.1.0.100"})
 	})
 
diff --git a/pkg/e2e/restart_test.go b/pkg/e2e/restart_test.go
index dee349ff9..9b2e2ccf4 100644
--- a/pkg/e2e/restart_test.go
+++ b/pkg/e2e/restart_test.go
@@ -32,8 +32,8 @@ func TestRestart(t *testing.T) {
 
 	getServiceRegx := func(service string, status string) string {
 		// match output with random spaces like:
-		// e2e-start-stop_db_1      "echo hello"     db      running
-		return fmt.Sprintf("%s_%s_1.+%s\\s+%s", projectName, service, service, status)
+		// e2e-start-stop-db-1      "echo hello"     db      running
+		return fmt.Sprintf("%s-%s-1.+%s\\s+%s", projectName, service, service, status)
 	}
 
 	t.Run("Up a project", func(t *testing.T) {
@@ -41,7 +41,7 @@ func TestRestart(t *testing.T) {
 		c.RunDockerOrExitError("compose", "--project-name", projectName, "down")
 
 		res := c.RunDockerOrExitError("compose", "-f", "./fixtures/restart-test/compose.yaml", "--project-name", projectName, "up", "-d")
-		assert.Assert(t, strings.Contains(res.Combined(), "Container e2e-restart_restart_1  Started"), res.Combined())
+		assert.Assert(t, strings.Contains(res.Combined(), "Container e2e-restart-restart-1  Started"), res.Combined())
 
 		c.WaitForCmdResult(c.NewDockerCmd("compose", "--project-name", projectName, "ps", "-a", "--format", "json"),
 			StdoutContains(`"State":"exited"`),
diff --git a/pkg/e2e/start_stop_test.go b/pkg/e2e/start_stop_test.go
index 5d897d08f..b91f3df79 100644
--- a/pkg/e2e/start_stop_test.go
+++ b/pkg/e2e/start_stop_test.go
@@ -37,13 +37,13 @@ func TestStartStop(t *testing.T) {
 
 	getServiceRegx := func(service string, status string) string {
 		// match output with random spaces like:
-		// e2e-start-stop_db_1      "echo hello"       db          running
-		return fmt.Sprintf("%s_%s_1.+%s\\s+%s", projectName, service, service, status)
+		// e2e-start-stop-db-1      "echo hello"       db          running
+		return fmt.Sprintf("%s-%s-1.+%s\\s+%s", projectName, service, service, status)
 	}
 
 	t.Run("Up a project", func(t *testing.T) {
 		res := c.RunDockerCmd("compose", "-f", "./fixtures/start-stop/compose.yaml", "--project-name", projectName, "up", "-d")
-		assert.Assert(t, strings.Contains(res.Combined(), "Container e2e-start-stop_simple_1  Started"), res.Combined())
+		assert.Assert(t, strings.Contains(res.Combined(), "Container e2e-start-stop-simple-1  Started"), res.Combined())
 
 		res = c.RunDockerCmd("compose", "ls", "--all")
 		testify.Regexp(t, getProjectRegx("running"), res.Stdout())
@@ -63,7 +63,7 @@ func TestStartStop(t *testing.T) {
 		testify.Regexp(t, getProjectRegx("exited"), res.Stdout())
 
 		res = c.RunDockerCmd("compose", "--project-name", projectName, "ps")
-		assert.Assert(t, !strings.Contains(res.Combined(), "e2e-start-stop_words_1"), res.Combined())
+		assert.Assert(t, !strings.Contains(res.Combined(), "e2e-start-stop-words-1"), res.Combined())
 
 		res = c.RunDockerCmd("compose", "--project-name", projectName, "ps", "--all")
 		testify.Regexp(t, getServiceRegx("simple", "exited"), res.Stdout())
diff --git a/pkg/e2e/volumes_test.go b/pkg/e2e/volumes_test.go
index 051cf73a0..76384d317 100644
--- a/pkg/e2e/volumes_test.go
+++ b/pkg/e2e/volumes_test.go
@@ -44,7 +44,7 @@ func TestLocalComposeVolume(t *testing.T) {
 	})
 
 	t.Run("check container volume specs", func(t *testing.T) {
-		res := c.RunDockerCmd("inspect", "compose-e2e-volume_nginx2_1", "--format", "{{ json .Mounts }}")
+		res := c.RunDockerCmd("inspect", "compose-e2e-volume-nginx2-1", "--format", "{{ json .Mounts }}")
 		output := res.Stdout()
 		// nolint
 		assert.Assert(t, strings.Contains(output, `"Destination":"/usr/src/app/node_modules","Driver":"local","Mode":"z","RW":true,"Propagation":""`), output)
@@ -52,17 +52,17 @@ func TestLocalComposeVolume(t *testing.T) {
 	})
 
 	t.Run("check config content", func(t *testing.T) {
-		output := c.RunDockerCmd("exec", "compose-e2e-volume_nginx2_1", "cat", "/myconfig").Stdout()
+		output := c.RunDockerCmd("exec", "compose-e2e-volume-nginx2-1", "cat", "/myconfig").Stdout()
 		assert.Assert(t, strings.Contains(output, `Hello from Nginx container`), output)
 	})
 
 	t.Run("check secrets content", func(t *testing.T) {
-		output := c.RunDockerCmd("exec", "compose-e2e-volume_nginx2_1", "cat", "/run/secrets/mysecret").Stdout()
+		output := c.RunDockerCmd("exec", "compose-e2e-volume-nginx2-1", "cat", "/run/secrets/mysecret").Stdout()
 		assert.Assert(t, strings.Contains(output, `Hello from Nginx container`), output)
 	})
 
 	t.Run("check container bind-mounts specs", func(t *testing.T) {
-		res := c.RunDockerCmd("inspect", "compose-e2e-volume_nginx_1", "--format", "{{ json .Mounts }}")
+		res := c.RunDockerCmd("inspect", "compose-e2e-volume-nginx-1", "--format", "{{ json .Mounts }}")
 		output := res.Stdout()
 		// nolint
 		assert.Assert(t, strings.Contains(output, `"Type":"bind"`))
@@ -70,15 +70,15 @@ func TestLocalComposeVolume(t *testing.T) {
 	})
 
 	t.Run("should inherit anonymous volumes", func(t *testing.T) {
-		c.RunDockerOrExitError("exec", "compose-e2e-volume_nginx2_1", "touch", "/usr/src/app/node_modules/test")
+		c.RunDockerOrExitError("exec", "compose-e2e-volume-nginx2-1", "touch", "/usr/src/app/node_modules/test")
 		c.RunDockerOrExitError("compose", "--project-directory", "fixtures/volume-test", "--project-name", projectName, "up", "--force-recreate", "-d")
-		c.RunDockerOrExitError("exec", "compose-e2e-volume_nginx2_1", "ls", "/usr/src/app/node_modules/test")
+		c.RunDockerOrExitError("exec", "compose-e2e-volume-nginx2-1", "ls", "/usr/src/app/node_modules/test")
 	})
 
 	t.Run("should renew anonymous volumes", func(t *testing.T) {
-		c.RunDockerOrExitError("exec", "compose-e2e-volume_nginx2_1", "touch", "/usr/src/app/node_modules/test")
+		c.RunDockerOrExitError("exec", "compose-e2e-volume-nginx2-1", "touch", "/usr/src/app/node_modules/test")
 		c.RunDockerOrExitError("compose", "--project-directory", "fixtures/volume-test", "--project-name", projectName, "up", "--force-recreate", "--renew-anon-volumes", "-d")
-		c.RunDockerOrExitError("exec", "compose-e2e-volume_nginx2_1", "ls", "/usr/src/app/node_modules/test")
+		c.RunDockerOrExitError("exec", "compose-e2e-volume-nginx2-1", "ls", "/usr/src/app/node_modules/test")
 	})
 
 	t.Run("cleanup volume project", func(t *testing.T) {