From 8b38874ababc9a1013a785d53f7ca859e87311cc Mon Sep 17 00:00:00 2001
From: Nicolas De Loof <nicolas.deloof@gmail.com>
Date: Fri, 19 Mar 2021 09:49:14 +0100
Subject: [PATCH] introduce `port` command for parity with docker-compose

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
---
 aci/compose.go                     |  4 ++
 api/client/compose.go              |  4 ++
 api/compose/api.go                 |  8 ++++
 cli/cmd/compose/compose.go         |  1 +
 cli/cmd/compose/port.go            | 77 ++++++++++++++++++++++++++++++
 cli/cmd/compose/ps.go              | 12 +----
 cli/cmd/compose/pull.go            |  3 +-
 cli/cmd/compose/up.go              |  3 +-
 ecs/local/compose.go               |  4 ++
 ecs/up.go                          |  4 ++
 kube/compose.go                    |  4 ++
 local/compose/port.go              | 50 +++++++++++++++++++
 local/e2e/compose/networks_test.go |  5 ++
 13 files changed, 167 insertions(+), 12 deletions(-)
 create mode 100644 cli/cmd/compose/port.go
 create mode 100644 local/compose/port.go

diff --git a/aci/compose.go b/aci/compose.go
index 653436701..a3d4de65d 100644
--- a/aci/compose.go
+++ b/aci/compose.go
@@ -233,3 +233,7 @@ func (cs *aciComposeService) Top(ctx context.Context, projectName string, servic
 func (cs *aciComposeService) Events(ctx context.Context, project string, options compose.EventsOptions) error {
 	return errdefs.ErrNotImplemented
 }
+
+func (cs *aciComposeService) Port(ctx context.Context, project string, service string, port int, options compose.PortOptions) (string, int, error) {
+	return "", 0, errdefs.ErrNotImplemented
+}
diff --git a/api/client/compose.go b/api/client/compose.go
index c5b1dca36..37107f654 100644
--- a/api/client/compose.go
+++ b/api/client/compose.go
@@ -107,3 +107,7 @@ func (c *composeService) Top(ctx context.Context, projectName string, services [
 func (c *composeService) Events(ctx context.Context, project string, options compose.EventsOptions) error {
 	return errdefs.ErrNotImplemented
 }
+
+func (c *composeService) Port(ctx context.Context, project string, service string, port int, options compose.PortOptions) (string, int, error) {
+	return "", 0, errdefs.ErrNotImplemented
+}
diff --git a/api/compose/api.go b/api/compose/api.go
index c6269328f..1952e979a 100644
--- a/api/compose/api.go
+++ b/api/compose/api.go
@@ -68,6 +68,8 @@ type Service interface {
 	Top(ctx context.Context, projectName string, services []string) ([]ContainerProcSummary, error)
 	// Events executes the equivalent to a `compose events`
 	Events(ctx context.Context, project string, options EventsOptions) error
+	// Port executes the equivalent to a `compose port`
+	Port(ctx context.Context, project string, service string, port int, options PortOptions) (string, int, error)
 }
 
 // BuildOptions group options of the Build API
@@ -199,6 +201,12 @@ type Event struct {
 	Attributes map[string]string
 }
 
+// PortOptions group options of the Port API
+type PortOptions struct {
+	Protocol string
+	Index    int
+}
+
 func (e Event) String() string {
 	t := e.Timestamp.Format("2006-01-02 15:04:05.000000")
 	var attr []string
diff --git a/cli/cmd/compose/compose.go b/cli/cmd/compose/compose.go
index c6b180397..29d8ec46d 100644
--- a/cli/cmd/compose/compose.go
+++ b/cli/cmd/compose/compose.go
@@ -140,6 +140,7 @@ func Command(contextType string) *cobra.Command {
 		unpauseCommand(&opts),
 		topCommand(&opts),
 		eventsCommand(&opts),
+		portCommand(&opts),
 	)
 
 	if contextType == store.LocalContextType || contextType == store.DefaultContextType {
diff --git a/cli/cmd/compose/port.go b/cli/cmd/compose/port.go
new file mode 100644
index 000000000..c556893ca
--- /dev/null
+++ b/cli/cmd/compose/port.go
@@ -0,0 +1,77 @@
+/*
+   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"
+	"strconv"
+
+	"github.com/spf13/cobra"
+
+	"github.com/docker/compose-cli/api/client"
+	"github.com/docker/compose-cli/api/compose"
+)
+
+type portOptions struct {
+	*projectOptions
+	protocol string
+	index    int
+}
+
+func portCommand(p *projectOptions) *cobra.Command {
+	opts := portOptions{
+		projectOptions: p,
+	}
+	cmd := &cobra.Command{
+		Use:   "port [options] [--] SERVICE PRIVATE_PORT",
+		Short: "Print the public port for a port binding.",
+		Args:  cobra.MinimumNArgs(2),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			port, err := strconv.Atoi(args[1])
+			if err != nil {
+				return err
+			}
+			return runPort(cmd.Context(), opts, args[0], port)
+		},
+	}
+	cmd.Flags().StringVar(&opts.protocol, "protocol", "tcp", "tcp or udp")
+	cmd.Flags().IntVar(&opts.index, "index", 1, "index of the container if service has multiple replicas")
+	return cmd
+}
+
+func runPort(ctx context.Context, opts portOptions, service string, port int) error {
+	c, err := client.New(ctx)
+	if err != nil {
+		return err
+	}
+
+	projectName, err := opts.toProjectName()
+	if err != nil {
+		return err
+	}
+	ip, port, err := c.ComposeService().Port(ctx, projectName, service, port, compose.PortOptions{
+		Protocol: opts.protocol,
+		Index:    opts.index,
+	})
+	if err != nil {
+		return err
+	}
+
+	fmt.Printf("%s:%d\n", ip, port)
+	return nil
+}
diff --git a/cli/cmd/compose/ps.go b/cli/cmd/compose/ps.go
index be0f0a104..5e91b5be3 100644
--- a/cli/cmd/compose/ps.go
+++ b/cli/cmd/compose/ps.go
@@ -29,6 +29,7 @@ import (
 	"github.com/docker/compose-cli/api/client"
 	"github.com/docker/compose-cli/api/compose"
 	"github.com/docker/compose-cli/cli/formatter"
+	"github.com/docker/compose-cli/utils"
 )
 
 type psOptions struct {
@@ -77,7 +78,7 @@ func runPs(ctx context.Context, opts psOptions) error {
 	if opts.Services {
 		services := []string{}
 		for _, s := range containers {
-			if !contains(services, s.Service) {
+			if !utils.StringContains(services, s.Service) {
 				services = append(services, s.Service)
 			}
 		}
@@ -115,12 +116,3 @@ func runPs(ctx context.Context, opts psOptions) error {
 		},
 		"NAME", "SERVICE", "STATUS", "PORTS")
 }
-
-func contains(slice []string, item string) bool {
-	for _, v := range slice {
-		if v == item {
-			return true
-		}
-	}
-	return false
-}
diff --git a/cli/cmd/compose/pull.go b/cli/cmd/compose/pull.go
index 44ccbae37..e735d355c 100644
--- a/cli/cmd/compose/pull.go
+++ b/cli/cmd/compose/pull.go
@@ -23,6 +23,7 @@ import (
 
 	"github.com/docker/compose-cli/api/client"
 	"github.com/docker/compose-cli/api/progress"
+	"github.com/docker/compose-cli/utils"
 )
 
 type pullOptions struct {
@@ -65,7 +66,7 @@ func runPull(ctx context.Context, opts pullOptions, services []string) error {
 			return err
 		}
 		for _, s := range project.Services {
-			if !contains(services, s.Name) {
+			if !utils.StringContains(services, s.Name) {
 				project.DisabledServices = append(project.DisabledServices, s)
 			}
 		}
diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go
index 280bb4ddb..5c11d2a94 100644
--- a/cli/cmd/compose/up.go
+++ b/cli/cmd/compose/up.go
@@ -28,6 +28,7 @@ import (
 	"time"
 
 	"github.com/compose-spec/compose-go/types"
+	"github.com/docker/compose-cli/utils"
 	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 	"golang.org/x/sync/errgroup"
@@ -106,7 +107,7 @@ func (opts upOptions) apply(project *types.Project, services []string) error {
 			return err
 		}
 		for _, s := range project.Services {
-			if !contains(services, s.Name) {
+			if !utils.StringContains(services, s.Name) {
 				project.DisabledServices = append(project.DisabledServices, s)
 			}
 		}
diff --git a/ecs/local/compose.go b/ecs/local/compose.go
index 69958af98..0d6ac4ae5 100644
--- a/ecs/local/compose.go
+++ b/ecs/local/compose.go
@@ -199,3 +199,7 @@ func (e ecsLocalSimulation) Top(ctx context.Context, projectName string, service
 func (e ecsLocalSimulation) Events(ctx context.Context, project string, options compose.EventsOptions) error {
 	return e.compose.Events(ctx, project, options)
 }
+
+func (e ecsLocalSimulation) Port(ctx context.Context, project string, service string, port int, options compose.PortOptions) (string, int, error) {
+	return "", 0, errdefs.ErrNotImplemented
+}
diff --git a/ecs/up.go b/ecs/up.go
index aa937f512..cb93a6120 100644
--- a/ecs/up.go
+++ b/ecs/up.go
@@ -67,6 +67,10 @@ func (b *ecsAPIService) Events(ctx context.Context, project string, options comp
 	return errdefs.ErrNotImplemented
 }
 
+func (b *ecsAPIService) Port(ctx context.Context, project string, service string, port int, options compose.PortOptions) (string, int, error) {
+	return "", 0, errdefs.ErrNotImplemented
+}
+
 func (b *ecsAPIService) Up(ctx context.Context, project *types.Project, options compose.UpOptions) error {
 	logrus.Debugf("deploying on AWS with region=%q", b.Region)
 	err := b.aws.CheckRequirements(ctx, b.Region)
diff --git a/kube/compose.go b/kube/compose.go
index adf4992f4..52ba0ae82 100644
--- a/kube/compose.go
+++ b/kube/compose.go
@@ -262,3 +262,7 @@ func (s *composeService) Top(ctx context.Context, projectName string, services [
 func (s *composeService) Events(ctx context.Context, project string, options compose.EventsOptions) error {
 	return errdefs.ErrNotImplemented
 }
+
+func (s *composeService) Port(ctx context.Context, project string, service string, port int, options compose.PortOptions) (string, int, error) {
+	return "", 0, errdefs.ErrNotImplemented
+}
diff --git a/local/compose/port.go b/local/compose/port.go
new file mode 100644
index 000000000..c10235156
--- /dev/null
+++ b/local/compose/port.go
@@ -0,0 +1,50 @@
+/*
+   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"
+
+	"github.com/docker/compose-cli/api/compose"
+
+	moby "github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/filters"
+)
+
+func (s *composeService) Port(ctx context.Context, project string, service string, port int, options compose.PortOptions) (string, int, error) {
+	list, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
+		Filters: filters.NewArgs(
+			projectFilter(project),
+			serviceFilter(service),
+			filters.Arg("label", fmt.Sprintf("%s=%d", containerNumberLabel, options.Index)),
+		),
+	})
+	if err != nil {
+		return "", 0, err
+	}
+	if len(list) == 0 {
+		return "", 0, fmt.Errorf("no container found for %s_%d", service, options.Index)
+	}
+	container := list[0]
+	for _, p := range container.Ports {
+		if p.PrivatePort == uint16(port) && p.Type == options.Protocol {
+			return p.IP, int(p.PublicPort), nil
+		}
+	}
+	return "", 0, err
+}
diff --git a/local/e2e/compose/networks_test.go b/local/e2e/compose/networks_test.go
index f90d5796f..b1d9301e7 100644
--- a/local/e2e/compose/networks_test.go
+++ b/local/e2e/compose/networks_test.go
@@ -55,6 +55,11 @@ func TestNetworks(t *testing.T) {
 		res.Assert(t, icmd.Expected{Out: "microservices"})
 	})
 
+	t.Run("port", func(t *testing.T) {
+		res := c.RunDockerCmd("compose", "--project-name", projectName, "port", "words", "8080")
+		res.Assert(t, icmd.Expected{Out: `0.0.0.0:8080`})
+	})
+
 	t.Run("down", func(t *testing.T) {
 		_ = c.RunDockerCmd("compose", "--project-name", projectName, "down")
 	})