Before this patch, a broken symlink would print a warning;
docker info > /dev/null
WARNING: Plugin "/Users/thajeztah/.docker/cli-plugins/docker-feedback" is not valid: failed to fetch metadata: fork/exec /Users/thajeztah/.docker/cli-plugins/docker-feedback: no such file or directory
After this patch, such symlinks are ignored:
docker info > /dev/null
With debug enabled, we don't ignore the faulty plugin, which will
make the warning shown on docker info;
mkdir -p ~/.docker/cli-plugins
ln -s nosuchplugin ~/.docker/cli-plugins/docker-brokenplugin
docker --debug info
Client:
Version: 29.0.0-dev
Context: default
Debug Mode: true
Plugins:
buildx: Docker Buildx (Docker Inc.)
Version: v0.25.0
Path: /usr/libexec/docker/cli-plugins/docker-buildx
WARNING: Plugin "/Users/thajeztah/.docker/cli-plugins/docker-brokenplugin" is not valid: failed to fetch metadata: fork/exec /Users/thajeztah/.docker/cli-plugins/docker-brokenplugin: no such file or directory
# ...
We should als consider passing a "seen" map to de-duplicate entries.
Entries can be either a direct symlink or in a symlinked path (for
which we can filepath.EvalSymlinks). We need to benchmark the overhead
of resolving the symlink vs possibly calling the plugin (to get their
metadata) further down the line.
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
233 lines
7.2 KiB
Go
233 lines
7.2 KiB
Go
package manager
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/containerd/errdefs"
|
|
"github.com/docker/cli/cli-plugins/metadata"
|
|
"github.com/docker/cli/cli/config"
|
|
"github.com/docker/cli/cli/config/configfile"
|
|
"github.com/docker/cli/cli/debug"
|
|
"github.com/fvbommel/sortorder"
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
// errPluginNotFound is the error returned when a plugin could not be found.
|
|
type errPluginNotFound string
|
|
|
|
func (errPluginNotFound) NotFound() {}
|
|
|
|
func (e errPluginNotFound) Error() string {
|
|
return "Error: No such CLI plugin: " + string(e)
|
|
}
|
|
|
|
// getPluginDirs returns the platform-specific locations to search for plugins
|
|
// in order of preference.
|
|
//
|
|
// Plugin-discovery is performed in the following order of preference:
|
|
//
|
|
// 1. The "cli-plugins" directory inside the CLIs [config.Path] (usually "~/.docker/cli-plugins").
|
|
// 2. Additional plugin directories as configured through [ConfigFile.CLIPluginsExtraDirs].
|
|
// 3. Platform-specific defaultSystemPluginDirs.
|
|
//
|
|
// [ConfigFile.CLIPluginsExtraDirs]: https://pkg.go.dev/github.com/docker/cli@v26.1.4+incompatible/cli/config/configfile#ConfigFile.CLIPluginsExtraDirs
|
|
func getPluginDirs(cfg *configfile.ConfigFile) []string {
|
|
var pluginDirs []string
|
|
|
|
if cfg != nil {
|
|
pluginDirs = append(pluginDirs, cfg.CLIPluginsExtraDirs...)
|
|
}
|
|
pluginDir := filepath.Join(config.Dir(), "cli-plugins")
|
|
pluginDirs = append(pluginDirs, pluginDir)
|
|
pluginDirs = append(pluginDirs, defaultSystemPluginDirs...)
|
|
return pluginDirs
|
|
}
|
|
|
|
func addPluginCandidatesFromDir(res map[string][]string, d string) {
|
|
dentries, err := os.ReadDir(d)
|
|
// Silently ignore any directories which we cannot list (e.g. due to
|
|
// permissions or anything else) or which is not a directory
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, dentry := range dentries {
|
|
switch mode := dentry.Type() & os.ModeType; mode { //nolint:exhaustive,nolintlint // no need to include all possible file-modes in this list
|
|
case os.ModeSymlink:
|
|
if !debug.IsEnabled() {
|
|
// Skip broken symlinks unless debug is enabled. With debug
|
|
// enabled, this will print a warning in "docker info".
|
|
if _, err := os.Stat(filepath.Join(d, dentry.Name())); errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
}
|
|
case 0:
|
|
// Regular file, keep going
|
|
default:
|
|
// Something else, ignore.
|
|
continue
|
|
}
|
|
name := dentry.Name()
|
|
if !strings.HasPrefix(name, metadata.NamePrefix) {
|
|
continue
|
|
}
|
|
name = strings.TrimPrefix(name, metadata.NamePrefix)
|
|
var err error
|
|
if name, err = trimExeSuffix(name); err != nil {
|
|
continue
|
|
}
|
|
res[name] = append(res[name], filepath.Join(d, dentry.Name()))
|
|
}
|
|
}
|
|
|
|
// listPluginCandidates returns a map from plugin name to the list of (unvalidated) Candidates. The list is in descending order of priority.
|
|
func listPluginCandidates(dirs []string) map[string][]string {
|
|
result := make(map[string][]string)
|
|
for _, d := range dirs {
|
|
addPluginCandidatesFromDir(result, d)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetPlugin returns a plugin on the system by its name
|
|
func GetPlugin(name string, dockerCLI config.Provider, rootcmd *cobra.Command) (*Plugin, error) {
|
|
pluginDirs := getPluginDirs(dockerCLI.ConfigFile())
|
|
return getPlugin(name, pluginDirs, rootcmd)
|
|
}
|
|
|
|
func getPlugin(name string, pluginDirs []string, rootcmd *cobra.Command) (*Plugin, error) {
|
|
candidates := listPluginCandidates(pluginDirs)
|
|
if paths, ok := candidates[name]; ok {
|
|
if len(paths) == 0 {
|
|
return nil, errPluginNotFound(name)
|
|
}
|
|
c := &candidate{paths[0]}
|
|
p, err := newPlugin(c, rootcmd.Commands())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !errdefs.IsNotFound(p.Err) {
|
|
p.ShadowedPaths = paths[1:]
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
return nil, errPluginNotFound(name)
|
|
}
|
|
|
|
// ListPlugins produces a list of the plugins available on the system
|
|
func ListPlugins(dockerCli config.Provider, rootcmd *cobra.Command) ([]Plugin, error) {
|
|
pluginDirs := getPluginDirs(dockerCli.ConfigFile())
|
|
candidates := listPluginCandidates(pluginDirs)
|
|
if len(candidates) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
var plugins []Plugin
|
|
var mu sync.Mutex
|
|
ctx := rootcmd.Context()
|
|
if ctx == nil {
|
|
// Fallback, mostly for tests that pass a bare cobra.command
|
|
ctx = context.Background()
|
|
}
|
|
eg, _ := errgroup.WithContext(ctx)
|
|
cmds := rootcmd.Commands()
|
|
for _, paths := range candidates {
|
|
func(paths []string) {
|
|
eg.Go(func() error {
|
|
if len(paths) == 0 {
|
|
return nil
|
|
}
|
|
c := &candidate{paths[0]}
|
|
p, err := newPlugin(c, cmds)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !errdefs.IsNotFound(p.Err) {
|
|
p.ShadowedPaths = paths[1:]
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
plugins = append(plugins, p)
|
|
}
|
|
return nil
|
|
})
|
|
}(paths)
|
|
}
|
|
if err := eg.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sort.Slice(plugins, func(i, j int) bool {
|
|
return sortorder.NaturalLess(plugins[i].Name, plugins[j].Name)
|
|
})
|
|
|
|
return plugins, nil
|
|
}
|
|
|
|
// PluginRunCommand returns an [os/exec.Cmd] which when [os/exec.Cmd.Run] will execute the named plugin.
|
|
// The rootcmd argument is referenced to determine the set of builtin commands in order to detect conficts.
|
|
// The error returned satisfies the [errdefs.IsNotFound] predicate if no plugin was found or if the first candidate plugin was invalid somehow.
|
|
func PluginRunCommand(dockerCli config.Provider, name string, rootcmd *cobra.Command) (*exec.Cmd, error) {
|
|
// This uses the full original args, not the args which may
|
|
// have been provided by cobra to our caller. This is because
|
|
// they lack e.g. global options which we must propagate here.
|
|
args := os.Args[1:]
|
|
if !isValidPluginName(name) {
|
|
// We treat this as "not found" so that callers will
|
|
// fallback to their "invalid" command path.
|
|
return nil, errPluginNotFound(name)
|
|
}
|
|
exename := addExeSuffix(metadata.NamePrefix + name)
|
|
pluginDirs := getPluginDirs(dockerCli.ConfigFile())
|
|
|
|
for _, d := range pluginDirs {
|
|
path := filepath.Join(d, exename)
|
|
|
|
// We stat here rather than letting the exec tell us
|
|
// ENOENT because the latter does not distinguish a
|
|
// file not existing from its dynamic loader or one of
|
|
// its libraries not existing.
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
|
|
c := &candidate{path: path}
|
|
plugin, err := newPlugin(c, rootcmd.Commands())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if plugin.Err != nil {
|
|
// TODO: why are we not returning plugin.Err?
|
|
return nil, errPluginNotFound(name)
|
|
}
|
|
cmd := exec.Command(plugin.Path, args...) // #nosec G204 -- ignore "Subprocess launched with a potential tainted input or cmd arguments"
|
|
|
|
// Using dockerCli.{In,Out,Err}() here results in a hang until something is input.
|
|
// See: - https://github.com/golang/go/issues/10338
|
|
// - https://github.com/golang/go/commit/d000e8742a173aa0659584aa01b7ba2834ba28ab
|
|
// os.Stdin is a *os.File which avoids this behaviour. We don't need the functionality
|
|
// of the wrappers here anyway.
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
cmd.Env = append(cmd.Environ(), metadata.ReexecEnvvar+"="+os.Args[0])
|
|
cmd.Env = appendPluginResourceAttributesEnvvar(cmd.Env, rootcmd, plugin)
|
|
|
|
return cmd, nil
|
|
}
|
|
return nil, errPluginNotFound(name)
|
|
}
|
|
|
|
// IsPluginCommand checks if the given cmd is a plugin-stub.
|
|
func IsPluginCommand(cmd *cobra.Command) bool {
|
|
return cmd.Annotations[metadata.CommandAnnotationPlugin] == "true"
|
|
}
|