catalog: add metadata filtering to refine workload selectors (#19198)

This implements the Filter field on pbcatalog.WorkloadSelector to be
a post-fetch in-memory filter using the https://github.com/hashicorp/go-bexpr
expression language to filter resources based on their envelope metadata fields.

All existing usages of WorkloadSelector should be able to make use of the filter.
pull/19204/head
R.B. Boyer 2023-10-13 13:37:42 -05:00 committed by GitHub
parent f0e4897736
commit 99f7a1219e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 713 additions and 34 deletions

View File

@ -101,3 +101,12 @@ func NewFailoverPolicyMapper() FailoverPolicyMapper {
func ValidateLocalServiceRefNoSection(ref *pbresource.Reference, wrapErr func(error) error) error {
return types.ValidateLocalServiceRefNoSection(ref, wrapErr)
}
// ValidateSelector ensures that the selector has at least one exact or prefix
// match constraint, and that if a filter is present it is valid.
//
// The selector can be nil, and have zero exact/prefix matches if allowEmpty is
// set to true.
func ValidateSelector(sel *pbcatalog.WorkloadSelector, allowEmpty bool) error {
return types.ValidateSelector(sel, allowEmpty)
}

View File

@ -5,6 +5,7 @@ package endpoints
import (
"context"
"fmt"
"sort"
"google.golang.org/grpc/codes"
@ -169,6 +170,14 @@ func gatherWorkloadsForService(ctx context.Context, rt controller.Runtime, svc *
workloadNames[rsp.Resource.Id.Name] = struct{}{}
}
if sel.GetFilter() != "" && len(workloads) > 0 {
var err error
workloads, err = resource.FilterResourcesByMetadata(workloads, sel.GetFilter())
if err != nil {
return nil, fmt.Errorf("error filtering results by metadata: %w", err)
}
}
// Sorting ensures deterministic output. This will help for testing but
// the real reason to do this is so we will be able to diff the set of
// workloads endpoints to determine if we need to update them.

View File

@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/suite"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing"
"github.com/hashicorp/consul/internal/catalog/internal/types"
@ -30,14 +31,16 @@ type reconciliationDataSuite struct {
client pbresource.ResourceServiceClient
rt controller.Runtime
apiServiceData *pbcatalog.Service
apiService *pbresource.Resource
apiEndpoints *pbresource.Resource
api1Workload *pbresource.Resource
api2Workload *pbresource.Resource
api123Workload *pbresource.Resource
web1Workload *pbresource.Resource
web2Workload *pbresource.Resource
apiServiceData *pbcatalog.Service
apiService *pbresource.Resource
apiServiceSubsetData *pbcatalog.Service
apiServiceSubset *pbresource.Resource
apiEndpoints *pbresource.Resource
api1Workload *pbresource.Resource
api2Workload *pbresource.Resource
api123Workload *pbresource.Resource
web1Workload *pbresource.Resource
web2Workload *pbresource.Resource
}
func (suite *reconciliationDataSuite) SetupTest() {
@ -62,12 +65,19 @@ func (suite *reconciliationDataSuite) SetupTest() {
},
},
}
suite.apiServiceSubsetData = proto.Clone(suite.apiServiceData).(*pbcatalog.Service)
suite.apiServiceSubsetData.Workloads.Filter = "(zim in metadata) and (metadata.zim matches `^g.`)"
suite.apiService = rtest.Resource(pbcatalog.ServiceType, "api").
WithData(suite.T(), suite.apiServiceData).
Write(suite.T(), suite.client)
suite.apiServiceSubset = rtest.Resource(pbcatalog.ServiceType, "api-subset").
WithData(suite.T(), suite.apiServiceSubsetData).
Write(suite.T(), suite.client)
suite.api1Workload = rtest.Resource(pbcatalog.WorkloadType, "api-1").
WithMeta("zim", "dib").
WithData(suite.T(), &pbcatalog.Workload{
Addresses: []*pbcatalog.WorkloadAddress{
{Host: "127.0.0.1"},
@ -92,6 +102,7 @@ func (suite *reconciliationDataSuite) SetupTest() {
Write(suite.T(), suite.client)
suite.api123Workload = rtest.Resource(pbcatalog.WorkloadType, "api-123").
WithMeta("zim", "gir").
WithData(suite.T(), &pbcatalog.Workload{
Addresses: []*pbcatalog.WorkloadAddress{
{Host: "127.0.0.1"},
@ -104,6 +115,7 @@ func (suite *reconciliationDataSuite) SetupTest() {
Write(suite.T(), suite.client)
suite.web1Workload = rtest.Resource(pbcatalog.WorkloadType, "web-1").
WithMeta("zim", "gaz").
WithData(suite.T(), &pbcatalog.Workload{
Addresses: []*pbcatalog.WorkloadAddress{
{Host: "127.0.0.1"},
@ -259,6 +271,20 @@ func (suite *reconciliationDataSuite) TestGetWorkloadData() {
prototest.AssertDeepEqual(suite.T(), suite.web2Workload, data[4].resource)
}
func (suite *reconciliationDataSuite) TestGetWorkloadDataWithFilter() {
// This is like TestGetWorkloadData except it exercises the post-read
// filter on the selector.
data, err := getWorkloadData(suite.ctx, suite.rt, &serviceData{
resource: suite.apiServiceSubset,
service: suite.apiServiceSubsetData,
})
require.NoError(suite.T(), err)
require.Len(suite.T(), data, 2)
prototest.AssertDeepEqual(suite.T(), suite.api123Workload, data[0].resource)
prototest.AssertDeepEqual(suite.T(), suite.web1Workload, data[1].resource)
}
func TestReconciliationData(t *testing.T) {
suite.Run(t, new(reconciliationDataSuite))
}

View File

@ -32,7 +32,7 @@ func ValidateDNSPolicy(res *pbresource.Resource) error {
var err error
// Ensure that this resource isn't useless and is attempting to
// select at least one workload.
if selErr := validateSelector(policy.Workloads, false); selErr != nil {
if selErr := ValidateSelector(policy.Workloads, false); selErr != nil {
err = multierror.Append(err, resource.ErrInvalidField{
Name: "workloads",
Wrapped: selErr,

View File

@ -30,7 +30,7 @@ func ValidateHealthChecks(res *pbresource.Resource) error {
var err error
// Validate the workload selector
if selErr := validateSelector(checks.Workloads, false); selErr != nil {
if selErr := ValidateSelector(checks.Workloads, false); selErr != nil {
err = multierror.Append(err, resource.ErrInvalidField{
Name: "workloads",
Wrapped: selErr,

View File

@ -61,7 +61,7 @@ func ValidateService(res *pbresource.Resource) error {
// ServiceEndpoints objects for this service such as when desiring to
// configure endpoint information for external services that are not
// registered as workloads
if selErr := validateSelector(service.Workloads, true); selErr != nil {
if selErr := ValidateSelector(service.Workloads, true); selErr != nil {
err = multierror.Append(err, resource.ErrInvalidField{
Name: "workloads",
Wrapped: selErr,

View File

@ -78,7 +78,7 @@ func validateWorkloadHost(host string) error {
return nil
}
func validateSelector(sel *pbcatalog.WorkloadSelector, allowEmpty bool) error {
func ValidateSelector(sel *pbcatalog.WorkloadSelector, allowEmpty bool) error {
if sel == nil {
if allowEmpty {
return nil
@ -88,14 +88,20 @@ func validateSelector(sel *pbcatalog.WorkloadSelector, allowEmpty bool) error {
}
if len(sel.Names) == 0 && len(sel.Prefixes) == 0 {
if allowEmpty {
return nil
if !allowEmpty {
return resource.ErrEmpty
}
return resource.ErrEmpty
if sel.Filter != "" {
return resource.ErrInvalidField{
Name: "filter",
Wrapped: errors.New("filter cannot be set unless there is a name or prefix selector"),
}
}
return nil
}
var err error
var merr error
// Validate that all the exact match names are non-empty. This is
// mostly for the sake of not admitting values that should always
@ -103,7 +109,7 @@ func validateSelector(sel *pbcatalog.WorkloadSelector, allowEmpty bool) error {
// This is because workloads must have non-empty names.
for idx, name := range sel.Names {
if name == "" {
err = multierror.Append(err, resource.ErrInvalidListElement{
merr = multierror.Append(merr, resource.ErrInvalidListElement{
Name: "names",
Index: idx,
Wrapped: resource.ErrEmpty,
@ -111,7 +117,14 @@ func validateSelector(sel *pbcatalog.WorkloadSelector, allowEmpty bool) error {
}
}
return err
if err := resource.ValidateMetadataFilter(sel.GetFilter()); err != nil {
merr = multierror.Append(merr, resource.ErrInvalidField{
Name: "filter",
Wrapped: err,
})
}
return merr
}
func validateIPAddress(ip string) error {

View File

@ -4,6 +4,7 @@
package types
import (
"errors"
"fmt"
"strings"
"testing"
@ -281,11 +282,49 @@ func TestValidateSelector(t *testing.T) {
},
},
},
"filter-with-empty-query": {
selector: &pbcatalog.WorkloadSelector{
Filter: "garbage.value == zzz",
},
allowEmpty: true,
err: resource.ErrInvalidField{
Name: "filter",
Wrapped: errors.New(
`filter cannot be set unless there is a name or prefix selector`,
),
},
},
"bad-filter": {
selector: &pbcatalog.WorkloadSelector{
Prefixes: []string{"foo", "bar"},
Filter: "garbage.value == zzz",
},
allowEmpty: false,
err: &multierror.Error{
Errors: []error{
resource.ErrInvalidField{
Name: "filter",
Wrapped: fmt.Errorf(
`filter "garbage.value == zzz" is invalid: %w`,
errors.New(`Selector "garbage" is not valid`),
),
},
},
},
},
"good-filter": {
selector: &pbcatalog.WorkloadSelector{
Prefixes: []string{"foo", "bar"},
Filter: "metadata.zone == west1",
},
allowEmpty: false,
err: nil,
},
}
for name, tcase := range cases {
t.Run(name, func(t *testing.T) {
err := validateSelector(tcase.selector, tcase.allowEmpty)
err := ValidateSelector(tcase.selector, tcase.allowEmpty)
if tcase.err == nil {
require.NoError(t, err)
} else {

View File

@ -92,7 +92,7 @@ func (r *reconciler) Reconcile(ctx context.Context, rt controller.Runtime, req c
destinationIDs := r.mapper.DestinationsForWorkload(req.ID)
rt.Logger.Trace("cached destinations IDs", "ids", destinationIDs)
decodedDestinations, err := r.fetchDestinations(ctx, rt.Client, destinationIDs)
decodedDestinations, err := r.fetchDestinations(ctx, rt.Client, destinationIDs, workload)
if err != nil {
rt.Logger.Error("error fetching mapper", "error", err)
return err
@ -241,8 +241,9 @@ func validate(
func (r *reconciler) fetchDestinations(
ctx context.Context,
client pbresource.ResourceServiceClient,
destinationIDs []*pbresource.ID) ([]*types.DecodedDestinations, error) {
destinationIDs []*pbresource.ID,
workload *types.DecodedWorkload,
) ([]*types.DecodedDestinations, error) {
// Sort all configs alphabetically.
sort.Slice(destinationIDs, func(i, j int) bool {
return destinationIDs[i].GetName() < destinationIDs[j].GetName()
@ -259,6 +260,17 @@ func (r *reconciler) fetchDestinations(
r.mapper.UntrackDestinations(id)
continue
}
if res.Data.Workloads.Filter != "" {
match, err := resource.FilterMatchesResourceMetadata(workload.Resource, res.Data.Workloads.Filter)
if err != nil {
return nil, fmt.Errorf("error checking selector filters: %w", err)
}
if !match {
continue
}
}
decoded = append(decoded, res)
}

View File

@ -5,6 +5,7 @@ package proxyconfiguration
import (
"context"
"fmt"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
@ -86,7 +87,7 @@ func (r *reconciler) Reconcile(ctx context.Context, rt controller.Runtime, req c
proxyCfgIDs := r.proxyConfigMapper.IDsForWorkload(req.ID)
rt.Logger.Trace("cached proxy cfg IDs", "ids", proxyCfgIDs)
decodedProxyCfgs, err := r.fetchProxyConfigs(ctx, rt.Client, proxyCfgIDs)
decodedProxyCfgs, err := r.fetchProxyConfigs(ctx, rt.Client, proxyCfgIDs, workload)
if err != nil {
rt.Logger.Error("error fetching proxy configurations", "error", err)
return err
@ -154,8 +155,9 @@ func (r *reconciler) Reconcile(ctx context.Context, rt controller.Runtime, req c
func (r *reconciler) fetchProxyConfigs(
ctx context.Context,
client pbresource.ResourceServiceClient,
proxyCfgIds []*pbresource.ID) ([]*types.DecodedProxyConfiguration, error) {
proxyCfgIds []*pbresource.ID,
workload *types.DecodedWorkload,
) ([]*types.DecodedProxyConfiguration, error) {
var decoded []*types.DecodedProxyConfiguration
for _, id := range proxyCfgIds {
res, err := resource.GetDecodedResource[*pbmesh.ProxyConfiguration](ctx, client, id)
@ -167,6 +169,17 @@ func (r *reconciler) fetchProxyConfigs(
r.proxyConfigMapper.UntrackID(id)
continue
}
if res.Data.Workloads.Filter != "" {
match, err := resource.FilterMatchesResourceMetadata(workload.Resource, res.Data.Workloads.Filter)
if err != nil {
return nil, fmt.Errorf("error checking selector filters: %w", err)
}
if !match {
continue
}
}
decoded = append(decoded, res)
}

View File

@ -73,6 +73,14 @@ func ValidateDestinations(res *pbresource.Resource) error {
var merr error
// Validate the workload selector
if selErr := catalog.ValidateSelector(destinations.Workloads, false); selErr != nil {
merr = multierror.Append(merr, resource.ErrInvalidField{
Name: "workloads",
Wrapped: selErr,
})
}
for i, dest := range destinations.Destinations {
wrapDestErr := func(err error) error {
return resource.ErrInvalidListElement{
@ -97,7 +105,5 @@ func ValidateDestinations(res *pbresource.Resource) error {
// TODO(v2): validate ListenAddr
}
// TODO(v2): validate workload selectors
return merr
}

View File

@ -4,8 +4,12 @@
package types
import (
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/consul/internal/catalog"
"github.com/hashicorp/consul/internal/resource"
pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v2beta1"
"github.com/hashicorp/consul/proto-public/pbresource"
)
func RegisterUpstreamsConfiguration(r resource.Registry) {
@ -13,6 +17,26 @@ func RegisterUpstreamsConfiguration(r resource.Registry) {
Type: pbmesh.DestinationsConfigurationType,
Proto: &pbmesh.DestinationsConfiguration{},
Scope: resource.ScopeNamespace,
Validate: nil,
Validate: ValidateDestinationsConfiguration,
})
}
func ValidateDestinationsConfiguration(res *pbresource.Resource) error {
var cfg pbmesh.DestinationsConfiguration
if err := res.Data.UnmarshalTo(&cfg); err != nil {
return resource.NewErrDataParse(&cfg, err)
}
var merr error
// Validate the workload selector
if selErr := catalog.ValidateSelector(cfg.Workloads, false); selErr != nil {
merr = multierror.Append(merr, resource.ErrInvalidField{
Name: "workloads",
Wrapped: selErr,
})
}
return merr
}

View File

@ -0,0 +1,80 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package types
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/hashicorp/consul/internal/resource"
"github.com/hashicorp/consul/internal/resource/resourcetest"
pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v2beta1"
pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v2beta1"
"github.com/hashicorp/consul/proto/private/prototest"
"github.com/hashicorp/consul/sdk/testutil"
)
func TestValidateDestinationsConfiguration(t *testing.T) {
type testcase struct {
data *pbmesh.DestinationsConfiguration
expectErr string
}
run := func(t *testing.T, tc testcase) {
res := resourcetest.Resource(pbmesh.DestinationsConfigurationType, "api").
WithTenancy(resource.DefaultNamespacedTenancy()).
WithData(t, tc.data).
Build()
err := ValidateDestinationsConfiguration(res)
// Verify that validate didn't actually change the object.
got := resourcetest.MustDecode[*pbmesh.DestinationsConfiguration](t, res)
prototest.AssertDeepEqual(t, tc.data, got.Data)
if tc.expectErr == "" {
require.NoError(t, err)
} else {
testutil.RequireErrorContains(t, err, tc.expectErr)
}
}
cases := map[string]testcase{
// emptiness
"empty": {
data: &pbmesh.DestinationsConfiguration{},
expectErr: `invalid "workloads" field: cannot be empty`,
},
"empty selector": {
data: &pbmesh.DestinationsConfiguration{
Workloads: &pbcatalog.WorkloadSelector{},
},
expectErr: `invalid "workloads" field: cannot be empty`,
},
"bad selector": {
data: &pbmesh.DestinationsConfiguration{
Workloads: &pbcatalog.WorkloadSelector{
Names: []string{"blah"},
Filter: "garbage.foo == bar",
},
},
expectErr: `invalid "filter" field: filter "garbage.foo == bar" is invalid: Selector "garbage" is not valid`,
},
"good selector": {
data: &pbmesh.DestinationsConfiguration{
Workloads: &pbcatalog.WorkloadSelector{
Names: []string{"blah"},
Filter: "metadata.foo == bar",
},
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
run(t, tc)
})
}
}

View File

@ -123,11 +123,30 @@ func TestValidateUpstreams(t *testing.T) {
cases := map[string]testcase{
// emptiness
"empty": {
data: &pbmesh.Destinations{},
data: &pbmesh.Destinations{},
expectErr: `invalid "workloads" field: cannot be empty`,
},
"empty selector": {
data: &pbmesh.Destinations{
Workloads: &pbcatalog.WorkloadSelector{},
},
expectErr: `invalid "workloads" field: cannot be empty`,
},
"bad selector": {
data: &pbmesh.Destinations{
Workloads: &pbcatalog.WorkloadSelector{
Names: []string{"blah"},
Filter: "garbage.foo == bar",
},
},
expectErr: `invalid "filter" field: filter "garbage.foo == bar" is invalid: Selector "garbage" is not valid`,
},
"dest/nil ref": {
skipMutate: true,
data: &pbmesh.Destinations{
Workloads: &pbcatalog.WorkloadSelector{
Names: []string{"blah"},
},
Destinations: []*pbmesh.Destination{
{DestinationRef: nil},
},
@ -137,6 +156,9 @@ func TestValidateUpstreams(t *testing.T) {
"dest/bad type": {
skipMutate: true,
data: &pbmesh.Destinations{
Workloads: &pbcatalog.WorkloadSelector{
Names: []string{"blah"},
},
Destinations: []*pbmesh.Destination{
{DestinationRef: newRefWithTenancy(pbcatalog.WorkloadType, "default.default", "api")},
},
@ -146,6 +168,9 @@ func TestValidateUpstreams(t *testing.T) {
"dest/nil tenancy": {
skipMutate: true,
data: &pbmesh.Destinations{
Workloads: &pbcatalog.WorkloadSelector{
Names: []string{"blah"},
},
Destinations: []*pbmesh.Destination{
{DestinationRef: &pbresource.Reference{Type: pbcatalog.ServiceType, Name: "api"}},
},
@ -155,6 +180,9 @@ func TestValidateUpstreams(t *testing.T) {
"dest/bad dest tenancy/partition": {
skipMutate: true,
data: &pbmesh.Destinations{
Workloads: &pbcatalog.WorkloadSelector{
Names: []string{"blah"},
},
Destinations: []*pbmesh.Destination{
{DestinationRef: newRefWithTenancy(pbcatalog.ServiceType, ".bar", "api")},
},
@ -164,6 +192,9 @@ func TestValidateUpstreams(t *testing.T) {
"dest/bad dest tenancy/namespace": {
skipMutate: true,
data: &pbmesh.Destinations{
Workloads: &pbcatalog.WorkloadSelector{
Names: []string{"blah"},
},
Destinations: []*pbmesh.Destination{
{DestinationRef: newRefWithTenancy(pbcatalog.ServiceType, "foo", "api")},
},
@ -173,6 +204,9 @@ func TestValidateUpstreams(t *testing.T) {
"dest/bad dest tenancy/peer_name": {
skipMutate: true,
data: &pbmesh.Destinations{
Workloads: &pbcatalog.WorkloadSelector{
Names: []string{"blah"},
},
Destinations: []*pbmesh.Destination{
{DestinationRef: resourcetest.Resource(pbcatalog.ServiceType, "api").
WithTenancy(&pbresource.Tenancy{Partition: "foo", Namespace: "bar"}).
@ -183,6 +217,22 @@ func TestValidateUpstreams(t *testing.T) {
},
"normal": {
data: &pbmesh.Destinations{
Workloads: &pbcatalog.WorkloadSelector{
Names: []string{"blah"},
},
Destinations: []*pbmesh.Destination{
{DestinationRef: newRefWithTenancy(pbcatalog.ServiceType, "foo.bar", "api")},
{DestinationRef: newRefWithTenancy(pbcatalog.ServiceType, "foo.zim", "api")},
{DestinationRef: newRefWithTenancy(pbcatalog.ServiceType, "gir.zim", "api")},
},
},
},
"normal with selector": {
data: &pbmesh.Destinations{
Workloads: &pbcatalog.WorkloadSelector{
Names: []string{"blah"},
Filter: "metadata.foo == bar",
},
Destinations: []*pbmesh.Destination{
{DestinationRef: newRefWithTenancy(pbcatalog.ServiceType, "foo.bar", "api")},
{DestinationRef: newRefWithTenancy(pbcatalog.ServiceType, "foo.zim", "api")},

View File

@ -4,6 +4,9 @@
package types
import (
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/consul/internal/catalog"
"github.com/hashicorp/consul/internal/resource"
pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v2beta1"
"github.com/hashicorp/consul/proto-public/pbresource"
@ -12,12 +15,11 @@ import (
func RegisterProxyConfiguration(r resource.Registry) {
r.Register(resource.Registration{
Type: pbmesh.ProxyConfigurationType,
Proto: &pbmesh.ProxyConfiguration{},
Scope: resource.ScopeNamespace,
// TODO(rb): add validation for proxy configuration
Validate: nil,
Type: pbmesh.ProxyConfigurationType,
Proto: &pbmesh.ProxyConfiguration{},
Scope: resource.ScopeNamespace,
Mutate: MutateProxyConfiguration,
Validate: ValidateProxyConfiguration,
})
}
@ -49,3 +51,25 @@ func MutateProxyConfiguration(res *pbresource.Resource) error {
return res.Data.MarshalFrom(&proxyCfg)
}
func ValidateProxyConfiguration(res *pbresource.Resource) error {
var cfg pbmesh.ProxyConfiguration
if err := res.Data.UnmarshalTo(&cfg); err != nil {
return resource.NewErrDataParse(&cfg, err)
}
var merr error
// Validate the workload selector
if selErr := catalog.ValidateSelector(cfg.Workloads, false); selErr != nil {
merr = multierror.Append(merr, resource.ErrInvalidField{
Name: "workloads",
Wrapped: selErr,
})
}
// TODO(rb): add more validation for proxy configuration
return merr
}

View File

@ -8,10 +8,13 @@ import (
"github.com/stretchr/testify/require"
"github.com/hashicorp/consul/internal/resource"
"github.com/hashicorp/consul/internal/resource/resourcetest"
pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v2beta1"
pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v2beta1"
"github.com/hashicorp/consul/proto/private/prototest"
"github.com/hashicorp/consul/sdk/iptables"
"github.com/hashicorp/consul/sdk/testutil"
)
func TestMutateProxyConfiguration(t *testing.T) {
@ -82,3 +85,74 @@ func TestMutateProxyConfiguration(t *testing.T) {
})
}
}
func TestValidateProxyConfiguration(t *testing.T) {
type testcase struct {
data *pbmesh.ProxyConfiguration
expectErr string
}
run := func(t *testing.T, tc testcase) {
res := resourcetest.Resource(pbmesh.ProxyConfigurationType, "api").
WithTenancy(resource.DefaultNamespacedTenancy()).
WithData(t, tc.data).
Build()
// Ensure things are properly mutated and updated in the inputs.
err := MutateProxyConfiguration(res)
require.NoError(t, err)
{
mutated := resourcetest.MustDecode[*pbmesh.ProxyConfiguration](t, res)
tc.data = mutated.Data
}
err = ValidateProxyConfiguration(res)
// Verify that validate didn't actually change the object.
got := resourcetest.MustDecode[*pbmesh.ProxyConfiguration](t, res)
prototest.AssertDeepEqual(t, tc.data, got.Data)
if tc.expectErr == "" {
require.NoError(t, err)
} else {
testutil.RequireErrorContains(t, err, tc.expectErr)
}
}
cases := map[string]testcase{
// emptiness
"empty": {
data: &pbmesh.ProxyConfiguration{},
expectErr: `invalid "workloads" field: cannot be empty`,
},
"empty selector": {
data: &pbmesh.ProxyConfiguration{
Workloads: &pbcatalog.WorkloadSelector{},
},
expectErr: `invalid "workloads" field: cannot be empty`,
},
"bad selector": {
data: &pbmesh.ProxyConfiguration{
Workloads: &pbcatalog.WorkloadSelector{
Names: []string{"blah"},
Filter: "garbage.foo == bar",
},
},
expectErr: `invalid "filter" field: filter "garbage.foo == bar" is invalid: Selector "garbage" is not valid`,
},
"good selector": {
data: &pbmesh.ProxyConfiguration{
Workloads: &pbcatalog.WorkloadSelector{
Names: []string{"blah"},
Filter: "metadata.foo == bar",
},
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
run(t, tc)
})
}
}

105
internal/resource/filter.go Normal file
View File

@ -0,0 +1,105 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package resource
import (
"fmt"
"github.com/hashicorp/go-bexpr"
"github.com/hashicorp/consul/proto-public/pbresource"
)
// FilterResourcesByMetadata will use the provided go-bexpr based filter to
// retain matching items from the provided slice.
//
// The only variables usable in the expressions are the metadata keys prefixed
// by "metadata."
//
// If no filter is provided, then this does nothing and returns the input.
func FilterResourcesByMetadata(resources []*pbresource.Resource, filter string) ([]*pbresource.Resource, error) {
if filter == "" || len(resources) == 0 {
return resources, nil
}
eval, err := createMetadataFilterEvaluator(filter)
if err != nil {
return nil, err
}
filtered := make([]*pbresource.Resource, 0, len(resources))
for _, res := range resources {
vars := &metadataFilterFieldDetails{
Meta: res.Metadata,
}
match, err := eval.Evaluate(vars)
if err != nil {
return nil, err
}
if match {
filtered = append(filtered, res)
}
}
if len(filtered) == 0 {
return nil, nil
}
return filtered, nil
}
// FilterMatchesResourceMetadata will use the provided go-bexpr based filter to
// determine if the provided resource matches.
//
// The only variables usable in the expressions are the metadata keys prefixed
// by "metadata."
//
// If no filter is provided, then this returns true.
func FilterMatchesResourceMetadata(res *pbresource.Resource, filter string) (bool, error) {
if res == nil {
return false, nil
} else if filter == "" {
return true, nil
}
eval, err := createMetadataFilterEvaluator(filter)
if err != nil {
return false, err
}
vars := &metadataFilterFieldDetails{
Meta: res.Metadata,
}
match, err := eval.Evaluate(vars)
if err != nil {
return false, err
}
return match, nil
}
// ValidateMetadataFilter will validate that the provided filter is going to be
// a valid input to the FilterResourcesByMetadata function.
//
// This is best called from a Validate hook.
func ValidateMetadataFilter(filter string) error {
if filter == "" {
return nil
}
_, err := createMetadataFilterEvaluator(filter)
return err
}
func createMetadataFilterEvaluator(filter string) (*bexpr.Evaluator, error) {
sampleVars := &metadataFilterFieldDetails{
Meta: make(map[string]string),
}
eval, err := bexpr.CreateEvaluatorForType(filter, nil, sampleVars)
if err != nil {
return nil, fmt.Errorf("filter %q is invalid: %w", filter, err)
}
return eval, nil
}
type metadataFilterFieldDetails struct {
Meta map[string]string `bexpr:"metadata"`
}

View File

@ -0,0 +1,195 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package resource
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/hashicorp/consul/proto-public/pbresource"
"github.com/hashicorp/consul/proto/private/prototest"
"github.com/hashicorp/consul/sdk/testutil"
)
func TestFilterResourcesByMetadata(t *testing.T) {
type testcase struct {
in []*pbresource.Resource
filter string
expect []*pbresource.Resource
expectErr string
}
create := func(name string, kvs ...string) *pbresource.Resource {
require.True(t, len(kvs)%2 == 0)
meta := make(map[string]string)
for i := 0; i < len(kvs); i += 2 {
meta[kvs[i]] = kvs[i+1]
}
return &pbresource.Resource{
Id: &pbresource.ID{
Name: name,
},
Metadata: meta,
}
}
run := func(t *testing.T, tc testcase) {
got, err := FilterResourcesByMetadata(tc.in, tc.filter)
if tc.expectErr != "" {
require.Error(t, err)
testutil.RequireErrorContains(t, err, tc.expectErr)
} else {
require.NoError(t, err)
prototest.AssertDeepEqual(t, tc.expect, got)
}
}
cases := map[string]testcase{
"nil input": {},
"no filter": {
in: []*pbresource.Resource{
create("one"),
create("two"),
create("three"),
create("four"),
},
filter: "",
expect: []*pbresource.Resource{
create("one"),
create("two"),
create("three"),
create("four"),
},
},
"bad filter": {
in: []*pbresource.Resource{
create("one"),
create("two"),
create("three"),
create("four"),
},
filter: "garbage.value == zzz",
expectErr: `Selector "garbage" is not valid`,
},
"filter everything out": {
in: []*pbresource.Resource{
create("one"),
create("two"),
create("three"),
create("four"),
},
filter: "metadata.foo == bar",
},
"filter simply": {
in: []*pbresource.Resource{
create("one", "foo", "bar"),
create("two", "foo", "baz"),
create("three", "zim", "gir"),
create("four", "zim", "gaz", "foo", "bar"),
},
filter: "metadata.foo == bar",
expect: []*pbresource.Resource{
create("one", "foo", "bar"),
create("four", "zim", "gaz", "foo", "bar"),
},
},
"filter prefix": {
in: []*pbresource.Resource{
create("one", "foo", "bar"),
create("two", "foo", "baz"),
create("three", "zim", "gir"),
create("four", "zim", "gaz", "foo", "bar"),
create("four", "zim", "zzz"),
},
filter: "(zim in metadata) and (metadata.zim matches `^g.`)",
expect: []*pbresource.Resource{
create("three", "zim", "gir"),
create("four", "zim", "gaz", "foo", "bar"),
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
run(t, tc)
})
}
}
func TestFilterMatchesResourceMetadata(t *testing.T) {
type testcase struct {
res *pbresource.Resource
filter string
expect bool
expectErr string
}
create := func(name string, kvs ...string) *pbresource.Resource {
require.True(t, len(kvs)%2 == 0)
meta := make(map[string]string)
for i := 0; i < len(kvs); i += 2 {
meta[kvs[i]] = kvs[i+1]
}
return &pbresource.Resource{
Id: &pbresource.ID{
Name: name,
},
Metadata: meta,
}
}
run := func(t *testing.T, tc testcase) {
got, err := FilterMatchesResourceMetadata(tc.res, tc.filter)
if tc.expectErr != "" {
require.Error(t, err)
testutil.RequireErrorContains(t, err, tc.expectErr)
} else {
require.NoError(t, err)
require.Equal(t, tc.expect, got)
}
}
cases := map[string]testcase{
"nil input": {},
"no filter": {
res: create("one"),
filter: "",
expect: true,
},
"bad filter": {
res: create("one"),
filter: "garbage.value == zzz",
expectErr: `Selector "garbage" is not valid`,
},
"no match": {
res: create("one"),
filter: "metadata.foo == bar",
},
"match simply": {
res: create("one", "foo", "bar"),
filter: "metadata.foo == bar",
expect: true,
},
"match via prefix": {
res: create("four", "zim", "gaz", "foo", "bar"),
filter: "(zim in metadata) and (metadata.zim matches `^g.`)",
expect: true,
},
"no match via prefix": {
res: create("four", "zim", "zzz", "foo", "bar"),
filter: "(zim in metadata) and (metadata.zim matches `^g.`)",
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
run(t, tc)
})
}
}