diff --git a/go.mod b/go.mod index 95e3b0d3..310267ee 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( sigs.k8s.io/yaml v1.6.0 unikraft.com/cloud/sdk v0.0.0-20260416133315-be4aec303a89 unikraft.com/x/colors v0.0.0-20260313145522-d793c36d706e + unikraft.com/x/filters v0.0.0-20260416164455-ec39ae908f3f unikraft.com/x/fingerprint v0.0.0-20260126094137-ab6e717e5679 unikraft.com/x/guesstermwidth v0.0.0-20260304162956-523940cab1de unikraft.com/x/image-spec v0.0.0-20260402110633-a9f8f467a2b5 diff --git a/go.sum b/go.sum index a56046c7..2dde1b00 100644 --- a/go.sum +++ b/go.sum @@ -499,6 +499,8 @@ unikraft.com/cloud/sdk v0.0.0-20260416133315-be4aec303a89 h1:hMNR+ulLXyGiGFoRnOe unikraft.com/cloud/sdk v0.0.0-20260416133315-be4aec303a89/go.mod h1:mB0KNJFoeiV8zucDtuAFGW16tUsusA/ZHShhqbqhA5Q= unikraft.com/x/colors v0.0.0-20260313145522-d793c36d706e h1:C/V6l4ut5XpcVTN5CvnskRv6NHDbyIeLdgFVLEJ9BIE= unikraft.com/x/colors v0.0.0-20260313145522-d793c36d706e/go.mod h1:SVlAGfyQ7MwJom7m9M2w83+TrO+nJoiLxeduJAxagEo= +unikraft.com/x/filters v0.0.0-20260416164455-ec39ae908f3f h1:v6pitpzsBnOjyzDIW0/YAEHHSI6cNOw4QOwQV0uD+dc= +unikraft.com/x/filters v0.0.0-20260416164455-ec39ae908f3f/go.mod h1:m4Qdsw8FQThJcu8g+XTEdcmpN+blf/jBuNRI81juz1M= unikraft.com/x/fingerprint v0.0.0-20260126094137-ab6e717e5679 h1:zdvJjNkjsriS8RM46FcdgcRoCh4EYM66PGjqVgi/ups= unikraft.com/x/fingerprint v0.0.0-20260126094137-ab6e717e5679/go.mod h1:FP7uOxux/W5PKqSRQsR4tyjNuLq4Cfio7mc5QVH1kW8= unikraft.com/x/guesstermwidth v0.0.0-20260304162956-523940cab1de h1:1xafSiBA1yfMvhnM3q1baUliqkV6wkE1AXxiOSUkJSA= diff --git a/internal/multimetro/filter.go b/internal/multimetro/filter.go index 4a78033a..25d10ae2 100644 --- a/internal/multimetro/filter.go +++ b/internal/multimetro/filter.go @@ -8,7 +8,8 @@ package multimetro import ( "context" - "github.com/containerd/containerd/v2/pkg/filters" + "unikraft.com/x/filters" + "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/resource" ) @@ -19,67 +20,30 @@ func filterMetrosFromContext(ctx context.Context, metros []config.Metro) []confi } func filterMetros(metros []config.Metro, spec filters.Filter) []config.Metro { - names := filterMetroNames(metros, spec) - if len(names) == 0 { - // filter did not seem to apply to any metros + if spec == nil { return metros } - result := make([]config.Metro, 0, len(names)) - for _, metro := range metros { // preserve order - if ok := names[metro.Name]; ok { - result = append(result, metro) - } + // Extract metro names as candidates + candidates := make([]string, len(metros)) + for i, m := range metros { + candidates[i] = m.Name } - return result -} -func filterMetroNames(metros []config.Metro, spec filters.Filter) map[string]bool { - result := make(map[string]bool) - switch spec := spec.(type) { - case filters.All: - for _, sub := range spec { - filtered := filterMetroNames(metros, sub) - for k, v := range filtered { - if _, exists := result[k]; !exists { - result[k] = true - } - result[k] = result[k] && v - } - } - return result - case filters.Any: - for _, sub := range spec { - filtered := filterMetroNames(metros, sub) - if len(filtered) == 0 { - // filter did not seem to apply to any metros, so include all of them - for _, metro := range metros { - result[metro.Name] = true - } - break - } - for k, v := range filtered { - result[k] = result[k] || v - } - } - return result - default: - for _, metro := range metros { - var found bool - matched := spec.Match(filters.AdapterFunc(func(fieldpath []string) (string, bool) { - if len(fieldpath) != 1 { - return "", false - } - if fieldpath[0] != "metro" { - return "", false - } - found = true - return metro.Name, true - })) - if found { - result[metro.Name] = matched - } + // Filter candidates based on the metro field + matched := filters.Restrict(spec, "metro", candidates) + + // Build result preserving order + matchedSet := make(map[string]bool, len(matched)) + for _, m := range matched { + matchedSet[m] = true + } + + result := make([]config.Metro, 0, len(matched)) + for _, metro := range metros { + if matchedSet[metro.Name] { + result = append(result, metro) } - return result } + return result } diff --git a/internal/multimetro/filter_test.go b/internal/multimetro/filter_test.go index f30473d7..d0f7dcd9 100644 --- a/internal/multimetro/filter_test.go +++ b/internal/multimetro/filter_test.go @@ -8,9 +8,9 @@ package multimetro import ( "testing" - "github.com/containerd/containerd/v2/pkg/filters" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "unikraft.com/x/filters" "unikraft.com/cli/internal/config" ) diff --git a/internal/resource/cmd/cmd.go b/internal/resource/cmd/cmd.go index e4735b55..e7fcc172 100644 --- a/internal/resource/cmd/cmd.go +++ b/internal/resource/cmd/cmd.go @@ -14,12 +14,14 @@ import ( "fmt" "io" "os" + "reflect" "slices" + "strconv" "strings" "time" "github.com/charmbracelet/x/ansi" - "github.com/containerd/containerd/v2/pkg/filters" + "unikraft.com/x/filters" "unikraft.com/x/kingkong" "unikraft.com/x/log" @@ -28,7 +30,6 @@ import ( "unikraft.com/cli/internal/resource" "unikraft.com/cli/internal/resource/patch" "unikraft.com/cli/internal/tui/watcher" - xfilters "unikraft.com/cli/internal/x/filters" xkong "unikraft.com/cli/internal/x/kong" "unikraft.com/cloud/sdk/platform/group" ) @@ -382,7 +383,7 @@ func filterResources(ctx context.Context, resources []resource.Resource, filter } // Extract field paths needed by the filter - filterKeys := xfilters.Keys(filter) + filterKeys := filters.Keys(filter) if len(filterKeys) == 0 { return resources, nil } @@ -396,28 +397,108 @@ func filterResources(ctx context.Context, resources []resource.Resource, filter return nil, err } + seenPaths := make(map[string]bool) for _, res := range resolved { fields, _ := res.Fields(ctx) - if filter.Match(filters.AdapterFunc(func(key []string) (string, bool) { - matched := resource.GetFieldByPath(fields, key) - if matched == nil { - return "", false - } - if len(matched) != 1 { - // 0 fields = no exact match - // >1 fields = ambiguous match - return "", false + matched, err := filter.Match(newFieldAdaptor(fields)) + if err != nil { + var fieldErr *filters.FieldNotFoundError + if errors.As(err, &fieldErr) { + // Deduplicate field-not-found errors by path + pathKey := strings.Join(fieldErr.Path, ".") + if !seenPaths[pathKey] { + seenPaths[pathKey] = true + rerr = errors.Join(rerr, err) + } + continue } - // HACK: strip escape sequences from rendered output - out, _ := matched[0].Render() - return ansi.Strip(out), true - })) { + return nil, err + } + if matched { filtered = append(filtered, res) } } return filtered, rerr } +// newFieldAdaptor creates a filters.Adaptor that can traverse resource fields. +// It handles both structured fields (with subfields) and slice values (like []string tags). +func newFieldAdaptor(fields []resource.Field) filters.AdapterFunc { + return func(key []string) (string, []string, bool) { + matched := resource.GetFieldByPath(fields, key) + if len(matched) == 0 { + // GetFieldByPath may not find a match if we're looking up an index + // in a slice value (e.g., ["tags", "0"]). Try to handle this case. + if len(key) >= 2 { + parentMatched := resource.GetFieldByPath(fields, key[:len(key)-1]) + if len(parentMatched) == 1 { + if slice, ok := getSliceValue(parentMatched[0].Value); ok { + idx, err := strconv.Atoi(key[len(key)-1]) + if err == nil && idx >= 0 && idx < len(slice) { + return slice[idx], nil, true + } + } + } + } + return "", nil, false + } + if len(matched) == 1 { + field := matched[0] + // If the field has subfields, return their names as entries + // This enables wildcard filtering (e.g., nested.*.value) + if len(field.Subfields) > 0 { + entries := make([]string, len(field.Subfields)) + for i, sub := range field.Subfields { + entries[i] = sub.Name + } + return "", entries, true + } + // Check if the field's value is a slice (e.g., []string tags) + // If so, return indices as entries for wildcard support + if slice, ok := getSliceValue(field.Value); ok { + entries := make([]string, len(slice)) + for i := range slice { + entries[i] = strconv.Itoa(i) + } + return "", entries, true + } + // HACK: strip escape sequences from rendered output + out, _ := field.Render() + return ansi.Strip(out), nil, true + } + // >1 fields = ambiguous match, return entries for wildcard support + entries := make([]string, len(matched)) + for i, field := range matched { + entries[i] = field.Name + } + return "", entries, true + } +} + +// getSliceValue extracts a []string from a field value if possible. +// It handles both []string and other slice types by converting to strings. +func getSliceValue(value any) ([]string, bool) { + if value == nil { + return nil, false + } + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Slice { + return nil, false + } + result := make([]string, rv.Len()) + for i := range rv.Len() { + elem := rv.Index(i) + if s, ok := elem.Interface().(string); ok { + result[i] = s + } else if s, ok := elem.Interface().(fmt.Stringer); ok { + result[i] = s.String() + } else { + result[i] = fmt.Sprintf("%v", elem.Interface()) + } + } + return result, true +} + type ResourceRemoveCmd[R resource.DeletableResource] struct { Targets []string `arg:"" name:"target" completion-predictor:"resource-key-${name}" help:"Target ${names} to remove."` diff --git a/internal/resource/cmd/cmd_test.go b/internal/resource/cmd/cmd_test.go index a258734f..73bccfef 100644 --- a/internal/resource/cmd/cmd_test.go +++ b/internal/resource/cmd/cmd_test.go @@ -157,6 +157,129 @@ func TestList(t *testing.T) { assert.NotContains(t, output, "test2") }) + t.Run("filter-wildcard-nested", func(t *testing.T) { + var out bytes.Buffer + cmd := &ResourceListCmd[resourcet.TestResource]{ + Filter: []string{"authors.*.email==alice@example.com"}, + FormatOpts: FormatOpts{ + Output: Printer{Type: PrinterTypeQuiet}, + }, + } + err := cmd.Run(ctx, testStdio(&out), sandbox) + require.NoError(t, err) + + output := out.String() + assert.Contains(t, output, "test1") // has Alice as author + assert.NotContains(t, output, "test2") + + out.Reset() + cmd.Filter = []string{"authors.*.email==charlie@example.com"} + err = cmd.Run(ctx, testStdio(&out), sandbox) + require.NoError(t, err) + + output = out.String() + assert.Contains(t, output, "test2") // has Charlie as author + assert.NotContains(t, output, "test1") + + out.Reset() + cmd.Filter = []string{"authors.*.name==Bob"} + err = cmd.Run(ctx, testStdio(&out), sandbox) + require.NoError(t, err) + + output = out.String() + assert.Contains(t, output, "test1") // has Bob as author + assert.NotContains(t, output, "test2") + }) + + t.Run("filter-indexed-nested", func(t *testing.T) { + var out bytes.Buffer + cmd := &ResourceListCmd[resourcet.TestResource]{ + Filter: []string{"authors.0.name==Alice"}, + FormatOpts: FormatOpts{ + Output: Printer{Type: PrinterTypeQuiet}, + }, + } + err := cmd.Run(ctx, testStdio(&out), sandbox) + require.NoError(t, err) + + output := out.String() + assert.Contains(t, output, "test1") // first author is Alice + assert.NotContains(t, output, "test2") // first author is Charlie + + out.Reset() + cmd.Filter = []string{"authors.1.email==bob@example.com"} + err = cmd.Run(ctx, testStdio(&out), sandbox) + require.NoError(t, err) + + output = out.String() + assert.Contains(t, output, "test1") // second author is Bob + assert.NotContains(t, output, "test2") // second author is Dana + }) + + t.Run("filter-nested-struct", func(t *testing.T) { + var out bytes.Buffer + cmd := &ResourceListCmd[resourcet.TestResource]{ + Filter: []string{"settings.bar==hello"}, + FormatOpts: FormatOpts{ + Output: Printer{Type: PrinterTypeQuiet}, + }, + } + err := cmd.Run(ctx, testStdio(&out), sandbox) + require.NoError(t, err) + + output := out.String() + assert.Contains(t, output, "test1") // settings.bar == "hello" + assert.NotContains(t, output, "test2") // settings.bar == "world" + }) + + t.Run("filter-unknown-field", func(t *testing.T) { + var out bytes.Buffer + cmd := &ResourceListCmd[resourcet.TestResource]{ + Filter: []string{"nonexistent==value"}, + FormatOpts: FormatOpts{ + Output: Printer{Type: PrinterTypeQuiet}, + }, + } + err := cmd.Run(ctx, testStdio(&out), sandbox) + require.Error(t, err) + assert.Contains(t, err.Error(), "nonexistent") + + output := out.String() + assert.NotContains(t, output, "test1") + assert.NotContains(t, output, "test2") + }) + + t.Run("filter-unknown-field-dedup", func(t *testing.T) { + var out bytes.Buffer + cmd := &ResourceListCmd[resourcet.TestResource]{ + Filter: []string{"missing_field==value"}, + FormatOpts: FormatOpts{ + Output: Printer{Type: PrinterTypeQuiet}, + }, + } + err := cmd.Run(ctx, testStdio(&out), sandbox) + require.Error(t, err) + + // Error should mention missing_field only once, not once per resource + errStr := err.Error() + assert.Contains(t, errStr, "missing_field") + count := strings.Count(errStr, "missing_field") + assert.Equal(t, 1, count, "expected 1 occurrence, got %d", count) + }) + + t.Run("filter-unknown-nested-field", func(t *testing.T) { + var out bytes.Buffer + cmd := &ResourceListCmd[resourcet.TestResource]{ + Filter: []string{"settings.nonexistent==value"}, + FormatOpts: FormatOpts{ + Output: Printer{Type: PrinterTypeQuiet}, + }, + } + err := cmd.Run(ctx, testStdio(&out), sandbox) + require.Error(t, err) + assert.Contains(t, err.Error(), "nonexistent") + }) + t.Run("sort-asc", func(t *testing.T) { var out bytes.Buffer cmd := &ResourceListCmd[resourcet.TestResource]{ diff --git a/internal/resource/ctx.go b/internal/resource/ctx.go index 2746b1a5..a9bcb86a 100644 --- a/internal/resource/ctx.go +++ b/internal/resource/ctx.go @@ -8,7 +8,7 @@ package resource import ( "context" - "github.com/containerd/containerd/v2/pkg/filters" + "unikraft.com/x/filters" ) type contextKeyFilters struct{} diff --git a/internal/x/filters/keys.go b/internal/x/filters/keys.go deleted file mode 100644 index 5c3cc5dd..00000000 --- a/internal/x/filters/keys.go +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause -// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. -// Licensed under the BSD-3-Clause License (the "License"). -// You may not use this file except in compliance with the License. - -package filters - -import ( - "github.com/containerd/containerd/v2/pkg/filters" -) - -// Keys extracts all field paths referenced by a filter. -// This is useful for determining which fields need to be resolved -// before the filter can be evaluated. -func Keys(filter filters.Filter) [][]string { - var keys [][]string - collectKeys(filter, &keys) - return keys -} - -func collectKeys(filter filters.Filter, keys *[][]string) { - switch f := filter.(type) { - case filters.All: - for _, sub := range f { - collectKeys(sub, keys) - } - case filters.Any: - for _, sub := range f { - collectKeys(sub, keys) - } - default: - // For leaf filters, we need to extract the field path. - // The filter will call the adaptor with the field path when matching. - // We use a fake adaptor to capture the field path. - filter.Match(filters.AdapterFunc(func(fieldpath []string) (string, bool) { - // Make a copy of the fieldpath to avoid aliasing issues - pathCopy := make([]string, len(fieldpath)) - copy(pathCopy, fieldpath) - *keys = append(*keys, pathCopy) - return "", false - })) - } -}