code.cestus.io/tools/fabricator@v0.4.3/pkg/cmd/plugin/plugin.go (about) 1 package plugin 2 3 import ( 4 "bytes" 5 "context" 6 "errors" 7 "fmt" 8 "os" 9 "path/filepath" 10 "runtime" 11 "strings" 12 13 "code.cestus.io/libs/buildinfo" 14 "code.cestus.io/tools/fabricator/internal/pkg/util" 15 "code.cestus.io/tools/fabricator/pkg/fabricator" 16 "github.com/spf13/cobra" 17 "github.com/spf13/pflag" 18 ) 19 20 var ( 21 pluginLong = ` 22 Provides utilities for interacting with plugins. 23 Plugins provide extended functionality that is not part of the fabricator base feature set` 24 25 pluginListLong = ` 26 List all available plugin files on a user's PATH. 27 28 Available plugin files are those that are: 29 - executable 30 - anywhere on the user's PATH (or in the ) 31 - begin with "fabricator-"` 32 33 ValidPluginFilenamePrefixes = []string{"fabricator"} 34 ) 35 36 // PluginHandler is capable of parsing command line arguments 37 // and performing executable filename lookups to search 38 // for valid plugin files, and execute found plugins. 39 type PluginHandler interface { 40 // exists at the given filename, or a boolean false. 41 // Lookup will iterate over a list of given prefixes 42 // in order to recognize valid plugin filenames. 43 // The first filepath to match a prefix is returned. 44 Lookup(ctx context.Context, filename string, paths []string) (string, bool) 45 // Execute receives an executable's filepath, a slice 46 // of arguments, and a slice of environment variables 47 // to relay to the executable. 48 Execute(ctx context.Context, executablePath string, cmdArgs []string, environment fabricator.Environment) error 49 } 50 51 func NewCmdPlugin(streams fabricator.IOStreams, flagparser fabricator.FlagParser) *cobra.Command { 52 cmd := &cobra.Command{ 53 Use: "plugin [flags]", 54 DisableFlagsInUseLine: true, 55 Short: "Provides utilities for interacting with plugins.", 56 Long: pluginLong, 57 Run: func(cmd *cobra.Command, args []string) { 58 util.DefaultSubCommandRun(streams.ErrOut)(cmd, args) 59 }, 60 } 61 62 cmd.AddCommand(NewCmdPluginList(streams, flagparser)) 63 return cmd 64 } 65 66 type Options struct { 67 fabricator.RootOptions 68 fabricator.IOStreams 69 Verifier PathVerifier 70 NameOnly bool 71 72 PluginPaths []string 73 } 74 75 // NewOptions returns initialized Options 76 func NewOptions(ioStreams fabricator.IOStreams, flagset *pflag.FlagSet, flagparser fabricator.FlagParser) *Options { 77 o := Options{ 78 IOStreams: ioStreams, 79 } 80 o.RootOptions.FlagParser = flagparser 81 o.RootOptions.RegisterOptions(flagset) 82 return &o 83 } 84 85 // NewCmdPluginList provides a way to list all plugin executables visible to fabricator 86 func NewCmdPluginList(streams fabricator.IOStreams, flagparser fabricator.FlagParser) *cobra.Command { 87 88 cmd := &cobra.Command{ 89 Use: "list", 90 Short: "list all visible plugin executables on a user's PATH", 91 Long: pluginListLong, 92 } 93 o := NewOptions(streams, cmd.Flags(), flagparser) 94 cmd.Run = func(cmd *cobra.Command, args []string) { 95 util.CheckErr(o.Complete(cmd)) 96 util.CheckErr(o.Run()) 97 } 98 cmd.Flags().BoolVar(&o.NameOnly, "name-only", o.NameOnly, "If true, display only the binary name of each plugin, rather than its full path") 99 return cmd 100 } 101 102 func (o *Options) Complete(cmd *cobra.Command) error { 103 err := o.FlagParser(cmd) 104 if err != nil { 105 return err 106 } 107 o.Verifier = &CommandOverrideVerifier{ 108 root: cmd.Root(), 109 seenPlugins: make(map[string]string), 110 } 111 112 o.PluginPaths = filepath.SplitList(o.PluginPath) 113 o.PluginPaths = append(o.PluginPaths, filepath.SplitList(os.Getenv("PATH"))...) 114 return nil 115 } 116 117 func (o *Options) Run() error { 118 pluginsFound := false 119 isFirstFile := true 120 pluginErrors := []error{} 121 pluginWarnings := 0 122 123 for _, dir := range uniquePathsList(o.PluginPaths) { 124 if len(strings.TrimSpace(dir)) == 0 { 125 continue 126 } 127 128 files, err := os.ReadDir(dir) 129 if err != nil { 130 if _, ok := err.(*os.PathError); ok { 131 fmt.Fprintf(o.ErrOut, "Unable to read directory %q from your PATH: %v. Skipping...\n", dir, err) 132 continue 133 } 134 135 pluginErrors = append(pluginErrors, fmt.Errorf("error: unable to read directory %q in your PATH: %v", dir, err)) 136 continue 137 } 138 139 for _, f := range files { 140 if f.IsDir() { 141 continue 142 } 143 if !hasValidPrefix(f.Name(), ValidPluginFilenamePrefixes) { 144 continue 145 } 146 if runtime.GOOS != "windows" { 147 // filter out windows executables 148 fileExt := strings.ToLower(filepath.Ext(f.Name())) 149 150 switch fileExt { 151 case ".bat", ".cmd", ".com", ".exe", ".ps1": 152 continue 153 } 154 } 155 156 if isFirstFile { 157 fmt.Fprintf(o.Out, "The following compatible plugins are available:\n\n") 158 pluginsFound = true 159 isFirstFile = false 160 } 161 162 pluginPath := f.Name() 163 if !o.NameOnly { 164 pluginPath = filepath.Join(dir, pluginPath) 165 } 166 167 fmt.Fprintf(o.Out, "%s\n", pluginPath) 168 if errs := o.Verifier.Verify(filepath.Join(dir, f.Name())); len(errs) != 0 { 169 for _, err := range errs { 170 fmt.Fprintf(o.ErrOut, " - %s\n", err) 171 pluginWarnings++ 172 } 173 } 174 } 175 } 176 177 if !pluginsFound { 178 pluginErrors = append(pluginErrors, fmt.Errorf("error: unable to find any fabricator plugins in your PATH")) 179 } 180 181 if pluginWarnings > 0 { 182 if pluginWarnings == 1 { 183 pluginErrors = append(pluginErrors, fmt.Errorf("error: one plugin warning was found")) 184 } else { 185 pluginErrors = append(pluginErrors, fmt.Errorf("error: %v plugin warnings were found", pluginWarnings)) 186 } 187 } 188 if len(pluginErrors) > 0 { 189 errs := bytes.NewBuffer(nil) 190 for _, e := range pluginErrors { 191 fmt.Fprintln(errs, e) 192 } 193 return fmt.Errorf("%s", errs.String()) 194 } 195 196 return nil 197 } 198 199 // pathVerifier receives a path and determines if it is valid or not 200 type PathVerifier interface { 201 // Verify determines if a given path is valid 202 Verify(path string) []error 203 } 204 205 type CommandOverrideVerifier struct { 206 root *cobra.Command 207 seenPlugins map[string]string 208 } 209 210 // Verify implements PathVerifier and determines if a given path 211 // is valid depending on whether or not it overwrites an existing 212 // fabricator command path, or a previously seen plugin. 213 func (v *CommandOverrideVerifier) Verify(path string) []error { 214 if v.root == nil { 215 return []error{fmt.Errorf("unable to verify path with nil root")} 216 } 217 218 // extract the plugin binary name 219 segs := strings.Split(path, "/") 220 binName := segs[len(segs)-1] 221 222 cmdPath := strings.Split(binName, "-") 223 if len(cmdPath) > 1 { 224 // the first argument is always "fabricator" for a plugin binary 225 cmdPath = cmdPath[1:] 226 } 227 228 errors := []error{} 229 230 if isExec, err := isExecutable(path); err == nil && !isExec { 231 errors = append(errors, fmt.Errorf("warning: %s identified as a fabricator plugin, but it is not executable", path)) 232 } else if err != nil { 233 errors = append(errors, fmt.Errorf("error: unable to identify %s as an executable file: %v", path, err)) 234 } 235 236 if existingPath, ok := v.seenPlugins[binName]; ok { 237 errors = append(errors, fmt.Errorf("warning: %s is overshadowed by a similarly named plugin: %s", path, existingPath)) 238 } else { 239 v.seenPlugins[binName] = path 240 } 241 242 if cmd, _, err := v.root.Find(cmdPath); err == nil { 243 errors = append(errors, fmt.Errorf("warning: %s overwrites existing command: %q", binName, cmd.CommandPath())) 244 } 245 246 return errors 247 } 248 249 func isExecutable(fullPath string) (bool, error) { 250 info, err := os.Stat(fullPath) 251 if err != nil { 252 return false, err 253 } 254 255 if runtime.GOOS == "windows" { 256 fileExt := strings.ToLower(filepath.Ext(fullPath)) 257 258 switch fileExt { 259 case ".bat", ".cmd", ".com", ".exe", ".ps1": 260 return true, nil 261 } 262 return false, nil 263 } 264 265 if m := info.Mode(); !m.IsDir() && m&0111 != 0 { 266 return true, nil 267 } 268 269 return false, nil 270 } 271 272 // uniquePathsList deduplicates a given slice of strings without 273 // sorting or otherwise altering its order in any way. 274 func uniquePathsList(paths []string) []string { 275 seen := map[string]bool{} 276 newPaths := []string{} 277 for _, p := range paths { 278 if seen[p] { 279 continue 280 } 281 seen[p] = true 282 newPaths = append(newPaths, p) 283 } 284 return newPaths 285 } 286 287 func hasValidPrefix(filepath string, validPrefixes []string) bool { 288 for _, prefix := range validPrefixes { 289 if !strings.HasPrefix(filepath, prefix+"-") { 290 continue 291 } 292 return true 293 } 294 return false 295 } 296 297 func NewPluginWrapper(ctx context.Context, streams fabricator.IOStreams, handler PluginHandler, flagparser fabricator.FlagParser, pluginPathPieces []string) *cobra.Command { 298 cmd := &cobra.Command{ 299 DisableFlagsInUseLine: true, 300 DisableFlagParsing: true, 301 Short: "execute plugin", 302 Long: "execute plugin", 303 } 304 // This cannot use the standard way of loading options, because we are trying to execute a plugin. But we will need to read the standard options to see if we have to use a plugin path. 305 o := NewOptions(streams, cmd.Flags(), flagparser) 306 // Most of the time FlagParser will complain about additional flags (since it cannot know what the plugin needs) so we ignore it here. The plugin is responsible to process the flags 307 o.FlagParser(cmd) 308 309 o.PluginPaths = filepath.SplitList(o.PluginPath) 310 o.PluginPaths = append(o.PluginPaths, filepath.SplitList(os.Getenv("PATH"))...) 311 fun, name, err := pluginCommandHandler(ctx, handler, pluginPathPieces, o.PluginPaths) 312 cmd.RunE = func(cmd *cobra.Command, args []string) error { 313 if err != nil { 314 return err 315 } 316 return fun() 317 } 318 cmd.Use = name 319 return cmd 320 } 321 322 func pluginCommandHandler(ctx context.Context, pluginHandler PluginHandler, cmdArgs []string, paths []string) (func() error, string, error) { 323 var remainingArgs []string // all "non-flag" arguments 324 name := "" 325 if len(cmdArgs) > 0 { 326 name = cmdArgs[0] 327 } 328 for _, arg := range cmdArgs { 329 if strings.HasPrefix(arg, "-") { 330 break 331 } 332 remainingArgs = append(remainingArgs, strings.Replace(arg, "-", "_", -1)) 333 } 334 335 if len(remainingArgs) == 0 { 336 // the length of cmdArgs is at least 1 337 err := fmt.Errorf("flags cannot be placed before plugin name: %s", cmdArgs[0]) 338 return func() error { return err }, name, err 339 } 340 341 foundBinaryPath := "" 342 343 // attempt to find binary, starting at longest possible name with given cmdArgs 344 for len(remainingArgs) > 0 { 345 tentativeName := strings.Join(remainingArgs, "-") 346 // try extended name with GOOS-ARCH first so developpers can work with their locally build plugins 347 path, found := pluginHandler.Lookup(ctx, strings.Join([]string{tentativeName, buildinfo.ProvideBuildInfo().OS, buildinfo.ProvideBuildInfo().Platform}, "-"), paths) 348 349 if !found { 350 path, found = pluginHandler.Lookup(ctx, tentativeName, paths) 351 } 352 if !found { 353 remainingArgs = remainingArgs[:len(remainingArgs)-1] 354 continue 355 } 356 357 foundBinaryPath = path 358 break 359 } 360 361 if len(foundBinaryPath) == 0 { 362 err := errors.New("no plugin found") 363 return func() error { return err }, name, err 364 } 365 366 exec := func() error { 367 // invoke cmd binary relaying the current environment and args given 368 if err := pluginHandler.Execute(ctx, foundBinaryPath, cmdArgs[len(remainingArgs):], make(fabricator.Environment)); err != nil { 369 return err 370 } 371 372 return nil 373 } 374 375 return exec, name, nil 376 } 377 378 // HandlePluginCommand receives a pluginHandler and command-line arguments and attempts to find 379 // a plugin executable on the PATH that satisfies the given arguments. 380 func HandlePluginCommand(ctx context.Context, pluginHandler PluginHandler, cmdArgs []string, paths []string) error { 381 var remainingArgs []string // all "non-flag" arguments 382 for _, arg := range cmdArgs { 383 if strings.HasPrefix(arg, "-") { 384 break 385 } 386 remainingArgs = append(remainingArgs, strings.Replace(arg, "-", "_", -1)) 387 } 388 389 if len(remainingArgs) == 0 { 390 // the length of cmdArgs is at least 1 391 return fmt.Errorf("flags cannot be placed before plugin name: %s", cmdArgs[0]) 392 } 393 394 foundBinaryPath := "" 395 396 // attempt to find binary, starting at longest possible name with given cmdArgs 397 for len(remainingArgs) > 0 { 398 path, found := pluginHandler.Lookup(ctx, strings.Join(remainingArgs, "-"), paths) 399 if !found { 400 remainingArgs = remainingArgs[:len(remainingArgs)-1] 401 continue 402 } 403 404 foundBinaryPath = path 405 break 406 } 407 408 if len(foundBinaryPath) == 0 { 409 return nil 410 } 411 412 // invoke cmd binary relaying the current environment and args given 413 if err := pluginHandler.Execute(ctx, foundBinaryPath, cmdArgs[len(remainingArgs):], make(fabricator.Environment)); err != nil { 414 return err 415 } 416 417 return nil 418 }