diff --git a/cli/cmd/compose/compose.go b/cli/cmd/compose/compose.go index 8e8211208..30248d25d 100644 --- a/cli/cmd/compose/compose.go +++ b/cli/cmd/compose/compose.go @@ -33,6 +33,7 @@ type composeOptions struct { WorkingDir string ConfigPaths []string Environment []string + Format string } func (o *composeOptions) toProjectName() (string, error) { diff --git a/cli/cmd/compose/list.go b/cli/cmd/compose/list.go index d44e05bb5..b1daa6903 100644 --- a/cli/cmd/compose/list.go +++ b/cli/cmd/compose/list.go @@ -21,10 +21,16 @@ import ( "fmt" "io" "os" + "strings" + "github.com/pkg/errors" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/docker/compose-cli/api/client" + "github.com/docker/compose-cli/api/compose" + "github.com/docker/compose-cli/errdefs" + "github.com/docker/compose-cli/formatter" ) func listCommand() *cobra.Command { @@ -35,10 +41,15 @@ func listCommand() *cobra.Command { return runList(cmd.Context(), opts) }, } - lsCmd.Flags().StringVarP(&opts.Name, "project-name", "p", "", "Project name") + addComposeCommonFlags(lsCmd.Flags(), &opts) return lsCmd } +func addComposeCommonFlags(f *pflag.FlagSet, opts *composeOptions) { + f.StringVarP(&opts.Name, "project-name", "p", "", "Project name") + f.StringVar(&opts.Format, "format", "", "Format the output. Values: [pretty | json]. (Default: pretty)") +} + func runList(ctx context.Context, opts composeOptions) error { c, err := client.New(ctx) if err != nil { @@ -49,10 +60,26 @@ func runList(ctx context.Context, opts composeOptions) error { return err } - err = printSection(os.Stdout, func(w io.Writer) { - for _, stack := range stackList { - fmt.Fprintf(w, "%s\t%s\n", stack.Name, stack.Status) + return printListFormatted(opts.Format, os.Stdout, stackList) +} + +func printListFormatted(format string, out io.Writer, stackList []compose.Stack) error { + var err error + switch strings.ToLower(format) { + case formatter.PRETTY, "": + err = formatter.PrintPrettySection(out, func(w io.Writer) { + for _, stack := range stackList { + fmt.Fprintf(w, "%s\t%s\n", stack.Name, stack.Status) + } + }, "NAME", "STATUS") + case formatter.JSON: + outJSON, err := formatter.ToStandardJSON(stackList) + if err != nil { + return err } - }, "NAME", "STATUS") + _, _ = fmt.Fprint(out, outJSON) + default: + err = errors.Wrapf(errdefs.ErrParsingFailed, "format value %q could not be parsed", format) + } return err } diff --git a/cli/cmd/compose/list_test.go b/cli/cmd/compose/list_test.go new file mode 100644 index 000000000..c1ddf8be5 --- /dev/null +++ b/cli/cmd/compose/list_test.go @@ -0,0 +1,45 @@ +/* + 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" + "testing" + + "gotest.tools/assert" + "gotest.tools/golden" + + "github.com/docker/compose-cli/api/compose" + "github.com/docker/compose-cli/formatter" +) + +func TestPrintComposeList(t *testing.T) { + secretList := []compose.Stack{ + { + ID: "123", + Name: "myName123", + Status: "Running", + }, + } + out := &bytes.Buffer{} + assert.NilError(t, printListFormatted(formatter.PRETTY, out, secretList)) + golden.Assert(t, out.String(), "compose-list-out.golden") + + out.Reset() + assert.NilError(t, printListFormatted(formatter.JSON, out, secretList)) + golden.Assert(t, out.String(), "compose-list-out-json.golden") +} diff --git a/cli/cmd/compose/ps.go b/cli/cmd/compose/ps.go index 8ad400554..bdef232c4 100644 --- a/cli/cmd/compose/ps.go +++ b/cli/cmd/compose/ps.go @@ -22,11 +22,14 @@ import ( "io" "os" "strings" - "text/tabwriter" + "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/docker/compose-cli/api/client" + "github.com/docker/compose-cli/api/compose" + "github.com/docker/compose-cli/errdefs" + "github.com/docker/compose-cli/formatter" ) func psCommand() *cobra.Command { @@ -37,10 +40,9 @@ func psCommand() *cobra.Command { return runPs(cmd.Context(), opts) }, } - psCmd.Flags().StringVarP(&opts.Name, "project-name", "p", "", "Project name") psCmd.Flags().StringVar(&opts.WorkingDir, "workdir", "", "Work dir") psCmd.Flags().StringArrayVarP(&opts.ConfigPaths, "file", "f", []string{}, "Compose configuration files") - + addComposeCommonFlags(psCmd.Flags(), &opts) return psCmd } @@ -59,17 +61,26 @@ func runPs(ctx context.Context, opts composeOptions) error { return err } - err = printSection(os.Stdout, func(w io.Writer) { - for _, service := range serviceList { - fmt.Fprintf(w, "%s\t%s\t%d/%d\t%s\n", service.ID, service.Name, service.Replicas, service.Desired, strings.Join(service.Ports, ", ")) - } - }, "ID", "NAME", "REPLICAS", "PORTS") - return err + return printPsFormatted(opts.Format, os.Stdout, serviceList) } -func printSection(out io.Writer, printer func(io.Writer), headers ...string) error { - w := tabwriter.NewWriter(out, 20, 1, 3, ' ', 0) - fmt.Fprintln(w, strings.Join(headers, "\t")) - printer(w) - return w.Flush() +func printPsFormatted(format string, out io.Writer, serviceList []compose.ServiceStatus) error { + var err error + switch strings.ToLower(format) { + case formatter.PRETTY, "": + err = formatter.PrintPrettySection(out, func(w io.Writer) { + for _, service := range serviceList { + fmt.Fprintf(w, "%s\t%s\t%d/%d\t%s\n", service.ID, service.Name, service.Replicas, service.Desired, strings.Join(service.Ports, ", ")) + } + }, "ID", "NAME", "REPLICAS", "PORTS") + case formatter.JSON: + outJSON, err := formatter.ToStandardJSON(serviceList) + if err != nil { + return err + } + _, _ = fmt.Fprint(out, outJSON) + default: + err = errors.Wrapf(errdefs.ErrParsingFailed, "format value %q could not be parsed", format) + } + return err } diff --git a/cli/cmd/compose/testdata/compose-list-out-json.golden b/cli/cmd/compose/testdata/compose-list-out-json.golden new file mode 100644 index 000000000..219a65d9a --- /dev/null +++ b/cli/cmd/compose/testdata/compose-list-out-json.golden @@ -0,0 +1,7 @@ +[ + { + "ID": "123", + "Name": "myName123", + "Status": "Running" + } +] \ No newline at end of file diff --git a/cli/cmd/compose/testdata/compose-list-out.golden b/cli/cmd/compose/testdata/compose-list-out.golden new file mode 100644 index 000000000..fab2b912f --- /dev/null +++ b/cli/cmd/compose/testdata/compose-list-out.golden @@ -0,0 +1,2 @@ +NAME STATUS +myName123 Running diff --git a/cli/cmd/context/ls.go b/cli/cmd/context/ls.go index 2cad5c7ba..01db114fa 100644 --- a/cli/cmd/context/ls.go +++ b/cli/cmd/context/ls.go @@ -17,18 +17,19 @@ package context import ( - "errors" "fmt" + "io" "os" "sort" "strings" - "text/tabwriter" + "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/docker/compose-cli/cli/mobycli" apicontext "github.com/docker/compose-cli/context" "github.com/docker/compose-cli/context/store" + "github.com/docker/compose-cli/errdefs" "github.com/docker/compose-cli/formatter" ) @@ -58,7 +59,8 @@ func listCommand() *cobra.Command { } cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false, "Only show context names") cmd.Flags().BoolVar(&opts.json, "json", false, "Format output as JSON") - cmd.Flags().StringVar(&opts.format, "format", "", "Format output as JSON") + cmd.Flags().StringVar(&opts.format, "format", "", "Format the output. Values: [pretty | json | go template]. (Default: pretty)") + _ = cmd.Flags().MarkHidden("json") return cmd } @@ -68,7 +70,7 @@ func runList(cmd *cobra.Command, opts lsOpts) error { if err != nil { return err } - if opts.format != "" { + if opts.format != "" && opts.format != formatter.JSON && opts.format != formatter.PRETTY { mobycli.Exec(cmd.Root()) return nil } @@ -93,35 +95,41 @@ func runList(cmd *cobra.Command, opts lsOpts) error { } if opts.json { - j, err := formatter.ToStandardJSON(contexts) - if err != nil { - return err - } - fmt.Println(j) - return nil + opts.format = formatter.JSON } - w := tabwriter.NewWriter(os.Stdout, 20, 1, 3, ' ', 0) - fmt.Fprintln(w, "NAME\tTYPE\tDESCRIPTION\tDOCKER ENDPOINT\tKUBERNETES ENDPOINT\tORCHESTRATOR") - format := "%s\t%s\t%s\t%s\t%s\t%s\n" + return printContextLsFormatted(opts.format, currentContext, os.Stdout, contexts) +} - for _, c := range contexts { - contextName := c.Name - if c.Name == currentContext { - contextName += " *" +func printContextLsFormatted(format string, currContext string, out io.Writer, contexts []*store.DockerContext) error { + var err error + switch strings.ToLower(format) { + case formatter.PRETTY, "": + err = formatter.PrintPrettySection(out, func(w io.Writer) { + for _, c := range contexts { + contextName := c.Name + if c.Name == currContext { + contextName += " *" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + contextName, + c.Type(), + c.Metadata.Description, + getEndpoint("docker", c.Endpoints), + getEndpoint("kubernetes", c.Endpoints), + c.Metadata.StackOrchestrator) + } + }, "NAME", "TYPE", "DESCRIPTION", "DOCKER ENDPOINT", "KUBERNETES ENDPOINT", "ORCHESTRATOR") + case formatter.JSON: + out, err := formatter.ToStandardJSON(contexts) + if err != nil { + return err } - - fmt.Fprintf(w, - format, - contextName, - c.Type(), - c.Metadata.Description, - getEndpoint("docker", c.Endpoints), - getEndpoint("kubernetes", c.Endpoints), - c.Metadata.StackOrchestrator) + fmt.Println(out) + default: + err = errors.Wrapf(errdefs.ErrParsingFailed, "format value %q could not be parsed", format) } - - return w.Flush() + return err } func getEndpoint(name string, meta map[string]interface{}) string { diff --git a/cli/cmd/ps.go b/cli/cmd/ps.go index 815284dec..9f9861fc4 100644 --- a/cli/cmd/ps.go +++ b/cli/cmd/ps.go @@ -19,30 +19,25 @@ package cmd import ( "context" "fmt" + "io" "os" "strings" - "text/tabwriter" "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/docker/compose-cli/api/client" "github.com/docker/compose-cli/api/containers" + "github.com/docker/compose-cli/errdefs" formatter2 "github.com/docker/compose-cli/formatter" "github.com/docker/compose-cli/utils/formatter" ) type psOpts struct { - all bool - quiet bool - json bool -} - -func (o psOpts) validate() error { - if o.quiet && o.json { - return errors.New(`cannot combine "quiet" and "json" options`) - } - return nil + all bool + quiet bool + json bool + format string } // PsCommand lists containers @@ -59,50 +54,69 @@ func PsCommand() *cobra.Command { cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false, "Only display IDs") cmd.Flags().BoolVarP(&opts.all, "all", "a", false, "Show all containers (default shows just running)") cmd.Flags().BoolVar(&opts.json, "json", false, "Format output as JSON") + cmd.Flags().StringVar(&opts.format, "format", "", "Format the output. Values: [pretty | json | go template]. (Default: pretty)") + _ = cmd.Flags().MarkHidden("json") return cmd } +func (o psOpts) validate() error { + if o.quiet && o.json { + return errors.New(`cannot combine "quiet" and "json" options`) + } + return nil +} + func runPs(ctx context.Context, opts psOpts) error { err := opts.validate() if err != nil { return err } - c, err := client.New(ctx) if err != nil { return errors.Wrap(err, "cannot connect to backend") } - containers, err := c.ContainerService().List(ctx, opts.all) + containerList, err := c.ContainerService().List(ctx, opts.all) if err != nil { - return errors.Wrap(err, "fetch containers") + return errors.Wrap(err, "fetch containerList") } if opts.quiet { - for _, c := range containers { + for _, c := range containerList { fmt.Println(c.ID) } return nil } if opts.json { - j, err := formatter2.ToStandardJSON(containers) + opts.format = formatter2.JSON + } + + return printPsFormatted(opts.format, os.Stdout, containerList) +} + +func printPsFormatted(format string, out io.Writer, containers []containers.Container) error { + var err error + switch strings.ToLower(format) { + case formatter2.PRETTY, "": + err = formatter2.PrintPrettySection(out, func(w io.Writer) { + for _, c := range containers { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", c.ID, c.Image, c.Command, c.Status, + strings.Join(formatter.PortsToStrings(c.Ports, fqdn(c)), ", ")) + } + }, "CONTAINER ID", "IMAGE", "COMMAND", "STATUS", "PORTS") + case formatter2.JSON: + out, err := formatter2.ToStandardJSON(containers) if err != nil { return err } - fmt.Println(j) - return nil - } + fmt.Println(out) - w := tabwriter.NewWriter(os.Stdout, 20, 1, 3, ' ', 0) - fmt.Fprintf(w, "CONTAINER ID\tIMAGE\tCOMMAND\tSTATUS\tPORTS\n") - format := "%s\t%s\t%s\t%s\t%s\n" - for _, container := range containers { - fmt.Fprintf(w, format, container.ID, container.Image, container.Command, container.Status, strings.Join(formatter.PortsToStrings(container.Ports, fqdn(container)), ", ")) + default: + err = errors.Wrapf(errdefs.ErrParsingFailed, "format value %q could not be parsed", format) } - - return w.Flush() + return err } func fqdn(container containers.Container) string { diff --git a/cli/cmd/secrets.go b/cli/cmd/secrets.go index cce9141f4..b7dc2aad4 100644 --- a/cli/cmd/secrets.go +++ b/cli/cmd/secrets.go @@ -21,12 +21,14 @@ import ( "io" "os" "strings" - "text/tabwriter" + "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/docker/compose-cli/api/client" "github.com/docker/compose-cli/api/secrets" + "github.com/docker/compose-cli/errdefs" + "github.com/docker/compose-cli/formatter" ) type createSecretOptions struct { @@ -105,7 +107,12 @@ func inspectSecret() *cobra.Command { return cmd } +type listSecretsOpts struct { + format string +} + func listSecrets() *cobra.Command { + var opts listSecretsOpts cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, @@ -119,10 +126,10 @@ func listSecrets() *cobra.Command { if err != nil { return err } - printList(os.Stdout, list) - return nil + return printSecretList(opts.format, os.Stdout, list) }, } + cmd.Flags().StringVar(&opts.format, "format", "", "Format the output. Values: [pretty | json]. (Default: pretty)") return cmd } @@ -149,17 +156,23 @@ func deleteSecret() *cobra.Command { return cmd } -func printList(out io.Writer, secrets []secrets.Secret) { - printSection(out, func(w io.Writer) { - for _, secret := range secrets { - fmt.Fprintf(w, "%s\t%s\t%s\n", secret.ID, secret.Name, secret.Description) // nolint:errcheck +func printSecretList(format string, out io.Writer, secrets []secrets.Secret) error { + var err error + switch strings.ToLower(format) { + case formatter.PRETTY, "": + err = formatter.PrintPrettySection(out, func(w io.Writer) { + for _, secret := range secrets { + fmt.Fprintf(w, "%s\t%s\t%s\n", secret.ID, secret.Name, secret.Description) // nolint:errcheck + } + }, "ID", "NAME", "DESCRIPTION") + case formatter.JSON: + outJSON, err := formatter.ToStandardJSON(secrets) + if err != nil { + return err } - }, "ID", "NAME", "DESCRIPTION") -} - -func printSection(out io.Writer, printer func(io.Writer), headers ...string) { - w := tabwriter.NewWriter(out, 20, 1, 3, ' ', 0) - fmt.Fprintln(w, strings.Join(headers, "\t")) // nolint:errcheck - printer(w) - w.Flush() // nolint:errcheck + _, _ = fmt.Fprint(out, outJSON) + default: + err = errors.Wrapf(errdefs.ErrParsingFailed, "format value %q could not be parsed", format) + } + return err } diff --git a/cli/cmd/secrets_test.go b/cli/cmd/secrets_test.go index 9624c0c5d..2e6a2fd1c 100644 --- a/cli/cmd/secrets_test.go +++ b/cli/cmd/secrets_test.go @@ -20,13 +20,15 @@ import ( "bytes" "testing" + "gotest.tools/assert" "gotest.tools/v3/golden" "github.com/docker/compose-cli/api/secrets" + "github.com/docker/compose-cli/formatter" ) func TestPrintList(t *testing.T) { - secrets := []secrets.Secret{ + secretList := []secrets.Secret{ { ID: "123", Name: "secret123", @@ -34,6 +36,10 @@ func TestPrintList(t *testing.T) { }, } out := &bytes.Buffer{} - printList(out, secrets) + assert.NilError(t, printSecretList(formatter.PRETTY, out, secretList)) golden.Assert(t, out.String(), "secrets-out.golden") + + out.Reset() + assert.NilError(t, printSecretList(formatter.JSON, out, secretList)) + golden.Assert(t, out.String(), "secrets-out-json.golden") } diff --git a/cli/cmd/testdata/secrets-out-json.golden b/cli/cmd/testdata/secrets-out-json.golden new file mode 100644 index 000000000..55a32b3ee --- /dev/null +++ b/cli/cmd/testdata/secrets-out-json.golden @@ -0,0 +1,8 @@ +[ + { + "ID": "123", + "Name": "secret123", + "Labels": null, + "Description": "secret 1,2,3" + } +] \ No newline at end of file diff --git a/cli/cmd/version.go b/cli/cmd/version.go index 70a7cd0ad..c37171d70 100644 --- a/cli/cmd/version.go +++ b/cli/cmd/version.go @@ -18,43 +18,94 @@ package cmd import ( "fmt" + "os" "strings" "github.com/spf13/cobra" "github.com/docker/compose-cli/cli/cmd/mobyflags" "github.com/docker/compose-cli/cli/mobycli" + "github.com/docker/compose-cli/formatter" ) +const formatOpt = "format" + // VersionCommand command to display version func VersionCommand(version string) *cobra.Command { cmd := &cobra.Command{ Use: "version", Short: "Show the Docker version information", Args: cobra.MaximumNArgs(0), - RunE: func(cmd *cobra.Command, _ []string) error { - return runVersion(cmd, version) + Run: func(cmd *cobra.Command, _ []string) { + runVersion(cmd, version) }, } // define flags for backward compatibility with com.docker.cli flags := cmd.Flags() - flags.StringP("format", "f", "", "Format the output using the given Go template") + flags.StringP(formatOpt, "f", "", "Format the output using the given Go template") + // flags.String(&opts.format, "format", "", "Format the output. Values: [pretty | json | go template]. (Default: pretty)") flags.String("kubeconfig", "", "Kubernetes config file") mobyflags.AddMobyFlagsForRetrocompatibility(flags) return cmd } -func runVersion(cmd *cobra.Command, version string) error { +func runVersion(cmd *cobra.Command, version string) { + var versionString string + format := strings.TrimSpace(cmd.Flag(formatOpt).Value.String()) displayedVersion := strings.TrimPrefix(version, "v") - versionResult, _ := mobycli.ExecSilent(cmd.Context()) + // Replace is preferred in this case to keep the order. + switch format { + case formatter.PRETTY, "": + versionString = strings.Replace(getOutFromMoby(cmd, fixedPrettyArgs(os.Args[1:])...), + "\n Version:", "\n Cloud integration: "+displayedVersion+"\n Version:", 1) + case formatter.JSON, "{{json .}}", "{{json . }}", "{{ json .}}", "{{ json . }}": // Try to catch full JSON formats + versionString = strings.Replace(getOutFromMoby(cmd, fixedJSONArgs(os.Args[1:])...), + `"Version":`, fmt.Sprintf(`"CloudIntegration":%q,"Version":`, displayedVersion), 1) + } + fmt.Print(versionString) +} + +func getOutFromMoby(cmd *cobra.Command, args ...string) string { + versionResult, _ := mobycli.ExecSilent(cmd.Context(), args...) // we don't want to fail on error, there is an error if the engine is not available but it displays client version info // Still, technically the [] byte versionResult could be nil, just let the original command display what it has to display if versionResult == nil { mobycli.Exec(cmd.Root()) - return nil + return "" + } + return string(versionResult) +} + +func fixedPrettyArgs(oArgs []string) []string { + var args []string + for i := 0; i < len(oArgs); i++ { + if isFormatOpt(oArgs[i]) && + len(oArgs) > i && + (strings.ToLower(oArgs[i+1]) == formatter.PRETTY || oArgs[i+1] == "") { + i++ + continue + } + args = append(args, oArgs[i]) + } + return args +} + +func fixedJSONArgs(oArgs []string) []string { + var args []string + for i := 0; i < len(oArgs); i++ { + if isFormatOpt(oArgs[i]) && + len(oArgs) > i && + strings.ToLower(oArgs[i+1]) == formatter.JSON { + args = append(args, oArgs[i], "{{json .}}") + i++ + continue + } + args = append(args, oArgs[i]) } - var s string = string(versionResult) - fmt.Print(strings.Replace(s, "\n Version:", "\n Cloud integration "+displayedVersion+"\n Version:", 1)) - return nil + return args +} + +func isFormatOpt(o string) bool { + return o == "--format" || o == "-f" } diff --git a/cli/cmd/version_test.go b/cli/cmd/version_test.go new file mode 100644 index 000000000..ea61e0245 --- /dev/null +++ b/cli/cmd/version_test.go @@ -0,0 +1,190 @@ +/* + 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 cmd + +import ( + "testing" + + "gotest.tools/assert" +) + +type caze struct { + Actual []string + Expected []string +} + +func TestVersionFormat(t *testing.T) { + jsonCases := []caze{ + { + Actual: fixedJSONArgs([]string{}), + Expected: nil, + }, + { + Actual: fixedJSONArgs([]string{ + "docker", + "version", + }), + Expected: []string{ + "docker", + "version", + }, + }, + { + Actual: fixedJSONArgs([]string{ + "docker", + "version", + "--format", + "json", + }), + Expected: []string{ + "docker", + "version", + "--format", + "{{json .}}", + }, + }, + { + Actual: fixedJSONArgs([]string{ + "docker", + "version", + "--format", + "jSoN", + }), + Expected: []string{ + "docker", + "version", + "--format", + "{{json .}}", + }, + }, + { + Actual: fixedJSONArgs([]string{ + "docker", + "version", + "--format", + "json", + "--kubeconfig", + "myKubeConfig", + }), + Expected: []string{ + "docker", + "version", + "--format", + "{{json .}}", + "--kubeconfig", + "myKubeConfig", + }, + }, + { + Actual: fixedJSONArgs([]string{ + "--format", + "json", + }), + Expected: []string{ + "--format", + "{{json .}}", + }, + }, + } + prettyCases := []caze{ + { + Actual: fixedPrettyArgs([]string{}), + Expected: nil, + }, + { + Actual: fixedPrettyArgs([]string{ + "docker", + "version", + }), + Expected: []string{ + "docker", + "version", + }, + }, + { + Actual: fixedPrettyArgs([]string{ + "docker", + "version", + "--format", + "pretty", + }), + Expected: []string{ + "docker", + "version", + }, + }, + { + Actual: fixedPrettyArgs([]string{ + "docker", + "version", + "--format", + "pRettY", + }), + Expected: []string{ + "docker", + "version", + }, + }, + { + Actual: fixedPrettyArgs([]string{ + "docker", + "version", + "--format", + "", + }), + Expected: []string{ + "docker", + "version", + }, + }, + { + Actual: fixedPrettyArgs([]string{ + "docker", + "version", + "--format", + "pretty", + "--kubeconfig", + "myKubeConfig", + }), + Expected: []string{ + "docker", + "version", + "--kubeconfig", + "myKubeConfig", + }, + }, + { + Actual: fixedPrettyArgs([]string{ + "--format", + "pretty", + }), + Expected: nil, + }, + } + + t.Run("json", func(t *testing.T) { + for _, c := range jsonCases { + assert.DeepEqual(t, c.Actual, c.Expected) + } + }) + + t.Run("pretty", func(t *testing.T) { + for _, c := range prettyCases { + assert.DeepEqual(t, c.Actual, c.Expected) + } + }) +} diff --git a/cli/cmd/volume/list.go b/cli/cmd/volume/list.go index 9ab3313ac..d20b618bd 100644 --- a/cli/cmd/volume/list.go +++ b/cli/cmd/volume/list.go @@ -21,15 +21,22 @@ import ( "io" "os" "strings" - "text/tabwriter" + "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/docker/compose-cli/api/client" "github.com/docker/compose-cli/api/volumes" + "github.com/docker/compose-cli/errdefs" + "github.com/docker/compose-cli/formatter" ) +type listVolumeOpts struct { + format string +} + func listVolume() *cobra.Command { + var opts listVolumeOpts cmd := &cobra.Command{ Use: "ls", Short: "list available volumes in context.", @@ -43,24 +50,30 @@ func listVolume() *cobra.Command { if err != nil { return err } - printList(os.Stdout, vols) - return nil + return printList(opts.format, os.Stdout, vols) }, } + cmd.Flags().StringVar(&opts.format, "format", formatter.PRETTY, "Format the output. Values: [pretty | json]. (Default: pretty)") return cmd } -func printList(out io.Writer, volumes []volumes.Volume) { - printSection(out, func(w io.Writer) { - for _, vol := range volumes { - _, _ = fmt.Fprintf(w, "%s\t%s\n", vol.ID, vol.Description) +func printList(format string, out io.Writer, volumes []volumes.Volume) error { + var err error + switch strings.ToLower(format) { + case formatter.PRETTY, "": + _ = formatter.PrintPrettySection(out, func(w io.Writer) { + for _, vol := range volumes { + _, _ = fmt.Fprintf(w, "%s\t%s\n", vol.ID, vol.Description) + } + }, "ID", "DESCRIPTION") + case formatter.JSON: + outJSON, err := formatter.ToStandardJSON(volumes) + if err != nil { + return err } - }, "ID", "DESCRIPTION") -} - -func printSection(out io.Writer, printer func(io.Writer), headers ...string) { - w := tabwriter.NewWriter(out, 20, 1, 3, ' ', 0) - _, _ = fmt.Fprintln(w, strings.Join(headers, "\t")) - printer(w) - _ = w.Flush() + _, _ = fmt.Fprint(out, outJSON) + default: + err = errors.Wrapf(errdefs.ErrParsingFailed, "format value %q could not be parsed", format) + } + return err } diff --git a/cli/cmd/volume/list_test.go b/cli/cmd/volume/list_test.go index f88716e90..093a4339e 100644 --- a/cli/cmd/volume/list_test.go +++ b/cli/cmd/volume/list_test.go @@ -20,9 +20,11 @@ import ( "bytes" "testing" + "gotest.tools/assert" "gotest.tools/v3/golden" "github.com/docker/compose-cli/api/volumes" + "github.com/docker/compose-cli/formatter" ) func TestPrintList(t *testing.T) { @@ -33,6 +35,11 @@ func TestPrintList(t *testing.T) { }, } out := &bytes.Buffer{} - printList(out, secrets) + assert.NilError(t, printList(formatter.PRETTY, out, secrets)) golden.Assert(t, out.String(), "volumes-out.golden") + + out.Reset() + assert.NilError(t, printList(formatter.JSON, out, secrets)) + golden.Assert(t, out.String(), "volumes-out-json.golden") + } diff --git a/cli/cmd/volume/testdata/volumes-out-json.golden b/cli/cmd/volume/testdata/volumes-out-json.golden new file mode 100644 index 000000000..480be0351 --- /dev/null +++ b/cli/cmd/volume/testdata/volumes-out-json.golden @@ -0,0 +1,6 @@ +[ + { + "ID": "volume/123", + "Description": "volume 123" + } +] \ No newline at end of file diff --git a/cli/mobycli/exec.go b/cli/mobycli/exec.go index 665de45b2..5b89cd1c3 100644 --- a/cli/mobycli/exec.go +++ b/cli/mobycli/exec.go @@ -112,7 +112,10 @@ func IsDefaultContextCommand(dockerCommand string) bool { } // ExecSilent executes a command and do redirect output to stdOut, return output -func ExecSilent(ctx context.Context) ([]byte, error) { - cmd := exec.CommandContext(ctx, ComDockerCli, os.Args[1:]...) +func ExecSilent(ctx context.Context, args ...string) ([]byte, error) { + if len(args) == 0 { + args = os.Args[1:] + } + cmd := exec.CommandContext(ctx, ComDockerCli, args...) return cmd.CombinedOutput() } diff --git a/formatter/consts.go b/formatter/consts.go new file mode 100644 index 000000000..1ca9ebcdd --- /dev/null +++ b/formatter/consts.go @@ -0,0 +1,24 @@ +/* + 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 formatter + +const ( + // JSON is the constant for Json formats on list commands + JSON = "json" + // PRETTY is the constant for default formats on list commands + PRETTY = "pretty" +) diff --git a/formatter/pretty.go b/formatter/pretty.go new file mode 100644 index 000000000..9f014fe93 --- /dev/null +++ b/formatter/pretty.go @@ -0,0 +1,32 @@ +/* + 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 formatter + +import ( + "fmt" + "io" + "strings" + "text/tabwriter" +) + +// PrintPrettySection prints a tabbed section on the writer parameter +func PrintPrettySection(out io.Writer, printer func(writer io.Writer), headers ...string) error { + w := tabwriter.NewWriter(out, 20, 1, 3, ' ', 0) + fmt.Fprintln(w, strings.Join(headers, "\t")) + printer(w) + return w.Flush() +} diff --git a/go.mod b/go.mod index f0cf60a46..aef8bdfa4 100644 --- a/go.mod +++ b/go.mod @@ -62,5 +62,6 @@ require ( google.golang.org/protobuf v1.25.0 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/ini.v1 v1.61.0 + gotest.tools v2.2.0+incompatible gotest.tools/v3 v3.0.2 ) diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 4b33c8e89..8211a5f8b 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -75,6 +75,12 @@ func TestContextDefault(t *testing.T) { t.Run("ls", func(t *testing.T) { res := c.RunDockerCmd("context", "ls") golden.Assert(t, res.Stdout(), GoldenFile("ls-out-default")) + + res = c.RunDockerCmd("context", "ls", "--format", "pretty") + golden.Assert(t, res.Stdout(), GoldenFile("ls-out-default")) + + res = c.RunDockerCmd("context", "ls", "--format", "json") + golden.Assert(t, res.Stdout(), GoldenFile("ls-out-json")) }) t.Run("inspect", func(t *testing.T) { @@ -407,6 +413,26 @@ func TestVersion(t *testing.T) { res.Assert(t, icmd.Expected{Out: `"Client":`}) }) + t.Run("format cloud integration", func(t *testing.T) { + res := c.RunDockerCmd("version", "-f", "pretty") + res.Assert(t, icmd.Expected{Out: `Cloud integration:`}) + res = c.RunDockerCmd("version", "-f", "") + res.Assert(t, icmd.Expected{Out: `Cloud integration:`}) + + res = c.RunDockerCmd("version", "-f", "json") + res.Assert(t, icmd.Expected{Out: `"CloudIntegration":`}) + res = c.RunDockerCmd("version", "-f", "{{ json . }}") + res.Assert(t, icmd.Expected{Out: `"CloudIntegration":`}) + res = c.RunDockerCmd("version", "--format", "{{json .}}") + res.Assert(t, icmd.Expected{Out: `"CloudIntegration":`}) + res = c.RunDockerCmd("version", "--format", "{{json . }}") + res.Assert(t, icmd.Expected{Out: `"CloudIntegration":`}) + res = c.RunDockerCmd("version", "--format", "{{ json .}}") + res.Assert(t, icmd.Expected{Out: `"CloudIntegration":`}) + res = c.RunDockerCmd("version", "--format", "{{ json . }}") + res.Assert(t, icmd.Expected{Out: `"CloudIntegration":`}) + }) + t.Run("delegate version flag", func(t *testing.T) { c.RunDockerCmd("context", "create", "example", "test-example") c.RunDockerCmd("context", "use", "test-example") @@ -431,6 +457,12 @@ func TestMockBackend(t *testing.T) { t.Run("ps", func(t *testing.T) { res := c.RunDockerCmd("ps") golden.Assert(t, res.Stdout(), "ps-out-example.golden") + + res = c.RunDockerCmd("ps", "--format", "pretty") + golden.Assert(t, res.Stdout(), "ps-out-example.golden") + + res = c.RunDockerCmd("ps", "--format", "json") + golden.Assert(t, res.Stdout(), "ps-out-example-json.golden") }) t.Run("ps quiet", func(t *testing.T) { diff --git a/tests/e2e/testdata/ls-out-json.golden b/tests/e2e/testdata/ls-out-json.golden new file mode 100644 index 000000000..e497ccd68 --- /dev/null +++ b/tests/e2e/testdata/ls-out-json.golden @@ -0,0 +1,16 @@ +[ + { + "Name": "default", + "Metadata": { + "Description": "Current DOCKER_HOST based configuration", + "StackOrchestrator": "swarm", + "Type": "moby" + }, + "Endpoints": { + "docker": { + "Host": "unix:///var/run/docker.sock" + }, + "kubernetes": {} + } + } +] diff --git a/tests/e2e/testdata/ps-out-example-json.golden b/tests/e2e/testdata/ps-out-example-json.golden new file mode 100644 index 000000000..24b8048e5 --- /dev/null +++ b/tests/e2e/testdata/ps-out-example-json.golden @@ -0,0 +1,30 @@ +[ + { + "ID": "id", + "Status": "", + "Image": "nginx", + "Command": "", + "CPUTime": 0, + "CPULimit": 0, + "MemoryUsage": 0, + "MemoryLimit": 0, + "PidsCurrent": 0, + "PidsLimit": 0, + "Platform": "", + "RestartPolicyCondition": "" + }, + { + "ID": "1234", + "Status": "", + "Image": "alpine", + "Command": "", + "CPUTime": 0, + "CPULimit": 0, + "MemoryUsage": 0, + "MemoryLimit": 0, + "PidsCurrent": 0, + "PidsLimit": 0, + "Platform": "", + "RestartPolicyCondition": "" + } +]