diff --git a/api/config/config.go b/api/config/config.go index 80265a905..8a3f8b4ef 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -100,5 +100,6 @@ func configFilePath(dir string) string { // File contains the current context from the docker configuration file type File struct { - CurrentContext string `json:"currentContext,omitempty"` + CurrentContext string `json:"currentContext,omitempty"` + Plugins map[string]map[string]string `json:"plugins,omitempty"` } diff --git a/cli/mobycli/cli_hints.go b/cli/mobycli/cli_hints.go new file mode 100644 index 000000000..ec4926a85 --- /dev/null +++ b/cli/mobycli/cli_hints.go @@ -0,0 +1,68 @@ +/* + Copyright 2023 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 mobycli + +import ( + "fmt" + "os" + + "github.com/docker/compose-cli/api/config" +) + +const ( + cliHintsEnvVarName = "DOCKER_CLI_HINTS" + cliHintsDefaultBehaviour = true + + cliHintsPluginName = "-x-cli-hints" + cliHintsEnabledName = "enabled" + cliHintsEnabled = "true" + cliHintsDisabled = "false" +) + +func CliHintsEnabled() bool { + if envValue, ok := os.LookupEnv(cliHintsEnvVarName); ok { + if enabled, err := parseCliHintFlag(envValue); err == nil { + return enabled + } + } + + conf, err := config.LoadFile(config.Dir()) + if err != nil { + // can't read the config file, use the default behaviour + return cliHintsDefaultBehaviour + } + if cliHintsPluginConfig, ok := conf.Plugins[cliHintsPluginName]; ok { + if cliHintsValue, ok := cliHintsPluginConfig[cliHintsEnabledName]; ok { + if cliHints, err := parseCliHintFlag(cliHintsValue); err == nil { + return cliHints + } + } + } + + return cliHintsDefaultBehaviour +} + +func parseCliHintFlag(value string) (bool, error) { + switch value { + case cliHintsEnabled: + return true, nil + case cliHintsDisabled: + return false, nil + default: + return cliHintsDefaultBehaviour, fmt.Errorf("could not parse CLI hints enabled flag") + } +} diff --git a/cli/mobycli/cli_hints_test.go b/cli/mobycli/cli_hints_test.go new file mode 100644 index 000000000..5adb9b9f4 --- /dev/null +++ b/cli/mobycli/cli_hints_test.go @@ -0,0 +1,168 @@ +/* + Copyright 2023 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 mobycli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/docker/compose-cli/api/config" + + "gotest.tools/v3/assert" +) + +func TestCliHintsEnabled(t *testing.T) { + testCases := []struct { + name string + setup func() + expected bool + }{ + { + "enabled by default", + func() {}, + true, + }, + { + "enabled from environment variable", + func() { + t.Setenv(cliHintsEnvVarName, "true") + }, + true, + }, + { + "disabled from environment variable", + func() { + t.Setenv(cliHintsEnvVarName, "false") + }, + false, + }, + { + "unsupported value", + func() { + t.Setenv(cliHintsEnvVarName, "maybe") + }, + true, + }, + { + "enabled in config file", + func() { + d := testConfigDir(t) + writeSampleConfig(t, d, configEnabled) + }, + true, + }, + { + "plugin defined in config file but no enabled entry", + func() { + d := testConfigDir(t) + writeSampleConfig(t, d, configPartial) + }, + true, + }, + + { + "unsupported value", + func() { + d := testConfigDir(t) + writeSampleConfig(t, d, configOnce) + }, + true, + }, + { + "disabled in config file", + func() { + d := testConfigDir(t) + writeSampleConfig(t, d, configDisabled) + }, + false, + }, + { + "enabled in config file but disabled by env var", + func() { + d := testConfigDir(t) + writeSampleConfig(t, d, configEnabled) + t.Setenv(cliHintsEnvVarName, "false") + }, + false, + }, + { + "disabled in config file but enabled by env var", + func() { + d := testConfigDir(t) + writeSampleConfig(t, d, configDisabled) + t.Setenv(cliHintsEnvVarName, "true") + }, + true, + }, + } + + for _, testCase := range testCases { + tc := testCase + t.Run(tc.name, func(t *testing.T) { + tc.setup() + assert.Equal(t, CliHintsEnabled(), tc.expected) + }) + } +} + +func testConfigDir(t *testing.T) string { + dir := config.Dir() + d, _ := os.MkdirTemp("", "") + config.WithDir(d) + t.Cleanup(func() { + _ = os.RemoveAll(d) + config.WithDir(dir) + }) + return d +} + +func writeSampleConfig(t *testing.T, d string, conf []byte) { + err := os.WriteFile(filepath.Join(d, config.ConfigFileName), conf, 0644) + assert.NilError(t, err) +} + +var configEnabled = []byte(`{ + "plugins": { + "-x-cli-hints": { + "enabled": "true" + } + } +}`) + +var configDisabled = []byte(`{ + "plugins": { + "-x-cli-hints": { + "enabled": "false" + } + } +}`) + +var configPartial = []byte(`{ + "plugins": { + "-x-cli-hints": { + } + } +}`) + +var configOnce = []byte(`{ + "plugins": { + "-x-cli-hints": { + "enabled": "maybe" + } + } +}`) diff --git a/cli/mobycli/exec.go b/cli/mobycli/exec.go index 94396f493..91b33c5a1 100644 --- a/cli/mobycli/exec.go +++ b/cli/mobycli/exec.go @@ -111,9 +111,18 @@ func Exec(_ *cobra.Command) { } commandArgs := os.Args[1:] command := metrics.GetCommand(commandArgs) - if command == "login" && !metrics.HasQuietFlag(commandArgs) { - displayPATSuggestMsg(commandArgs) + if !metrics.HasQuietFlag(commandArgs) { + switch command { + case "build": // only on regular build, not on buildx build + displayScoutQuickViewSuggestMsgOnBuild(commandArgs) + case "pull": + displayScoutQuickViewSuggestMsgOnPull(commandArgs) + case "login": + displayPATSuggestMsg(commandArgs) + default: + } } + metricsClient.Track( metrics.CmdResult{ ContextType: store.DefaultContextType, diff --git a/cli/mobycli/scout_suggest.go b/cli/mobycli/scout_suggest.go new file mode 100644 index 000000000..b6f32eb07 --- /dev/null +++ b/cli/mobycli/scout_suggest.go @@ -0,0 +1,76 @@ +/* + Copyright 2023 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 mobycli + +import ( + "fmt" + "os" + "strings" + + "github.com/docker/compose/v2/pkg/utils" + + "github.com/fatih/color" +) + +func displayScoutQuickViewSuggestMsgOnPull(args []string) { + image := pulledImageFromArgs(args) + displayScoutQuickViewSuggestMsg(image) +} + +func displayScoutQuickViewSuggestMsgOnBuild(args []string) { + // only display the hint in the main case, build command and not buildx build, no output flag, no progress flag, no push flag + if utils.StringContains(args, "--output") || utils.StringContains(args, "-o") || + utils.StringContains(args, "--progress") || + utils.StringContains(args, "--push") { + return + } + if _, ok := os.LookupEnv("BUILDKIT_PROGRESS"); ok { + return + } + displayScoutQuickViewSuggestMsg("") +} + +func displayScoutQuickViewSuggestMsg(image string) { + if !CliHintsEnabled() { + return + } + if len(image) > 0 { + image = " " + image + } + out := os.Stderr + b := color.New(color.Bold) + _, _ = fmt.Fprintln(out) + _, _ = b.Fprintln(out, "What's Next?") + _, _ = fmt.Fprintf(out, " View summary of image vulnerabilities and recommendations → %s", color.CyanString("docker scout quickview%s", image)) + _, _ = fmt.Fprintln(out) +} + +func pulledImageFromArgs(args []string) string { + var image string + var pull bool + for _, a := range args { + if a == "pull" { + pull = true + continue + } + if pull && !strings.HasPrefix(a, "-") { + image = a + break + } + } + return image +} diff --git a/go.mod b/go.mod index 69db23c9a..a048042e6 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/docker/docker v20.10.7+incompatible github.com/docker/go-connections v0.4.0 github.com/docker/go-units v0.5.0 + github.com/fatih/color v1.7.0 github.com/gobwas/ws v1.1.0 github.com/golang/mock v1.6.0 github.com/golang/protobuf v1.5.2 @@ -100,7 +101,6 @@ require ( github.com/docker/go-metrics v0.0.1 // indirect github.com/evanphx/json-patch v4.9.0+incompatible // indirect github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect - github.com/fatih/color v1.7.0 // indirect github.com/form3tech-oss/jwt-go v3.2.2+incompatible // indirect github.com/fvbommel/sortorder v1.0.1 // indirect github.com/go-errors/errors v1.0.1 // indirect