diff --git a/cmd/limactl/list.go b/cmd/limactl/list.go index a651ff34b04..ec4effabb18 100644 --- a/cmd/limactl/list.go +++ b/cmd/limactl/list.go @@ -6,6 +6,7 @@ package main import ( "bufio" "bytes" + "encoding/json" "errors" "fmt" "reflect" @@ -56,7 +57,17 @@ The output can be presented in one of several formats, using the --format }}' - If the format begins and ends with '{{ }}', then it is used as a go template. -` + store.FormatHelp, + +Filtering instances: + --filter EXPR - Filter instances using yq expression (this is equivalent to --yq 'select(EXPR)') + Can be specified multiple times and it works with all output formats. + Examples: + --filter '.status == "Running"' + --filter '.vmType == "vz"' + --filter '.status == "Running"' --filter '.vmType == "vz"' +` + store.FormatHelp + ` +The following legacy flags continue to function: + --json - equal to '--format json'`, Args: WrapArgsError(cobra.ArbitraryArgs), RunE: listAction, ValidArgsFunction: listBashComplete, @@ -69,6 +80,7 @@ The output can be presented in one of several formats, using the --format 0 { + var filterExprs []string + for _, f := range filter { + filterExprs = append(filterExprs, "select("+f+")") + } + instances, err = filterInstances(instances, filterExprs) + if err != nil { + return err + } + } + + if quiet && len(yq) == 0 { + for _, instance := range instances { + fmt.Fprintln(cmd.OutOrStdout(), instance.Name) + } + if unmatchedInstances { + return unmatchedInstancesError{} + } + return nil + } + for _, instance := range instances { if len(instance.Errors) > 0 { logrus.WithField("errors", instance.Errors).Warnf("instance %q has errors", instance.Name) @@ -217,10 +259,12 @@ func listAction(cmd *cobra.Command, args []string) error { options.TerminalWidth = w } } - // --yq implies --format json unless --format yaml has been explicitly specified + + // --yq implies --format json unless --format has been explicitly specified if len(yq) != 0 && !cmd.Flags().Changed("format") { format = "json" } + // Always pipe JSON and YAML through yq to colorize it if isTTY if len(yq) == 0 && (format == "json" || format == "yaml") { yq = append(yq, ".") @@ -317,3 +361,34 @@ func listAction(cmd *cobra.Command, args []string) error { func listBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return bashCompleteInstanceNames(cmd) } + +// filterInstances applies yq expressions to instances and returns the filtered results. +func filterInstances(instances []*limatype.Instance, yqExprs []string) ([]*limatype.Instance, error) { + if len(yqExprs) == 0 { + return instances, nil + } + + // the yq expression is evaluated with yqutil.EvaluateExpressionWithEncoder, which disables environment variable access + // and file operations, mitigating injection attacks like ".name=strenv(SOME_SECRET_ENV)" which could + // trick Lima into exposing environment variables. + yqExpr := strings.Join(yqExprs, " | ") + + var filteredInstances []*limatype.Instance + for _, instance := range instances { + jsonBytes, err := json.Marshal(instance) + if err != nil { + return nil, fmt.Errorf("failed to marshal instance %q: %w", instance.Name, err) + } + + result, err := yqutil.EvaluateExpression(yqExpr, jsonBytes) + if err != nil { + return nil, fmt.Errorf("failed to apply filter %q: %w", yqExpr, err) + } + + if len(result) > 0 { + filteredInstances = append(filteredInstances, instance) + } + } + + return filteredInstances, nil +} diff --git a/hack/bats/tests/list.bats b/hack/bats/tests/list.bats index 99210095c56..ddc675aecd1 100644 --- a/hack/bats/tests/list.bats +++ b/hack/bats/tests/list.bats @@ -274,3 +274,31 @@ local_setup() { run_e -1 limactl ls --yq "load(\"${BASH_SOURCE[0]}\")" assert_fatal "file operations have been disabled" } + +@test '--filter option filters instances' { + run -0 limactl ls --filter '.name == "foo"' + assert_line --index 0 --regexp '^NAME' + assert_line --index 1 --regexp '^foo' + assert_output_lines_count 2 +} + +@test '--filter option works with all output formats' { + run -0 limactl ls --filter '.name == "foo"' + assert_line --index 1 --regexp '^foo' + + run -0 limactl ls --filter '.name == "foo"' --format json + assert_line --index 0 --regexp '^\{"name":"foo",' + + run -0 limactl ls --filter '.name == "foo"' --format '{{.Name}}' + assert_output "foo" +} + +@test '--filter option is compatible with --yq' { + run -0 limactl ls --filter '.name == "foo"' --yq '.name' + assert_output "foo" +} + +@test '--quiet option can be used with --filter' { + run -0 limactl ls --quiet --filter '.name == "foo"' + assert_output "foo" +}