github.com/thajeztah/cli@v0.0.0-20240223162942-dc6bfac81a8b/cli-plugins/manager/manager.go (about) 1 package manager 2 3 import ( 4 "context" 5 "os" 6 "os/exec" 7 "path/filepath" 8 "sort" 9 "strings" 10 "sync" 11 12 "github.com/docker/cli/cli/command" 13 "github.com/docker/cli/cli/config" 14 "github.com/docker/cli/cli/config/configfile" 15 "github.com/fvbommel/sortorder" 16 "github.com/spf13/cobra" 17 "golang.org/x/sync/errgroup" 18 ) 19 20 // ReexecEnvvar is the name of an ennvar which is set to the command 21 // used to originally invoke the docker CLI when executing a 22 // plugin. Assuming $PATH and $CWD remain unchanged this should allow 23 // the plugin to re-execute the original CLI. 24 const ReexecEnvvar = "DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND" 25 26 // errPluginNotFound is the error returned when a plugin could not be found. 27 type errPluginNotFound string 28 29 func (e errPluginNotFound) NotFound() {} 30 31 func (e errPluginNotFound) Error() string { 32 return "Error: No such CLI plugin: " + string(e) 33 } 34 35 type notFound interface{ NotFound() } 36 37 // IsNotFound is true if the given error is due to a plugin not being found. 38 func IsNotFound(err error) bool { 39 if e, ok := err.(*pluginError); ok { 40 err = e.Cause() 41 } 42 _, ok := err.(notFound) 43 return ok 44 } 45 46 func getPluginDirs(cfg *configfile.ConfigFile) ([]string, error) { 47 var pluginDirs []string 48 49 if cfg != nil { 50 pluginDirs = append(pluginDirs, cfg.CLIPluginsExtraDirs...) 51 } 52 pluginDir, err := config.Path("cli-plugins") 53 if err != nil { 54 return nil, err 55 } 56 57 pluginDirs = append(pluginDirs, pluginDir) 58 pluginDirs = append(pluginDirs, defaultSystemPluginDirs...) 59 return pluginDirs, nil 60 } 61 62 func addPluginCandidatesFromDir(res map[string][]string, d string) error { 63 dentries, err := os.ReadDir(d) 64 if err != nil { 65 return err 66 } 67 for _, dentry := range dentries { 68 switch dentry.Type() & os.ModeType { 69 case 0, os.ModeSymlink: 70 // Regular file or symlink, keep going 71 default: 72 // Something else, ignore. 73 continue 74 } 75 name := dentry.Name() 76 if !strings.HasPrefix(name, NamePrefix) { 77 continue 78 } 79 name = strings.TrimPrefix(name, NamePrefix) 80 var err error 81 if name, err = trimExeSuffix(name); err != nil { 82 continue 83 } 84 res[name] = append(res[name], filepath.Join(d, dentry.Name())) 85 } 86 return nil 87 } 88 89 // listPluginCandidates returns a map from plugin name to the list of (unvalidated) Candidates. The list is in descending order of priority. 90 func listPluginCandidates(dirs []string) (map[string][]string, error) { 91 result := make(map[string][]string) 92 for _, d := range dirs { 93 // Silently ignore any directories which we cannot 94 // Stat (e.g. due to permissions or anything else) or 95 // which is not a directory. 96 if fi, err := os.Stat(d); err != nil || !fi.IsDir() { 97 continue 98 } 99 if err := addPluginCandidatesFromDir(result, d); err != nil { 100 // Silently ignore paths which don't exist. 101 if os.IsNotExist(err) { 102 continue 103 } 104 return nil, err // Or return partial result? 105 } 106 } 107 return result, nil 108 } 109 110 // GetPlugin returns a plugin on the system by its name 111 func GetPlugin(name string, dockerCli command.Cli, rootcmd *cobra.Command) (*Plugin, error) { 112 pluginDirs, err := getPluginDirs(dockerCli.ConfigFile()) 113 if err != nil { 114 return nil, err 115 } 116 117 candidates, err := listPluginCandidates(pluginDirs) 118 if err != nil { 119 return nil, err 120 } 121 122 if paths, ok := candidates[name]; ok { 123 if len(paths) == 0 { 124 return nil, errPluginNotFound(name) 125 } 126 c := &candidate{paths[0]} 127 p, err := newPlugin(c, rootcmd.Commands()) 128 if err != nil { 129 return nil, err 130 } 131 if !IsNotFound(p.Err) { 132 p.ShadowedPaths = paths[1:] 133 } 134 return &p, nil 135 } 136 137 return nil, errPluginNotFound(name) 138 } 139 140 // ListPlugins produces a list of the plugins available on the system 141 func ListPlugins(dockerCli command.Cli, rootcmd *cobra.Command) ([]Plugin, error) { 142 pluginDirs, err := getPluginDirs(dockerCli.ConfigFile()) 143 if err != nil { 144 return nil, err 145 } 146 147 candidates, err := listPluginCandidates(pluginDirs) 148 if err != nil { 149 return nil, err 150 } 151 152 var plugins []Plugin 153 var mu sync.Mutex 154 eg, _ := errgroup.WithContext(context.TODO()) 155 cmds := rootcmd.Commands() 156 for _, paths := range candidates { 157 func(paths []string) { 158 eg.Go(func() error { 159 if len(paths) == 0 { 160 return nil 161 } 162 c := &candidate{paths[0]} 163 p, err := newPlugin(c, cmds) 164 if err != nil { 165 return err 166 } 167 if !IsNotFound(p.Err) { 168 p.ShadowedPaths = paths[1:] 169 mu.Lock() 170 defer mu.Unlock() 171 plugins = append(plugins, p) 172 } 173 return nil 174 }) 175 }(paths) 176 } 177 if err := eg.Wait(); err != nil { 178 return nil, err 179 } 180 181 sort.Slice(plugins, func(i, j int) bool { 182 return sortorder.NaturalLess(plugins[i].Name, plugins[j].Name) 183 }) 184 185 return plugins, nil 186 } 187 188 // PluginRunCommand returns an "os/exec".Cmd which when .Run() will execute the named plugin. 189 // The rootcmd argument is referenced to determine the set of builtin commands in order to detect conficts. 190 // The error returned satisfies the IsNotFound() predicate if no plugin was found or if the first candidate plugin was invalid somehow. 191 func PluginRunCommand(dockerCli command.Cli, name string, rootcmd *cobra.Command) (*exec.Cmd, error) { 192 // This uses the full original args, not the args which may 193 // have been provided by cobra to our caller. This is because 194 // they lack e.g. global options which we must propagate here. 195 args := os.Args[1:] 196 if !pluginNameRe.MatchString(name) { 197 // We treat this as "not found" so that callers will 198 // fallback to their "invalid" command path. 199 return nil, errPluginNotFound(name) 200 } 201 exename := addExeSuffix(NamePrefix + name) 202 pluginDirs, err := getPluginDirs(dockerCli.ConfigFile()) 203 if err != nil { 204 return nil, err 205 } 206 207 for _, d := range pluginDirs { 208 path := filepath.Join(d, exename) 209 210 // We stat here rather than letting the exec tell us 211 // ENOENT because the latter does not distinguish a 212 // file not existing from its dynamic loader or one of 213 // its libraries not existing. 214 if _, err := os.Stat(path); os.IsNotExist(err) { 215 continue 216 } 217 218 c := &candidate{path: path} 219 plugin, err := newPlugin(c, rootcmd.Commands()) 220 if err != nil { 221 return nil, err 222 } 223 if plugin.Err != nil { 224 // TODO: why are we not returning plugin.Err? 225 return nil, errPluginNotFound(name) 226 } 227 cmd := exec.Command(plugin.Path, args...) 228 // Using dockerCli.{In,Out,Err}() here results in a hang until something is input. 229 // See: - https://github.com/golang/go/issues/10338 230 // - https://github.com/golang/go/commit/d000e8742a173aa0659584aa01b7ba2834ba28ab 231 // os.Stdin is a *os.File which avoids this behaviour. We don't need the functionality 232 // of the wrappers here anyway. 233 cmd.Stdin = os.Stdin 234 cmd.Stdout = os.Stdout 235 cmd.Stderr = os.Stderr 236 237 cmd.Env = os.Environ() 238 cmd.Env = append(cmd.Env, ReexecEnvvar+"="+os.Args[0]) 239 240 return cmd, nil 241 } 242 return nil, errPluginNotFound(name) 243 } 244 245 // IsPluginCommand checks if the given cmd is a plugin-stub. 246 func IsPluginCommand(cmd *cobra.Command) bool { 247 return cmd.Annotations[CommandAnnotationPlugin] == "true" 248 }