Repository: docker/compose-cli
Pull request #2151 (2022-05-23 07:00:43)

build metrics compatibility for next 22.06
follow-up https://github.com/docker/cli/pull/3314 and https://github.com/docker/compose-cli/pull/2104

Ensures build metrics are future proof for the next Docker major release.

To detect if buildx will be used while typing `docker build ...`, we need to check the current docker cli version. I don't think there is another simple way, besides creating an infamous custom dimension in Looker. :sweat_smile:

cc @tonistiigi @chris-crone @gtardif @mat007 

Signed-off-by: CrazyMax <crazy-max@users.noreply.github.com>

crazy-max: Switch from shelling out to retrieve docker cli version by using the [`debug/buildinfo` module](https://pkg.go.dev/debug/buildinfo). Atm docker cli strips DWARF symbol table so will be empty for current 22.06 but will be solved by https://github.com/docker/cli/pull/3717.

PTAL @mat007 @rumpl @glours 

Patch:
diff --git a/cli/main.go b/cli/main.go
index 3a644fe5..33222f57 100644
--- a/cli/main.go
+++ b/cli/main.go
@@ -61,6 +61,7 @@ import (
 )
 
 var (
+	metricsClient           metrics.Client
 	contextAgnosticCommands = map[string]struct{}{
 		"context":          {},
 		"login":            {},
@@ -86,6 +87,12 @@ func init() {
 	if err := os.Setenv("PATH", appendPaths(os.Getenv("PATH"), path)); err != nil {
 		panic(err)
 	}
+
+	metricsClient = metrics.NewClient()
+	metricsClient.WithCliVersionFunc(func() string {
+		return mobycli.CliVersion()
+	})
+
 	// Seed random
 	rand.Seed(time.Now().UnixNano())
 }
@@ -249,7 +256,7 @@ func main() {
 	if err = root.ExecuteContext(ctx); err != nil {
 		handleError(ctx, err, ctype, currentContext, cc, root)
 	}
-	metrics.Track(ctype, os.Args[1:], compose.SuccessStatus)
+	metricsClient.Track(ctype, os.Args[1:], compose.SuccessStatus)
 }
 
 func customizeCliForACI(command *cobra.Command, proxy *api.ServiceProxy) {
@@ -271,7 +278,7 @@ func customizeCliForACI(command *cobra.Command, proxy *api.ServiceProxy) {
 func handleError(ctx context.Context, err error, ctype string, currentContext string, cc *store.DockerContext, root *cobra.Command) {
 	// if user canceled request, simply exit without any error message
 	if api.IsErrCanceled(err) || errors.Is(ctx.Err(), context.Canceled) {
-		metrics.Track(ctype, os.Args[1:], compose.CanceledStatus)
+		metricsClient.Track(ctype, os.Args[1:], compose.CanceledStatus)
 		os.Exit(130)
 	}
 	if ctype == store.AwsContextType {
@@ -293,7 +300,7 @@ $ docker context create %s <name>`, cc.Type(), store.EcsContextType), ctype)
 
 func exit(ctx string, err error, ctype string) {
 	if exit, ok := err.(cli.StatusError); ok {
-		metrics.Track(ctype, os.Args[1:], compose.SuccessStatus)
+		metricsClient.Track(ctype, os.Args[1:], compose.SuccessStatus)
 		os.Exit(exit.StatusCode)
 	}
 
@@ -308,7 +315,7 @@ func exit(ctx string, err error, ctype string) {
 		metricsStatus = compose.CommandSyntaxFailure.MetricsStatus
 		exitCode = compose.CommandSyntaxFailure.ExitCode
 	}
-	metrics.Track(ctype, os.Args[1:], metricsStatus)
+	metricsClient.Track(ctype, os.Args[1:], metricsStatus)
 
 	if errors.Is(err, api.ErrLoginRequired) {
 		fmt.Fprintln(os.Stderr, err)
@@ -343,7 +350,7 @@ func checkIfUnknownCommandExistInDefaultContext(err error, currentContext string
 
 		if mobycli.IsDefaultContextCommand(dockerCommand) {
 			fmt.Fprintf(os.Stderr, "Command %q not available in current context (%s), you can use the \"default\" context to run this command\n", dockerCommand, currentContext)
-			metrics.Track(contextType, os.Args[1:], compose.FailureStatus)
+			metricsClient.Track(contextType, os.Args[1:], compose.FailureStatus)
 			os.Exit(1)
 		}
 	}
diff --git a/cli/metrics/client.go b/cli/metrics/client.go
index 570a135e..46103e0a 100644
--- a/cli/metrics/client.go
+++ b/cli/metrics/client.go
@@ -27,9 +27,15 @@ import (
 )
 
 type client struct {
+	cliversion *cliversion
 	httpClient *http.Client
 }
 
+type cliversion struct {
+	version string
+	f       func() string
+}
+
 // Command is a command
 type Command struct {
 	Command string `json:"command"`
@@ -47,17 +53,23 @@ func init() {
 	}
 }
 
-// Client sends metrics to Docker Desktopn
+// Client sends metrics to Docker Desktop
 type Client interface {
+	// WithCliVersionFunc sets the docker cli version func
+	// that returns the docker cli version (com.docker.cli)
+	WithCliVersionFunc(f func() string)
 	// Send sends the command to Docker Desktop. Note that the function doesn't
 	// return anything, not even an error, this is because we don't really care
 	// if the metrics were sent or not. We only fire and forget.
 	Send(Command)
+	// Track sends the tracking analytics to Docker Desktop
+	Track(context string, args []string, status string)
 }
 
 // NewClient returns a new metrics client
 func NewClient() Client {
 	return &client{
+		cliversion: &cliversion{},
 		httpClient: &http.Client{
 			Transport: &http.Transport{
 				DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
@@ -68,6 +80,10 @@ func NewClient() Client {
 	}
 }
 
+func (c *client) WithCliVersionFunc(f func() string) {
+	c.cliversion.f = f
+}
+
 func (c *client) Send(command Command) {
 	result := make(chan bool, 1)
 	go func() {
diff --git a/cli/metrics/metadata/build.go b/cli/metrics/metadata/build.go
index a9c72b4b..bcf42b0e 100644
--- a/cli/metrics/metadata/build.go
+++ b/cli/metrics/metadata/build.go
@@ -31,23 +31,34 @@ import (
 	"github.com/docker/cli/cli/config/configfile"
 	"github.com/docker/docker/api/types"
 	dockerclient "github.com/docker/docker/client"
+	"github.com/hashicorp/go-version"
 	"github.com/spf13/pflag"
 )
 
-// getBuildMetadata returns build metadata for this command
-func getBuildMetadata(cliSource string, command string, args []string) string {
+// BuildMetadata returns build metadata for this command
+func BuildMetadata(cliSource, cliVersion, command string, args []string) string {
 	var cli, builder string
 	dockercfg := config.LoadDefaultConfigFile(io.Discard)
 	if alias, ok := dockercfg.Aliases["builder"]; ok {
+		if alias != "buildx" {
+			return cliSource
+		}
 		command = alias
 	}
 	if command == "build" {
-		cli = "docker"
-		builder = "buildkit"
-		if enabled, _ := isBuildKitEnabled(); !enabled {
-			builder = "legacy"
+		buildkitEnabled, _ := isBuildKitEnabled()
+		if buildkitEnabled && isBuildxDefault(cliVersion) {
+			command = "buildx"
+			args = append([]string{"build"}, args...)
+		} else {
+			cli = "docker"
+			builder = "buildkit"
+			if !buildkitEnabled {
+				builder = "legacy"
+			}
 		}
-	} else if command == "buildx" {
+	}
+	if command == "buildx" {
 		cli = "buildx"
 		builder = buildxDriver(dockercfg, args)
 	}
@@ -183,3 +194,24 @@ func buildxBuilder(buildArgs []string) string {
 	}
 	return builder
 }
+
+// isBuildxDefault returns true if buildx by default is used
+// through "docker build" command which is already an alias to
+// "docker buildx build" in docker cli.
+// more info: https://github.com/docker/cli/pull/3314
+func isBuildxDefault(cliVersion string) bool {
+	if cliVersion == "" {
+		// empty means DWARF symbol table is stripped from cli binary
+		// which is the case with docker cli < 22.06
+		return false
+	}
+	verCurrent, err := version.NewVersion(cliVersion)
+	if err != nil {
+		return false
+	}
+	// 21.0.0 is an arbitrary version number because next major is not
+	// intended to be 21 but 22 and buildx by default will never be part
+	// of a 20 release version anyway.
+	verBuildxDefault, _ := version.NewVersion("21.0.0")
+	return verCurrent.GreaterThanOrEqual(verBuildxDefault)
+}
diff --git a/cli/metrics/metadata/metadata.go b/cli/metrics/metadata/metadata.go
deleted file mode 100644
index 7d55ab8b..00000000
--- a/cli/metrics/metadata/metadata.go
+++ /dev/null
@@ -1,29 +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 metadata
-
-// Get returns the JSON metadata linked to the invoked command
-func Get(cliSource string, args []string) string {
-	if len(args) == 0 {
-		return cliSource
-	}
-	switch args[0] {
-	case "build", "buildx":
-		cliSource = getBuildMetadata(cliSource, args[0], args[1:])
-	}
-	return cliSource
-}
diff --git a/cli/metrics/metrics.go b/cli/metrics/metrics.go
index 11cbfc2e..247e55be 100644
--- a/cli/metrics/metrics.go
+++ b/cli/metrics/metrics.go
@@ -24,23 +24,32 @@ import (
 	"github.com/docker/compose/v2/pkg/utils"
 )
 
-// Track sends the tracking analytics to Docker Desktop
-func Track(context string, args []string, status string) {
+func (c *client) Track(context string, args []string, status string) {
 	if isInvokedAsCliBackend() {
 		return
 	}
 	command := GetCommand(args)
 	if command != "" {
-		c := NewClient()
 		c.Send(Command{
 			Command: command,
 			Context: context,
-			Source:  metadata.Get(CLISource, args),
+			Source:  c.getMetadata(CLISource, args),
 			Status:  status,
 		})
 	}
 }
 
+func (c *client) getMetadata(cliSource string, args []string) string {
+	if len(args) == 0 {
+		return cliSource
+	}
+	switch args[0] {
+	case "build", "buildx":
+		cliSource = metadata.BuildMetadata(cliSource, c.cliversion.f(), args[0], args[1:])
+	}
+	return cliSource
+}
+
 func isInvokedAsCliBackend() bool {
 	executable := os.Args[0]
 	return strings.HasSuffix(executable, "-backend")
diff --git a/cli/mobycli/exec.go b/cli/mobycli/exec.go
index 9e5be867..73ba171b 100644
--- a/cli/mobycli/exec.go
+++ b/cli/mobycli/exec.go
@@ -18,6 +18,7 @@ package mobycli
 
 import (
 	"context"
+	"debug/buildinfo"
 	"fmt"
 	"os"
 	"os/exec"
@@ -25,15 +26,16 @@ import (
 	"path/filepath"
 	"regexp"
 	"runtime"
-
-	"github.com/docker/compose/v2/pkg/compose"
-	"github.com/docker/compose/v2/pkg/utils"
-	"github.com/spf13/cobra"
+	"strings"
 
 	apicontext "github.com/docker/compose-cli/api/context"
 	"github.com/docker/compose-cli/api/context/store"
 	"github.com/docker/compose-cli/cli/metrics"
 	"github.com/docker/compose-cli/cli/mobycli/resolvepath"
+	"github.com/docker/compose/v2/pkg/compose"
+	"github.com/docker/compose/v2/pkg/utils"
+	"github.com/google/shlex"
+	"github.com/spf13/cobra"
 )
 
 var delegatedContextTypes = []string{store.DefaultContextType}
@@ -71,16 +73,20 @@ func mustDelegateToMoby(ctxType string) bool {
 
 // Exec delegates to com.docker.cli if on moby context
 func Exec(root *cobra.Command) {
+	metricsClient := metrics.NewClient()
+	metricsClient.WithCliVersionFunc(func() string {
+		return CliVersion()
+	})
 	childExit := make(chan bool)
 	err := RunDocker(childExit, os.Args[1:]...)
 	childExit <- true
 	if err != nil {
 		if exiterr, ok := err.(*exec.ExitError); ok {
 			exitCode := exiterr.ExitCode()
-			metrics.Track(store.DefaultContextType, os.Args[1:], compose.ByExitCode(exitCode).MetricsStatus)
+			metricsClient.Track(store.DefaultContextType, os.Args[1:], compose.ByExitCode(exitCode).MetricsStatus)
 			os.Exit(exitCode)
 		}
-		metrics.Track(store.DefaultContextType, os.Args[1:], compose.FailureStatus)
+		metricsClient.Track(store.DefaultContextType, os.Args[1:], compose.FailureStatus)
 		fmt.Fprintln(os.Stderr, err)
 		os.Exit(1)
 	}
@@ -92,7 +98,7 @@ func Exec(root *cobra.Command) {
 	if command == "login" && !metrics.HasQuietFlag(commandArgs) {
 		displayPATSuggestMsg(commandArgs)
 	}
-	metrics.Track(store.DefaultContextType, os.Args[1:], compose.SuccessStatus)
+	metricsClient.Track(store.DefaultContextType, os.Args[1:], compose.SuccessStatus)
 
 	os.Exit(0)
 }
@@ -174,6 +180,35 @@ func IsDefaultContextCommand(dockerCommand string) bool {
 	return regexp.MustCompile("Usage:\\s*docker\\s*" + dockerCommand).Match(b)
 }
 
+// CliVersion returns the docker cli version
+func CliVersion() string {
+	info, err := buildinfo.ReadFile(ComDockerCli)
+	if err != nil {
+		return ""
+	}
+	for _, s := range info.Settings {
+		if s.Key != "-ldflags" {
+			continue
+		}
+		args, err := shlex.Split(s.Value)
+		if err != nil {
+			return ""
+		}
+		for _, a := range args {
+			// https://github.com/docker/cli/blob/f1615facb1ca44e4336ab20e621315fc2cfb845a/scripts/build/.variables#L77
+			if !strings.HasPrefix(a, "github.com/docker/cli/cli/version.Version") {
+				continue
+			}
+			parts := strings.Split(a, "=")
+			if len(parts) != 2 {
+				return ""
+			}
+			return parts[1]
+		}
+	}
+	return ""
+}
+
 // ExecSilent executes a command and do redirect output to stdOut, return output
 func ExecSilent(ctx context.Context, args ...string) ([]byte, error) {
 	if len(args) == 0 {
diff --git a/go.mod b/go.mod
index cb1aa877..2f2f5544 100644
--- a/go.mod
+++ b/go.mod
@@ -30,8 +30,10 @@ require (
 	github.com/golang/mock v1.5.0
 	github.com/golang/protobuf v1.5.2
 	github.com/google/go-cmp v0.5.5
+	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
 	github.com/hashicorp/go-multierror v1.1.0
 	github.com/hashicorp/go-uuid v1.0.2
+	github.com/hashicorp/go-version v1.4.0
 	github.com/iancoleman/strcase v0.1.2
 	github.com/joho/godotenv v1.3.0
 	github.com/labstack/echo v3.3.10+incompatible
@@ -115,7 +117,6 @@ require (
 	github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
 	github.com/google/btree v1.0.0 // indirect
 	github.com/google/gofuzz v1.1.0 // indirect
-	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
 	github.com/google/uuid v1.2.0 // indirect
 	github.com/googleapis/gnostic v0.4.1 // indirect
 	github.com/gorilla/mux v1.8.0 // indirect
@@ -124,7 +125,6 @@ require (
 	github.com/grpc-ecosystem/go-grpc-middleware v1.2.0 // indirect
 	github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 // indirect
 	github.com/hashicorp/errwrap v1.0.0 // indirect
-	github.com/hashicorp/go-version v1.3.0 // indirect
 	github.com/hashicorp/golang-lru v0.5.3 // indirect
 	github.com/huandu/xstrings v1.3.1 // indirect
 	github.com/imdario/mergo v0.3.12 // indirect
diff --git a/go.sum b/go.sum
index 0387abcd..8e8f19c1 100644
--- a/go.sum
+++ b/go.sum
@@ -787,8 +787,8 @@ github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2I
 github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
 github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
 github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
-github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw=
-github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/go-version v1.4.0 h1:aAQzgqIrRKRa7w75CKpbBxYsmUoPjzVm1W59ca1L0J4=
+github.com/hashicorp/go-version v1.4.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
 github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=


Test patch:
diff --git a/cli/metrics/metadata/build_test.go b/cli/metrics/metadata/build_test.go
index 32a3a355..14c92feb 100644
--- a/cli/metrics/metadata/build_test.go
+++ b/cli/metrics/metadata/build_test.go
@@ -85,3 +85,32 @@ func TestBuildxDriver(t *testing.T) {
 		})
 	}
 }
+
+func TestIsBuildxDefault(t *testing.T) {
+	tts := []struct {
+		cliVersion string
+		expected   bool
+	}{
+		{
+			cliVersion: "",
+			expected:   false,
+		},
+		{
+			cliVersion: "20.10.15",
+			expected:   false,
+		},
+		{
+			cliVersion: "20.10.2-575-g22edabb584.m",
+			expected:   false,
+		},
+		{
+			cliVersion: "22.05.0",
+			expected:   true,
+		},
+	}
+	for _, tt := range tts {
+		t.Run(tt.cliVersion, func(t *testing.T) {
+			assert.Equal(t, tt.expected, isBuildxDefault(tt.cliVersion))
+		})
+	}
+}
diff --git a/cli/server/metrics_test.go b/cli/server/metrics_test.go
index 82eb980f..b830e2c0 100644
--- a/cli/server/metrics_test.go
+++ b/cli/server/metrics_test.go
@@ -122,6 +122,14 @@ type mockMetricsClient struct {
 	mock.Mock
 }
 
+func (s *mockMetricsClient) WithCliVersionFunc(f func() string) {
+	s.Called(f)
+}
+
 func (s *mockMetricsClient) Send(command metrics.Command) {
 	s.Called(command)
 }
+
+func (s *mockMetricsClient) Track(context string, args []string, status string) {
+	s.Called(context, args, status)
+}
diff --git a/local/e2e/cli-only/e2e_test.go b/local/e2e/cli-only/e2e_test.go
index 61dbed61..fb430e9f 100644
--- a/local/e2e/cli-only/e2e_test.go
+++ b/local/e2e/cli-only/e2e_test.go
@@ -30,7 +30,6 @@ import (
 	"gotest.tools/v3/golden"
 	"gotest.tools/v3/icmd"
 
-	"github.com/docker/compose-cli/cli/mobycli"
 	. "github.com/docker/compose-cli/utils/e2e"
 )
 
@@ -355,7 +354,7 @@ func TestMissingExistingCLI(t *testing.T) {
 	res := icmd.RunCmd(c)
 	res.Assert(t, icmd.Expected{
 		ExitCode: 1,
-		Err:      fmt.Sprintf(`"%s": executable file not found`, mobycli.ComDockerCli),
+		Err:      `"com.docker.cli": executable file not found`,
 	})
 }