| // FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: | |
| //go:build go1.26 | |
| package manager | |
| import ( | |
| "context" | |
| "errors" | |
| "os" | |
| "os/exec" | |
| "path/filepath" | |
| slices "slices" | |
| "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 | |
| } | |
| slices.SortFunc(plugins, func(a, b Plugin) int { | |
| return sortorder.NaturalCompare(a.Name, b.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 conflicts. | |
| // 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" | |
| } |