-
Notifications
You must be signed in to change notification settings - Fork 129
Add filter and foreach sub commands for bulk operations #3027
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 39 commits
85d659a
17e4b39
01a681c
010f1a4
358f9d6
c3fef19
3d84151
9a1c2aa
1336ff2
b588164
53bd4f8
2d827cc
bc7a167
ada16f7
9123dae
423f035
2e3721c
8180ff4
f2abc11
f72ea26
5c4d63e
d94e9b2
3cc0824
78cce2b
2656a22
c36efd7
0649c73
5e3b8b0
df2c85b
b495ced
812530c
e3e3c9a
54fce2b
13f348f
b10bb7f
ab72763
005c2c3
8189930
caaff9a
f997640
a9699e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,9 @@ elastic-package | |
| # IDEA | ||
| .idea | ||
|
|
||
| # VSCode | ||
| .vscode | ||
|
|
||
| # Build directory | ||
| /build | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| // Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
| // or more contributor license agreements. Licensed under the Elastic License; | ||
| // you may not use this file except in compliance with the Elastic License. | ||
|
|
||
| package cmd | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "slices" | ||
|
|
||
| "github.com/spf13/cobra" | ||
|
|
||
| "github.com/elastic/elastic-package/internal/cobraext" | ||
| "github.com/elastic/elastic-package/internal/filter" | ||
| "github.com/elastic/elastic-package/internal/packages" | ||
| ) | ||
|
|
||
| const filterLongDescription = `This command gives you a list of all packages based on the given query` | ||
|
|
||
| func setupFilterCommand() *cobraext.Command { | ||
| cmd := &cobra.Command{ | ||
| Use: "filter [flags]", | ||
| Short: "filter integrations based on given flags", | ||
| Long: filterLongDescription, | ||
| Args: cobra.NoArgs, | ||
| RunE: filterCommandAction, | ||
| } | ||
|
|
||
| // add filter flags to the command (input, code owner, kibana version, categories) | ||
| filter.SetFilterFlags(cmd) | ||
|
|
||
| // add the output package name flag to the command | ||
| cmd.Flags().BoolP(cobraext.FilterOutputPackageNameFlagName, cobraext.FilterOutputPackageNameFlagShorthand, false, cobraext.FilterOutputPackageNameFlagDescription) | ||
|
|
||
| return cobraext.NewCommand(cmd, cobraext.ContextPackage) | ||
| } | ||
|
|
||
| func filterCommandAction(cmd *cobra.Command, args []string) error { | ||
| filtered, err := filterPackage(cmd) | ||
| if err != nil { | ||
| return fmt.Errorf("filtering packages failed: %w", err) | ||
| } | ||
|
|
||
| printPackageName, err := cmd.Flags().GetBool(cobraext.FilterOutputPackageNameFlagName) | ||
| if err != nil { | ||
| return fmt.Errorf("getting output package name flag failed: %w", err) | ||
| } | ||
|
|
||
| if err = printPkgList(filtered, printPackageName, os.Stdout); err != nil { | ||
| return fmt.Errorf("printing JSON failed: %w", err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func filterPackage(cmd *cobra.Command) ([]packages.PackageDirNameAndManifest, error) { | ||
| filters := filter.NewFilterRegistry() | ||
|
|
||
| if err := filters.Parse(cmd); err != nil { | ||
| return nil, fmt.Errorf("parsing filter options failed: %w", err) | ||
| } | ||
|
|
||
| if err := filters.Validate(); err != nil { | ||
| return nil, fmt.Errorf("validating filter options failed: %w", err) | ||
| } | ||
|
|
||
| filtered, errors := filters.Execute() | ||
| if errors != nil { | ||
| return nil, fmt.Errorf("filtering packages failed: %s", errors.Error()) | ||
| } | ||
|
|
||
| return filtered, nil | ||
| } | ||
|
|
||
| func printPkgList(pkgs []packages.PackageDirNameAndManifest, printPackageName bool, w io.Writer) error { | ||
| enc := json.NewEncoder(w) | ||
| enc.SetEscapeHTML(false) | ||
| if len(pkgs) == 0 { | ||
| return nil | ||
| } | ||
|
|
||
| names := make([]string, 0, len(pkgs)) | ||
| if printPackageName { | ||
| for _, pkg := range pkgs { | ||
| names = append(names, pkg.Manifest.Name) | ||
| } | ||
| } else { | ||
| for _, pkg := range pkgs { | ||
| names = append(names, pkg.DirName) | ||
| } | ||
| } | ||
|
|
||
| slices.Sort(names) | ||
| return enc.Encode(names) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| // Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
| // or more contributor license agreements. Licensed under the Elastic License; | ||
| // you may not use this file except in compliance with the Elastic License. | ||
|
|
||
| package cmd | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "slices" | ||
| "strings" | ||
| "sync" | ||
|
|
||
| "github.com/spf13/cobra" | ||
|
|
||
| "github.com/elastic/elastic-package/internal/cobraext" | ||
| "github.com/elastic/elastic-package/internal/filter" | ||
| "github.com/elastic/elastic-package/internal/logger" | ||
| "github.com/elastic/elastic-package/internal/multierror" | ||
| "github.com/elastic/elastic-package/internal/packages" | ||
| ) | ||
|
|
||
| const foreachLongDescription = `Execute a command for each package matching the given filter criteria. | ||
| This command combines filtering capabilities with command execution, allowing you to run | ||
| any elastic-package subcommand across multiple packages in a single operation. | ||
| The command uses the same filter flags as the 'filter' command to select packages, | ||
| then executes the specified subcommand for each matched package.` | ||
|
|
||
| // getAllowedSubCommands returns the list of allowed subcommands for the foreach command. | ||
| func getAllowedSubCommands() []string { | ||
| return []string{ | ||
| "build", | ||
| "check", | ||
| "clean", | ||
| "format", | ||
| "install", | ||
| "lint", | ||
| "test", | ||
| "uninstall", | ||
| } | ||
| } | ||
|
|
||
| func setupForeachCommand() *cobraext.Command { | ||
| cmd := &cobra.Command{ | ||
| Use: "foreach [flags] -- <SUBCOMMAND>", | ||
| Short: "Execute a command for filtered packages", | ||
| Long: foreachLongDescription, | ||
| Example: ` # Run system tests for packages with specific inputs | ||
| elastic-package foreach --input tcp,udp --parallel 10 -- test system -g`, | ||
| RunE: foreachCommandAction, | ||
| Args: cobra.MinimumNArgs(1), | ||
| } | ||
|
|
||
| // Add filter flags | ||
| filter.SetFilterFlags(cmd) | ||
|
|
||
| // Add pool size flag | ||
| cmd.Flags().IntP(cobraext.ForeachPoolSizeFlagName, "", 1, cobraext.ForeachPoolSizeFlagDescription) | ||
|
|
||
| return cobraext.NewCommand(cmd, cobraext.ContextPackage) | ||
| } | ||
|
|
||
| func foreachCommandAction(cmd *cobra.Command, args []string) error { | ||
| poolSize, err := cmd.Flags().GetInt(cobraext.ForeachPoolSizeFlagName) | ||
| if err != nil { | ||
| return fmt.Errorf("getting pool size failed: %w", err) | ||
| } | ||
|
|
||
| if err := validateSubCommand(args[0]); err != nil { | ||
| return fmt.Errorf("validating sub command failed: %w", err) | ||
| } | ||
|
|
||
| // Find integration root | ||
| root, err := packages.MustFindIntegrationRoot() | ||
| if err != nil { | ||
| return fmt.Errorf("can't find integration root: %w", err) | ||
| } | ||
|
|
||
| // reuse filterPackage from cmd/filter.go | ||
| filtered, err := filterPackage(cmd) | ||
| if err != nil { | ||
| return fmt.Errorf("filtering packages failed: %w", err) | ||
| } | ||
|
|
||
| wg := sync.WaitGroup{} | ||
| mu := sync.Mutex{} | ||
| errs := multierror.Error{} | ||
| successes := 0 | ||
|
|
||
| packageChan := make(chan string, poolSize) | ||
|
|
||
| for range poolSize { | ||
| wg.Add(1) | ||
| go func(packageChan <-chan string) { | ||
| defer wg.Done() | ||
| for packageName := range packageChan { | ||
| path := filepath.Join(root, "packages", packageName) | ||
| err := executeCommand(args, path) | ||
|
|
||
| mu.Lock() | ||
| if err != nil { | ||
| errs = append(errs, fmt.Errorf("executing command for package %s failed: %w", packageName, err)) | ||
| } else { | ||
| successes++ | ||
| } | ||
| mu.Unlock() | ||
| } | ||
| }(packageChan) | ||
| } | ||
|
|
||
| for _, pkg := range filtered { | ||
| packageChan <- pkg.DirName | ||
| } | ||
| close(packageChan) | ||
|
|
||
| wg.Wait() | ||
|
|
||
| logger.Infof("Successfully executed command for %d packages\n", successes) | ||
| logger.Infof("Errors occurred while executing command for %d packages\n", len(errs)) | ||
|
|
||
| if errs.Error() != "" { | ||
| return fmt.Errorf("errors occurred while executing command for packages: \n%s", errs.Error()) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func executeCommand(args []string, path string) error { | ||
| // Look up the elastic-package binary in PATH | ||
| execPath, err := exec.LookPath("elastic-package") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as this is a command run on top of other commands of this binary, could we target directly the commands by its functions, instead of looking for the path binary? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it should be possible to do something like this: cmd := cmd.RootCmd()
cmd.SetArgs(args)
return cmd.Execute()We could also create our own command that is like |
||
| if err != nil { | ||
| return fmt.Errorf("elastic-package binary not found in PATH: %w", err) | ||
| } | ||
|
|
||
| cmd := &exec.Cmd{ | ||
| Path: execPath, | ||
| Args: append([]string{execPath}, args...), | ||
| Dir: path, | ||
| Stdout: io.Discard, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why discarding stdout? |
||
| Stderr: os.Stderr, | ||
| Env: os.Environ(), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. when running the command, could we select the variables strictly needed instead of just bulking all the system ones? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This came across my mind at first but I did not go through with it. Let me know if we want to have a list if env variables to limit it. |
||
| } | ||
|
|
||
| if err := cmd.Run(); err != nil { | ||
| return fmt.Errorf("executing command for package %s failed: %w", path, err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func validateSubCommand(subCommand string) error { | ||
| if !slices.Contains(getAllowedSubCommands(), subCommand) { | ||
| return fmt.Errorf("invalid subcommand: %s. Allowed subcommands are: %s", subCommand, strings.Join(getAllowedSubCommands(), ", ")) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -133,8 +133,37 @@ const ( | |||||||||
| FailOnMissingFlagName = "fail-on-missing" | ||||||||||
| FailOnMissingFlagDescription = "fail if tests are missing" | ||||||||||
|
|
||||||||||
| FailFastFlagName = "fail-fast" | ||||||||||
| FailFastFlagDescription = "fail immediately if any file requires updates (do not overwrite)" | ||||||||||
| FailFastFlagName = "fail-fast" | ||||||||||
| FailFastFlagDescription = "fail immediately if any file requires updates (do not overwrite)" | ||||||||||
|
|
||||||||||
| FilterCategoriesFlagName = "category" | ||||||||||
| FilterCategoriesFlagDescription = "integration categories to filter by (comma-separated values)" | ||||||||||
|
|
||||||||||
| FilterCodeOwnerFlagName = "code-owner" | ||||||||||
| FilterCodeOwnerFlagDescription = "code owners to filter by (comma-separated values)" | ||||||||||
|
|
||||||||||
| FilterIntegrationTypeFlagName = "integration-type" | ||||||||||
| FilterIntegrationTypeFlagDescription = "integration types to filter by (comma-separated values)" | ||||||||||
|
||||||||||
| FilterIntegrationTypeFlagName = "integration-type" | |
| FilterIntegrationTypeFlagDescription = "integration types to filter by (comma-separated values)" | |
| FilterPackageTypeFlagName = "package-type" | |
| FilterPackageTypeFlagDescription = "package types to filter by (comma-separated values)" |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can call this flag directly packages, or package-path to avoid confusion between package paths and their names.
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| ForeachPoolSizeFlagDescription = "number of packages to execute in parallel (defaults to serial execution)" | |
| ForeachPoolSizeFlagDescription = "Number of subcommands to execute in parallel (defaults to serial execution)" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In #2327 this functionality was proposed as a subcommand that is only available under
foreach. wdyt about this approach?This could be implemented if as proposed in https://github.com/elastic/elastic-package/pull/3027/files#r2469930805, we create another root command for
foreachwith its own allowed subcommands, that could be a different subset to the ones available in the root command.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The idea behind a separate filter command is that it allows us users pipe its output to run arbitrary scripts for bulk operations which we don't support.
Moreover, both filter and foreach utilizes same code. If we add a new flag in filter it will directly be available to foreach without any code change in foreach. So it's just a way to allow users to fetch a list of integrations for given condition.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, completely agree with this. But making
filtera subcommand offoreachwould allow to have all this kind of functionality intended for multiple packages under an only subcommand.