diff --git a/Makefile b/Makefile index 6f0ff4b008..aaf5317fe6 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,10 @@ binary: ## build executable for Linux @echo "WARNING: binary creates a Linux executable. Use cross for macOS or Windows." ./scripts/build/binary +.PHONY: plugins +plugins: ## build example CLI plugins + ./scripts/build/plugins + .PHONY: cross cross: ## build executable for macOS and Windows ./scripts/build/cross @@ -42,10 +46,18 @@ cross: ## build executable for macOS and Windows binary-windows: ## build executable for Windows ./scripts/build/windows +.PHONY: plugins-windows +plugins-windows: ## build example CLI plugins for Windows + ./scripts/build/plugins-windows + .PHONY: binary-osx binary-osx: ## build executable for macOS ./scripts/build/osx +.PHONY: plugins-osx +plugins-osx: ## build example CLI plugins for macOS + ./scripts/build/plugins-osx + .PHONY: dynbinary dynbinary: ## build dynamically linked binary ./scripts/build/dynbinary diff --git a/cli-plugins/examples/helloworld/main.go b/cli-plugins/examples/helloworld/main.go new file mode 100644 index 0000000000..a319ee623f --- /dev/null +++ b/cli-plugins/examples/helloworld/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + + "github.com/docker/cli/cli-plugins/manager" + "github.com/docker/cli/cli-plugins/plugin" + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" +) + +func main() { + plugin.Run(func(dockerCli command.Cli) *cobra.Command { + goodbye := &cobra.Command{ + Use: "goodbye", + Short: "Say Goodbye instead of Hello", + Run: func(cmd *cobra.Command, _ []string) { + fmt.Fprintln(dockerCli.Out(), "Goodbye World!") + }, + } + + cmd := &cobra.Command{ + Use: "helloworld", + Short: "A basic Hello World plugin for tests", + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintln(dockerCli.Out(), "Hello World!") + }, + } + + cmd.AddCommand(goodbye) + return cmd + }, + manager.Metadata{ + SchemaVersion: "0.1.0", + Vendor: "Docker Inc.", + Version: "0.1.0", + }) +} diff --git a/cli-plugins/manager/metadata.go b/cli-plugins/manager/metadata.go new file mode 100644 index 0000000000..2d5734e868 --- /dev/null +++ b/cli-plugins/manager/metadata.go @@ -0,0 +1,25 @@ +package manager + +const ( + // NamePrefix is the prefix required on all plugin binary names + NamePrefix = "docker-" + + // MetadataSubcommandName is the name of the plugin subcommand + // which must be supported by every plugin and returns the + // plugin metadata. + MetadataSubcommandName = "docker-cli-plugin-metadata" +) + +// Metadata provided by the plugin +type Metadata struct { + // SchemaVersion describes the version of this struct. Mandatory, must be "0.1.0" + SchemaVersion string + // Vendor is the name of the plugin vendor. Mandatory + Vendor string + // Version is the optional version of this plugin. + Version string + // ShortDescription should be suitable for a single line help message. + ShortDescription string + // URL is a pointer to the plugin's homepage. + URL string +} diff --git a/cli-plugins/plugin/plugin.go b/cli-plugins/plugin/plugin.go new file mode 100644 index 0000000000..ce2bd0bd6f --- /dev/null +++ b/cli-plugins/plugin/plugin.go @@ -0,0 +1,96 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli-plugins/manager" + "github.com/docker/cli/cli/command" + cliflags "github.com/docker/cli/cli/flags" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Run is the top-level entry point to the CLI plugin framework. It should be called from your plugin's `main()` function. +func Run(makeCmd func(command.Cli) *cobra.Command, meta manager.Metadata) { + dockerCli, err := command.NewDockerCli() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + plugin := makeCmd(dockerCli) + + cmd := newPluginCommand(dockerCli, plugin, meta) + + if err := cmd.Execute(); err != nil { + if sterr, ok := err.(cli.StatusError); ok { + if sterr.Status != "" { + fmt.Fprintln(dockerCli.Err(), sterr.Status) + } + // StatusError should only be used for errors, and all errors should + // have a non-zero exit status, so never exit with 0 + if sterr.StatusCode == 0 { + os.Exit(1) + } + os.Exit(sterr.StatusCode) + } + fmt.Fprintln(dockerCli.Err(), err) + os.Exit(1) + } +} + +func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager.Metadata) *cobra.Command { + var ( + opts *cliflags.ClientOptions + flags *pflag.FlagSet + ) + + name := plugin.Use + fullname := manager.NamePrefix + name + + cmd := &cobra.Command{ + Use: fmt.Sprintf("docker [OPTIONS] %s [ARG...]", name), + Short: fullname + " is a Docker CLI plugin", + SilenceUsage: true, + SilenceErrors: true, + TraverseChildren: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // flags must be the top-level command flags, not cmd.Flags() + opts.Common.SetDefaultOptions(flags) + return dockerCli.Initialize(opts) + }, + DisableFlagsInUseLine: true, + } + opts, flags = cli.SetupPluginRootCommand(cmd) + + cmd.SetOutput(dockerCli.Out()) + + cmd.AddCommand( + plugin, + newMetadataSubcommand(plugin, meta), + ) + + cli.DisableFlagsInUseLine(cmd) + + return cmd +} + +func newMetadataSubcommand(plugin *cobra.Command, meta manager.Metadata) *cobra.Command { + if meta.ShortDescription == "" { + meta.ShortDescription = plugin.Short + } + cmd := &cobra.Command{ + Use: manager.MetadataSubcommandName, + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + return enc.Encode(meta) + }, + } + return cmd +} diff --git a/cli/cobra.go b/cli/cobra.go index ce4284a0fd..8acce94783 100644 --- a/cli/cobra.go +++ b/cli/cobra.go @@ -12,6 +12,8 @@ import ( "github.com/spf13/pflag" ) +// setupCommonRootCommand contains the setup common to +// SetupRootCommand and SetupPluginRootCommand. func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet) { opts := cliflags.NewClientOptions() flags := rootCmd.Flags() @@ -47,6 +49,16 @@ func SetupRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.F return opts, flags } +// SetupPluginRootCommand sets default usage, help and error handling for a plugin root command. +func SetupPluginRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *pflag.FlagSet) { + opts, flags := setupCommonRootCommand(rootCmd) + + rootCmd.PersistentFlags().BoolP("help", "", false, "Print usage") + rootCmd.PersistentFlags().Lookup("help").Hidden = true + + return opts, flags +} + // FlagErrorFunc prints an error message which matches the format of the // docker/cli/cli error messages func FlagErrorFunc(cmd *cobra.Command, err error) error { diff --git a/docker.Makefile b/docker.Makefile index 19bbe02b6b..6de7683c64 100644 --- a/docker.Makefile +++ b/docker.Makefile @@ -56,6 +56,9 @@ binary: build_binary_native_image ## build the CLI build: binary ## alias for binary +plugins: build_binary_native_image ## build the CLI plugin examples + docker run --rm $(ENVVARS) $(MOUNTS) $(BINARY_NATIVE_IMAGE_NAME) ./scripts/build/plugins + .PHONY: clean clean: build_docker_image ## clean build artifacts docker run --rm $(ENVVARS) $(MOUNTS) $(DEV_DOCKER_IMAGE_NAME) make clean @@ -76,10 +79,18 @@ cross: build_cross_image ## build the CLI for macOS and Windows binary-windows: build_cross_image ## build the CLI for Windows docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ +.PHONY: plugins-windows +plugins-windows: build_cross_image ## build the example CLI plugins for Windows + docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ + .PHONY: binary-osx binary-osx: build_cross_image ## build the CLI for macOS docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ +.PHONY: plugins-osx +plugins-osx: build_cross_image ## build the example CLI plugins for macOS + docker run --rm $(ENVVARS) $(MOUNTS) $(CROSS_IMAGE_NAME) make $@ + .PHONY: dev dev: build_docker_image ## start a build container in interactive mode for in-container development docker run -ti --rm $(ENVVARS) $(MOUNTS) \ diff --git a/dockerfiles/Dockerfile.e2e b/dockerfiles/Dockerfile.e2e index 68368c0970..b2f57e0a10 100644 --- a/dockerfiles/Dockerfile.e2e +++ b/dockerfiles/Dockerfile.e2e @@ -38,5 +38,6 @@ ARG VERSION ARG GITCOMMIT ENV VERSION=${VERSION} GITCOMMIT=${GITCOMMIT} RUN ./scripts/build/binary +RUN ./scripts/build/plugins CMD ./scripts/test/e2e/entry diff --git a/scripts/build/plugins b/scripts/build/plugins new file mode 100755 index 0000000000..bc14748b92 --- /dev/null +++ b/scripts/build/plugins @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# +# Build a static binary for the host OS/ARCH +# + +set -eu -o pipefail + +source ./scripts/build/.variables + +mkdir -p "build/plugins-${GOOS}-${GOARCH}" +for p in cli-plugins/examples/* ; do + [ -d "$p" ] || continue + + n=$(basename "$p") + + TARGET="build/plugins-${GOOS}-${GOARCH}/docker-${n}" + + echo "Building statically linked $TARGET" + export CGO_ENABLED=0 + go build -o "${TARGET}" --ldflags "${LDFLAGS}" "github.com/docker/cli/${p}" +done diff --git a/scripts/build/plugins-osx b/scripts/build/plugins-osx new file mode 100755 index 0000000000..8e870f4f05 --- /dev/null +++ b/scripts/build/plugins-osx @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# +# Build a static binary for the host OS/ARCH +# + +set -eu -o pipefail + +source ./scripts/build/.variables + +export CGO_ENABLED=1 +export GOOS=darwin +export GOARCH=amd64 +export CC=o64-clang +export CXX=o64-clang++ +export LDFLAGS="$LDFLAGS -linkmode external -s" +export LDFLAGS_STATIC_DOCKER='-extld='${CC} + +source ./scripts/build/plugins diff --git a/scripts/build/plugins-windows b/scripts/build/plugins-windows new file mode 100755 index 0000000000..607ad6dc12 --- /dev/null +++ b/scripts/build/plugins-windows @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# Build a static binary for the host OS/ARCH +# + +set -eu -o pipefail + +source ./scripts/build/.variables +export CC=x86_64-w64-mingw32-gcc +export CGO_ENABLED=1 +export GOOS=windows +export GOARCH=amd64 + +source ./scripts/build/plugins