diff --git a/docs/pages/reference/predicate-language.mdx b/docs/pages/reference/predicate-language.mdx index b98b20232b059..77ac314d57c11 100644 --- a/docs/pages/reference/predicate-language.mdx +++ b/docs/pages/reference/predicate-language.mdx @@ -62,13 +62,15 @@ The language supports the following operators: The language also supports the following functions: -| Functions (with examples) | Description | -|---------------------------------------|------------------------------------------------------------| -| `equals(labels["env"], "prod")` | resources with label key `env` equal to label value `prod` | -| `exists(labels["env"])` | resources with a label key `env`; label value unchecked | -| `!exists(labels["env"])` | resources without a label key `env`; label value unchecked | -| `search("foo", "bar", "some phrase")` | fuzzy match against common resource fields | -| `hasPrefix(name, "foo")` | resources with a name that starts with the prefix `foo` | +| Functions (with examples) | Description | +|----------------------------------------------|------------------------------------------------------------| +| `equals(labels["env"], "prod")` | resources with label key `env` equal to label value `prod` | +| `exists(labels["env"])` | resources with a label key `env`; label value unchecked | +| `!exists(labels["env"])` | resources without a label key `env`; label value unchecked | +| `search("foo", "bar", "some phrase")` | fuzzy match against common resource fields | +| `hasPrefix(name, "foo")` | resources with a name that starts with the prefix `foo` | +| `split(labels["foo"], ",")` | converts a delimited string into a list | +| `contains(split(labels["foo"], ","), "bar")` | determines if a value exists in a list | See some [examples](cli.mdx#filter-examples) of the different ways you can filter resources. diff --git a/lib/services/parser.go b/lib/services/parser.go index 69378d154abfd..d5a132788533e 100644 --- a/lib/services/parser.go +++ b/lib/services/parser.go @@ -21,6 +21,7 @@ package services import ( "fmt" "io" + "slices" "strings" "time" @@ -779,6 +780,12 @@ func NewResourceExpression(expression string) (typical.Expression[types.Resource "exists": typical.UnaryFunction[types.ResourceWithLabels](func(value string) (bool, error) { return value != "", nil }), + "split": typical.BinaryFunction[types.ResourceWithLabels](func(value string, delimiter string) ([]string, error) { + return strings.Split(value, delimiter), nil + }), + "contains": typical.BinaryFunction[types.ResourceWithLabels](func(list []string, value string) (bool, error) { + return slices.Contains(list, value), nil + }), }, GetUnknownIdentifier: func(env types.ResourceWithLabels, fields []string) (any, error) { if fields[0] == ResourceIdentifier { diff --git a/lib/services/parser_test.go b/lib/services/parser_test.go index 8d58ef6f3a8b5..4731f87d2636e 100644 --- a/lib/services/parser_test.go +++ b/lib/services/parser_test.go @@ -343,6 +343,75 @@ func TestResourceExpression_NameIdentifier(t *testing.T) { require.True(t, match) } +func TestResourceParserLabelExpansion(t *testing.T) { + t.Parallel() + + // Server resource should use hostname when using name identifier. + server, err := types.NewServerWithLabels("server-name", types.KindNode, types.ServerSpecV2{ + Hostname: "server-hostname", + }, map[string]string{"ip": "1.2.3.11,1.2.3.101,1.2.3.1", "foo": "bar"}) + require.NoError(t, err) + + tests := []struct { + expression string + assertion require.BoolAssertionFunc + }{ + { + expression: `contains(split(labels["ip"], ","), "1.2.3.1")`, + assertion: require.True, + }, + { + expression: `contains(split(labels.ip, ","), "1.2.3.1",)`, + assertion: require.True, + }, + { + expression: `contains(split(labels["ip"], ","), "1.2.3.2")`, + assertion: require.False, + }, + { + expression: `contains(split(labels.llama, ","), "1.2.3.2")`, + assertion: require.False, + }, + { + expression: `contains(split(labels.ip, ","), "1.2.3.2")`, + assertion: require.False, + }, + { + expression: `contains(split(labels.foo, ","), "bar")`, + assertion: require.True, + }, + } + + for _, test := range tests { + t.Run(test.expression, func(t *testing.T) { + expression, err := NewResourceExpression(test.expression) + require.NoError(t, err) + + match, err := expression.Evaluate(server) + require.NoError(t, err) + test.assertion(t, match) + }) + } +} + +func BenchmarkContains(b *testing.B) { + server, err := types.NewServerWithLabels("server-name", types.KindNode, types.ServerSpecV2{ + Hostname: "server-hostname", + }, map[string]string{"ip": "1.2.3.11|1.2.3.101|1.2.3.1"}) + require.NoError(b, err) + + expression, err := NewResourceExpression(`contains(split(labels["ip"], "|"), "1.2.3.1")`) + require.NoError(b, err) + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + match, err := expression.Evaluate(server) + require.NoError(b, err) + require.True(b, match) + } +} + // TestParserHostCertContext tests set functions with a custom host cert // context. func TestParserHostCertContext(t *testing.T) {