Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
78 changes: 21 additions & 57 deletions internal/multimetro/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
}
2 changes: 1 addition & 1 deletion internal/multimetro/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
113 changes: 97 additions & 16 deletions internal/resource/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
)
Expand Down Expand Up @@ -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
}
Expand All @@ -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) {
Comment thread
jedevc marked this conversation as resolved.
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() {
Comment thread
jedevc marked this conversation as resolved.
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."`

Expand Down
Loading
Loading