From b876d95dc26e8584fb932552091949f93ef63159 Mon Sep 17 00:00:00 2001 From: "R.B. Boyer" Date: Tue, 8 Aug 2023 10:14:42 -0500 Subject: [PATCH] mesh: add validation for the new pbmesh resources --- .../mesh/internal/types/computed_routes.go | 47 +- .../internal/types/computed_routes_test.go | 79 +++ .../mesh/internal/types/destination_policy.go | 142 ++++- .../internal/types/destination_policy_test.go | 413 +++++++++++++++ internal/mesh/internal/types/grpc_route.go | 186 ++++++- .../mesh/internal/types/grpc_route_test.go | 92 ++++ internal/mesh/internal/types/http_route.go | 492 +++++++++++++++++- .../mesh/internal/types/http_route_test.go | 236 +++++++++ .../internal/types/proxy_configuration.go | 5 +- internal/mesh/internal/types/tcp_route.go | 69 ++- .../mesh/internal/types/tcp_route_test.go | 79 +++ internal/mesh/internal/types/util.go | 52 ++ internal/mesh/internal/types/xroute.go | 20 + proto-public/pbmesh/v1alpha1/xroute_addons.go | 91 ++++ .../pbmesh/v1alpha1/xroute_addons_test.go | 174 +++++++ 15 files changed, 2166 insertions(+), 11 deletions(-) create mode 100644 internal/mesh/internal/types/computed_routes_test.go create mode 100644 internal/mesh/internal/types/destination_policy_test.go create mode 100644 internal/mesh/internal/types/grpc_route_test.go create mode 100644 internal/mesh/internal/types/http_route_test.go create mode 100644 internal/mesh/internal/types/tcp_route_test.go create mode 100644 internal/mesh/internal/types/util.go create mode 100644 internal/mesh/internal/types/xroute.go create mode 100644 proto-public/pbmesh/v1alpha1/xroute_addons.go create mode 100644 proto-public/pbmesh/v1alpha1/xroute_addons_test.go diff --git a/internal/mesh/internal/types/computed_routes.go b/internal/mesh/internal/types/computed_routes.go index aca627d554fae..3b25fbc8081dc 100644 --- a/internal/mesh/internal/types/computed_routes.go +++ b/internal/mesh/internal/types/computed_routes.go @@ -4,6 +4,8 @@ package types import ( + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/consul/internal/resource" pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v1alpha1" "github.com/hashicorp/consul/proto-public/pbresource" @@ -27,6 +29,49 @@ func RegisterComputedRoutes(r resource.Registry) { r.Register(resource.Registration{ Type: ComputedRoutesV1Alpha1Type, Proto: &pbmesh.ComputedRoutes{}, - Validate: nil, + Validate: ValidateComputedRoutes, }) } + +func ValidateComputedRoutes(res *pbresource.Resource) error { + var config pbmesh.ComputedRoutes + + if err := res.Data.UnmarshalTo(&config); err != nil { + return resource.NewErrDataParse(&config, err) + } + + var merr error + + if len(config.PortedConfigs) == 0 { + merr = multierror.Append(merr, resource.ErrInvalidField{ + Name: "ported_configs", + Wrapped: resource.ErrEmpty, + }) + } + + // TODO(rb): do more elaborate validation + + for port, pmc := range config.PortedConfigs { + wrapErr := func(err error) error { + return resource.ErrInvalidMapValue{ + Map: "ported_configs", + Key: port, + Wrapped: err, + } + } + if pmc.Config == nil { + merr = multierror.Append(merr, wrapErr(resource.ErrInvalidField{ + Name: "config", + Wrapped: resource.ErrEmpty, + })) + } + if len(pmc.Targets) == 0 { + merr = multierror.Append(merr, wrapErr(resource.ErrInvalidField{ + Name: "targets", + Wrapped: resource.ErrEmpty, + })) + } + } + + return merr +} diff --git a/internal/mesh/internal/types/computed_routes_test.go b/internal/mesh/internal/types/computed_routes_test.go new file mode 100644 index 0000000000000..7830d9680b8c5 --- /dev/null +++ b/internal/mesh/internal/types/computed_routes_test.go @@ -0,0 +1,79 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/internal/resource/resourcetest" + pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v1alpha1" + "github.com/hashicorp/consul/proto/private/prototest" + "github.com/hashicorp/consul/sdk/testutil" +) + +func TestValidateComputedRoutes(t *testing.T) { + type testcase struct { + routes *pbmesh.ComputedRoutes + expectErr string + } + + run := func(t *testing.T, tc testcase) { + res := resourcetest.Resource(ComputedRoutesType, "api"). + WithData(t, tc.routes). + Build() + + err := ValidateComputedRoutes(res) + + // Verify that validate didn't actually change the object. + got := resourcetest.MustDecode[pbmesh.ComputedRoutes, *pbmesh.ComputedRoutes](t, res) + prototest.AssertDeepEqual(t, tc.routes, got.Data) + + if tc.expectErr == "" { + require.NoError(t, err) + } else { + testutil.RequireErrorContains(t, err, tc.expectErr) + } + } + + cases := map[string]testcase{ + "empty": { + routes: &pbmesh.ComputedRoutes{}, + expectErr: `invalid "ported_configs" field: cannot be empty`, + }, + "empty targets": { + routes: &pbmesh.ComputedRoutes{ + PortedConfigs: map[string]*pbmesh.ComputedPortRoutes{ + "http": { + Config: &pbmesh.ComputedPortRoutes_Tcp{ + Tcp: &pbmesh.InterpretedTCPRoute{}, + }, + }, + }, + }, + expectErr: `invalid value of key "http" within ported_configs: invalid "targets" field: cannot be empty`, + }, + "valid": { + routes: &pbmesh.ComputedRoutes{ + PortedConfigs: map[string]*pbmesh.ComputedPortRoutes{ + "http": { + Config: &pbmesh.ComputedPortRoutes_Tcp{ + Tcp: &pbmesh.InterpretedTCPRoute{}, + }, + Targets: map[string]*pbmesh.BackendTargetDetails{ + "foo": {}, + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + run(t, tc) + }) + } +} diff --git a/internal/mesh/internal/types/destination_policy.go b/internal/mesh/internal/types/destination_policy.go index f92dbabdae03b..8a46c1f50ce4b 100644 --- a/internal/mesh/internal/types/destination_policy.go +++ b/internal/mesh/internal/types/destination_policy.go @@ -4,6 +4,11 @@ package types import ( + "errors" + "fmt" + + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/consul/internal/resource" pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v1alpha1" "github.com/hashicorp/consul/proto-public/pbresource" @@ -27,6 +32,141 @@ func RegisterDestinationPolicy(r resource.Registry) { r.Register(resource.Registration{ Type: DestinationPolicyV1Alpha1Type, Proto: &pbmesh.DestinationPolicy{}, - Validate: nil, + Validate: ValidateDestinationPolicy, }) } + +func ValidateDestinationPolicy(res *pbresource.Resource) error { + var policy pbmesh.DestinationPolicy + + if err := res.Data.UnmarshalTo(&policy); err != nil { + return resource.NewErrDataParse(&policy, err) + } + + var merr error + + if len(policy.PortConfigs) == 0 { + merr = multierror.Append(merr, resource.ErrInvalidField{ + Name: "port_configs", + Wrapped: resource.ErrEmpty, + }) + } + + for port, pc := range policy.PortConfigs { + wrapErr := func(err error) error { + return resource.ErrInvalidMapValue{ + Map: "port_configs", + Key: port, + Wrapped: err, + } + } + + if dur := pc.ConnectTimeout.AsDuration(); dur < 0 { + merr = multierror.Append(merr, wrapErr(resource.ErrInvalidField{ + Name: "connect_timeout", + Wrapped: fmt.Errorf("'%v', must be >= 0", dur), + })) + } + if dur := pc.RequestTimeout.AsDuration(); dur < 0 { + merr = multierror.Append(merr, wrapErr(resource.ErrInvalidField{ + Name: "request_timeout", + Wrapped: fmt.Errorf("'%v', must be >= 0", dur), + })) + } + + if pc.LoadBalancer != nil { + lb := pc.LoadBalancer + wrapLBErr := func(err error) error { + return wrapErr(resource.ErrInvalidMapValue{ + Map: "load_balancer", + Key: port, + Wrapped: err, + }) + } + + if lb.Policy != pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_RING_HASH && lb.Config != nil { + if _, ok := lb.Config.(*pbmesh.LoadBalancer_RingHashConfig); ok { + merr = multierror.Append(merr, wrapLBErr(resource.ErrInvalidField{ + Name: "config", + Wrapped: fmt.Errorf("RingHashConfig specified for incompatible load balancing policy %q", lb.Policy), + })) + } + } + + if lb.Policy != pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_LEAST_REQUEST && lb.Config != nil { + if _, ok := lb.Config.(*pbmesh.LoadBalancer_LeastRequestConfig); ok { + merr = multierror.Append(merr, wrapLBErr(resource.ErrInvalidField{ + Name: "config", + Wrapped: fmt.Errorf("LeastRequestConfig specified for incompatible load balancing policy %q", lb.Policy), + })) + } + } + + if !lb.Policy.IsHashBased() && len(lb.HashPolicies) > 0 { + merr = multierror.Append(merr, wrapLBErr(resource.ErrInvalidField{ + Name: "hash_policies", + Wrapped: fmt.Errorf("hash_policies specified for non-hash-based policy %q", lb.Policy), + })) + } + + for i, hp := range lb.HashPolicies { + wrapHPErr := func(err error) error { + return wrapLBErr(resource.ErrInvalidListElement{ + Name: "hash_policies", + Index: i, + Wrapped: err, + }) + } + + hasField := (hp.Field != pbmesh.HashPolicyField_HASH_POLICY_FIELD_UNSPECIFIED) + + if hp.SourceIp { + if hasField { + merr = multierror.Append(merr, wrapHPErr(resource.ErrInvalidField{ + Name: "field", + Wrapped: fmt.Errorf("a single hash policy cannot hash both a source address and a %q", hp.Field), + })) + } + if hp.FieldValue != "" { + merr = multierror.Append(merr, wrapHPErr(resource.ErrInvalidField{ + Name: "field_value", + Wrapped: errors.New("cannot be specified when hashing source_ip"), + })) + } + } + + if hasField && hp.FieldValue == "" { + merr = multierror.Append(merr, wrapHPErr(resource.ErrInvalidField{ + Name: "field_value", + Wrapped: fmt.Errorf("field %q was specified without a field_value", hp.Field), + })) + } + if hp.FieldValue != "" && !hasField { + merr = multierror.Append(merr, wrapHPErr(resource.ErrInvalidField{ + Name: "field_value", + Wrapped: errors.New("requires a field to apply to"), + })) + } + if hp.CookieConfig != nil { + if hp.Field != pbmesh.HashPolicyField_HASH_POLICY_FIELD_COOKIE { + merr = multierror.Append(merr, wrapHPErr(resource.ErrInvalidField{ + Name: "cookie_config", + Wrapped: fmt.Errorf("incompatible with field %q", hp.Field), + })) + } + if hp.CookieConfig.Session && hp.CookieConfig.Ttl.AsDuration() != 0 { + merr = multierror.Append(merr, wrapHPErr(resource.ErrInvalidField{ + Name: "cookie_config", + Wrapped: resource.ErrInvalidField{ + Name: "ttl", + Wrapped: fmt.Errorf("a session cookie cannot have an associated TTL"), + }, + })) + } + } + } + } + } + + return merr +} diff --git a/internal/mesh/internal/types/destination_policy_test.go b/internal/mesh/internal/types/destination_policy_test.go new file mode 100644 index 0000000000000..c295d0e801330 --- /dev/null +++ b/internal/mesh/internal/types/destination_policy_test.go @@ -0,0 +1,413 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package types + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/hashicorp/consul/internal/resource/resourcetest" + pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v1alpha1" + "github.com/hashicorp/consul/proto/private/prototest" + "github.com/hashicorp/consul/sdk/testutil" +) + +func TestValidateDestinationPolicy(t *testing.T) { + type testcase struct { + policy *pbmesh.DestinationPolicy + expectErr string + expectErrs []string + } + + run := func(t *testing.T, tc testcase) { + res := resourcetest.Resource(DestinationPolicyType, "api"). + WithData(t, tc.policy). + Build() + + err := ValidateDestinationPolicy(res) + + // Verify that validate didn't actually change the object. + got := resourcetest.MustDecode[pbmesh.DestinationPolicy, *pbmesh.DestinationPolicy](t, res) + prototest.AssertDeepEqual(t, tc.policy, got.Data) + + if tc.expectErr != "" && len(tc.expectErrs) > 0 { + t.Fatalf("cannot test singular and list errors at the same time") + } + + if tc.expectErr == "" && len(tc.expectErrs) == 0 { + require.NoError(t, err) + } else if tc.expectErr != "" { + testutil.RequireErrorContains(t, err, tc.expectErr) + } else { + for _, expectErr := range tc.expectErrs { + testutil.RequireErrorContains(t, err, expectErr) + } + } + } + + cases := map[string]testcase{ + // emptiness + "empty": { + policy: &pbmesh.DestinationPolicy{}, + expectErr: `invalid "port_configs" field: cannot be empty`, + }, + "good connect timeout": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + }, + }, + }, + }, + "bad connect timeout": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(-55 * time.Second), + }, + }, + }, + expectErr: `invalid value of key "http" within port_configs: invalid "connect_timeout" field: '-55s', must be >= 0`, + }, + "good request timeout": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + RequestTimeout: durationpb.New(55 * time.Second), + }, + }, + }, + }, + "bad request timeout": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + RequestTimeout: durationpb.New(-55 * time.Second), + }, + }, + }, + expectErr: `invalid value of key "http" within port_configs: invalid "request_timeout" field: '-55s', must be >= 0`, + }, + // load balancer + "lbpolicy: supported": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_RANDOM, + }, + }, + }, + }, + }, + "lbpolicy: bad for least request config": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_RING_HASH, + Config: &pbmesh.LoadBalancer_LeastRequestConfig{ + LeastRequestConfig: &pbmesh.LeastRequestConfig{ + ChoiceCount: 10, + }, + }, + }, + }, + }, + }, + expectErr: `invalid value of key "http" within port_configs: invalid value of key "http" within load_balancer: invalid "config" field: LeastRequestConfig specified for incompatible load balancing policy "LOAD_BALANCER_POLICY_RING_HASH"`, + }, + "lbpolicy: bad for ring hash config": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_LEAST_REQUEST, + Config: &pbmesh.LoadBalancer_RingHashConfig{ + RingHashConfig: &pbmesh.RingHashConfig{ + MinimumRingSize: 1024, + }, + }, + }, + }, + }, + }, + expectErr: `invalid value of key "http" within port_configs: invalid value of key "http" within load_balancer: invalid "config" field: RingHashConfig specified for incompatible load balancing policy "LOAD_BALANCER_POLICY_LEAST_REQUEST"`, + }, + "lbpolicy: good for least request config": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_LEAST_REQUEST, + Config: &pbmesh.LoadBalancer_LeastRequestConfig{ + LeastRequestConfig: &pbmesh.LeastRequestConfig{ + ChoiceCount: 10, + }, + }, + }, + }, + }, + }, + }, + "lbpolicy: good for ring hash config": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_RING_HASH, + Config: &pbmesh.LoadBalancer_RingHashConfig{ + RingHashConfig: &pbmesh.RingHashConfig{ + MinimumRingSize: 1024, + }, + }, + }, + }, + }, + }, + }, + "lbpolicy: empty policy with hash policy": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + HashPolicies: []*pbmesh.HashPolicy{ + {SourceIp: true}, + }, + }, + }, + }, + }, + expectErr: `invalid value of key "http" within port_configs: invalid value of key "http" within load_balancer: invalid "hash_policies" field: hash_policies specified for non-hash-based policy "LOAD_BALANCER_POLICY_UNSPECIFIED"`, + }, + "lbconfig: cookie config with header policy": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_MAGLEV, + HashPolicies: []*pbmesh.HashPolicy{ + { + Field: pbmesh.HashPolicyField_HASH_POLICY_FIELD_HEADER, + FieldValue: "x-user-id", + CookieConfig: &pbmesh.CookieConfig{ + Ttl: durationpb.New(10 * time.Second), + Path: "/root", + }, + }, + }, + }, + }, + }, + }, + expectErr: `invalid value of key "http" within port_configs: invalid value of key "http" within load_balancer: invalid element at index 0 of list "hash_policies": invalid "cookie_config" field: incompatible with field "HASH_POLICY_FIELD_HEADER"`, + }, + "lbconfig: cannot generate session cookie with ttl": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_MAGLEV, + HashPolicies: []*pbmesh.HashPolicy{ + { + Field: pbmesh.HashPolicyField_HASH_POLICY_FIELD_COOKIE, + FieldValue: "good-cookie", + CookieConfig: &pbmesh.CookieConfig{ + Session: true, + Ttl: durationpb.New(10 * time.Second), + }, + }, + }, + }, + }, + }, + }, + expectErr: `invalid value of key "http" within port_configs: invalid value of key "http" within load_balancer: invalid element at index 0 of list "hash_policies": invalid "cookie_config" field: invalid "ttl" field: a session cookie cannot have an associated TTL`, + }, + "lbconfig: valid cookie policy": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_MAGLEV, + HashPolicies: []*pbmesh.HashPolicy{ + { + Field: pbmesh.HashPolicyField_HASH_POLICY_FIELD_COOKIE, + FieldValue: "good-cookie", + CookieConfig: &pbmesh.CookieConfig{ + Ttl: durationpb.New(10 * time.Second), + Path: "/oven", + }, + }, + }, + }, + }, + }, + }, + }, + "lbconfig: supported match field": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_MAGLEV, + HashPolicies: []*pbmesh.HashPolicy{ + { + Field: pbmesh.HashPolicyField_HASH_POLICY_FIELD_HEADER, + FieldValue: "X-Consul-Token", + }, + }, + }, + }, + }, + }, + }, + "lbconfig: cannot match on source address and custom field": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_MAGLEV, + HashPolicies: []*pbmesh.HashPolicy{ + { + Field: pbmesh.HashPolicyField_HASH_POLICY_FIELD_HEADER, + SourceIp: true, + }, + }, + }, + }, + }, + }, + expectErrs: []string{ + `invalid value of key "http" within port_configs: invalid value of key "http" within load_balancer: invalid element at index 0 of list "hash_policies": invalid "field" field: a single hash policy cannot hash both a source address and a "HASH_POLICY_FIELD_HEADER"`, + `invalid value of key "http" within port_configs: invalid value of key "http" within load_balancer: invalid element at index 0 of list "hash_policies": invalid "field_value" field: field "HASH_POLICY_FIELD_HEADER" was specified without a field_value`, + }, + }, + "lbconfig: matchvalue not compatible with source address": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_MAGLEV, + HashPolicies: []*pbmesh.HashPolicy{ + { + FieldValue: "X-Consul-Token", + SourceIp: true, + }, + }, + }, + }, + }, + }, + expectErrs: []string{ + `invalid value of key "http" within port_configs: invalid value of key "http" within load_balancer: invalid element at index 0 of list "hash_policies": invalid "field_value" field: cannot be specified when hashing source_ip`, + `invalid value of key "http" within port_configs: invalid value of key "http" within load_balancer: invalid element at index 0 of list "hash_policies": invalid "field_value" field: requires a field to apply to`, + }, + }, + "lbconfig: field without match value": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_MAGLEV, + HashPolicies: []*pbmesh.HashPolicy{ + { + Field: pbmesh.HashPolicyField_HASH_POLICY_FIELD_HEADER, + }, + }, + }, + }, + }, + }, + expectErr: `invalid value of key "http" within port_configs: invalid value of key "http" within load_balancer: invalid element at index 0 of list "hash_policies": invalid "field_value" field: field "HASH_POLICY_FIELD_HEADER" was specified without a field_value`, + }, + "lbconfig: matchvalue without field": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_MAGLEV, + HashPolicies: []*pbmesh.HashPolicy{ + { + FieldValue: "my-cookie", + }, + }, + }, + }, + }, + }, + expectErr: `invalid value of key "http" within port_configs: invalid value of key "http" within load_balancer: invalid element at index 0 of list "hash_policies": invalid "field_value" field: requires a field to apply to`, + }, + "lbconfig: ring hash kitchen sink": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_RING_HASH, + Config: &pbmesh.LoadBalancer_RingHashConfig{ + RingHashConfig: &pbmesh.RingHashConfig{ + MaximumRingSize: 10, + MinimumRingSize: 2, + }, + }, + HashPolicies: []*pbmesh.HashPolicy{ + { + Field: pbmesh.HashPolicyField_HASH_POLICY_FIELD_COOKIE, + FieldValue: "my-cookie", + }, + { + Field: pbmesh.HashPolicyField_HASH_POLICY_FIELD_HEADER, + FieldValue: "alt-header", + Terminal: true, + }, + }, + }, + }, + }, + }, + }, + "lbconfig: least request kitchen sink": { + policy: &pbmesh.DestinationPolicy{ + PortConfigs: map[string]*pbmesh.DestinationConfig{ + "http": { + ConnectTimeout: durationpb.New(55 * time.Second), + LoadBalancer: &pbmesh.LoadBalancer{ + Policy: pbmesh.LoadBalancerPolicy_LOAD_BALANCER_POLICY_LEAST_REQUEST, + Config: &pbmesh.LoadBalancer_LeastRequestConfig{ + LeastRequestConfig: &pbmesh.LeastRequestConfig{ + ChoiceCount: 10, + }, + }, + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + run(t, tc) + }) + } +} diff --git a/internal/mesh/internal/types/grpc_route.go b/internal/mesh/internal/types/grpc_route.go index eacf5a245b0f4..7cdd25d18413d 100644 --- a/internal/mesh/internal/types/grpc_route.go +++ b/internal/mesh/internal/types/grpc_route.go @@ -4,6 +4,11 @@ package types import ( + "errors" + "fmt" + + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/consul/internal/resource" pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v1alpha1" "github.com/hashicorp/consul/proto-public/pbresource" @@ -25,8 +30,183 @@ var ( func RegisterGRPCRoute(r resource.Registry) { r.Register(resource.Registration{ - Type: GRPCRouteV1Alpha1Type, - Proto: &pbmesh.GRPCRoute{}, - Validate: nil, + Type: GRPCRouteV1Alpha1Type, + Proto: &pbmesh.GRPCRoute{}, + // TODO(rb): normalize parent/backend ref tenancies in a Mutate hook + Validate: ValidateGRPCRoute, }) } + +func ValidateGRPCRoute(res *pbresource.Resource) error { + var route pbmesh.GRPCRoute + + // TODO(rb):sync common stuff from HTTPRoute + + if err := res.Data.UnmarshalTo(&route); err != nil { + return resource.NewErrDataParse(&route, err) + } + + var merr error + if err := validateParentRefs(route.ParentRefs); err != nil { + merr = multierror.Append(merr, err) + } + + if len(route.Hostnames) > 0 { + merr = multierror.Append(merr, resource.ErrInvalidField{ + Name: "hostnames", + Wrapped: errors.New("should not populate hostnames"), + }) + } + + for i, rule := range route.Rules { + wrapRuleErr := func(err error) error { + return resource.ErrInvalidListElement{ + Name: "rules", + Index: i, + Wrapped: err, + } + } + + // TODO(rb): port a bunch of validation from ServiceRouterConfigEntry.Validate + + for j, match := range rule.Matches { + wrapMatchErr := func(err error) error { + return wrapRuleErr(resource.ErrInvalidListElement{ + Name: "matches", + Index: j, + Wrapped: err, + }) + } + + if match.Method != nil && match.Method.Type == pbmesh.GRPCMethodMatchType_GRPC_METHOD_MATCH_TYPE_UNSPECIFIED { + merr = multierror.Append(merr, wrapMatchErr( + resource.ErrInvalidField{ + Name: "method", + Wrapped: resource.ErrMissing, + }, + )) + } + + for k, header := range match.Headers { + wrapMatchHeaderErr := func(err error) error { + return wrapRuleErr(resource.ErrInvalidListElement{ + Name: "headers", + Index: k, + Wrapped: err, + }) + } + + if header.Type == pbmesh.HeaderMatchType_HEADER_MATCH_TYPE_UNSPECIFIED { + merr = multierror.Append(merr, wrapMatchHeaderErr( + resource.ErrInvalidField{ + Name: "type", + Wrapped: resource.ErrMissing, + }, + )) + } + } + } + + for j, filter := range rule.Filters { + wrapFilterErr := func(err error) error { + return wrapRuleErr(resource.ErrInvalidListElement{ + Name: "filters", + Index: j, + Wrapped: err, + }) + } + set := 0 + if filter.RequestHeaderModifier != nil { + set++ + } + if filter.ResponseHeaderModifier != nil { + set++ + } + if filter.UrlRewrite != nil { + set++ + if filter.UrlRewrite.PathPrefix == "" { + merr = multierror.Append(merr, wrapFilterErr( + resource.ErrInvalidField{ + Name: "url_rewrite", + Wrapped: resource.ErrInvalidField{ + Name: "path_prefix", + Wrapped: errors.New("field should not be empty if enclosing section is set"), + }, + }, + )) + } + } + if set != 1 { + merr = multierror.Append(merr, wrapFilterErr( + errors.New("exactly one of request_header_modifier, response_header_modifier, or url_rewrite"), + )) + } + } + + if len(rule.BackendRefs) == 0 { + /* + BackendRefs (optional)¶ + + BackendRefs defines API objects where matching requests should be + sent. If unspecified, the rule performs no forwarding. If + unspecified and no filters are specified that would result in a + response being sent, a 404 error code is returned. + */ + merr = multierror.Append(merr, wrapRuleErr( + resource.ErrInvalidField{ + Name: "backend_refs", + Wrapped: resource.ErrEmpty, + }, + )) + } + for j, hbref := range rule.BackendRefs { + wrapBackendRefErr := func(err error) error { + return wrapRuleErr(resource.ErrInvalidListElement{ + Name: "backend_refs", + Index: j, + Wrapped: err, + }) + } + for _, err := range validateBackendRef(hbref.BackendRef) { + merr = multierror.Append(merr, wrapBackendRefErr( + resource.ErrInvalidField{ + Name: "backend_ref", + Wrapped: err, + }, + )) + } + + if len(hbref.Filters) > 0 { + merr = multierror.Append(merr, wrapBackendRefErr( + resource.ErrInvalidField{ + Name: "filters", + Wrapped: errors.New("filters are not supported at this level yet"), + }, + )) + } + } + + if rule.Timeouts != nil { + // TODO(rb): validate timeouts + } + if rule.Retries != nil { + // TODO(rb): validate retries + for j, condition := range rule.Retries.OnConditions { + if !isValidRetryCondition(condition) { + merr = multierror.Append(merr, wrapRuleErr( + resource.ErrInvalidListElement{ + Name: "retries", + Index: j, + Wrapped: resource.ErrInvalidField{ + Name: "on_conditions", + Wrapped: fmt.Errorf("not a valid retry condition: %q", condition), + }, + }, + )) + } + } + } + } + + return merr +} diff --git a/internal/mesh/internal/types/grpc_route_test.go b/internal/mesh/internal/types/grpc_route_test.go new file mode 100644 index 0000000000000..466342f056e58 --- /dev/null +++ b/internal/mesh/internal/types/grpc_route_test.go @@ -0,0 +1,92 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/internal/catalog" + "github.com/hashicorp/consul/internal/resource/resourcetest" + pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v1alpha1" + "github.com/hashicorp/consul/proto/private/prototest" + "github.com/hashicorp/consul/sdk/testutil" +) + +func TestValidateGRPCRoute(t *testing.T) { + type testcase struct { + route *pbmesh.GRPCRoute + expectErr string + } + + run := func(t *testing.T, tc testcase) { + res := resourcetest.Resource(GRPCRouteType, "api"). + WithData(t, tc.route). + Build() + + err := ValidateGRPCRoute(res) + + // Verify that validate didn't actually change the object. + got := resourcetest.MustDecode[pbmesh.GRPCRoute, *pbmesh.GRPCRoute](t, res) + prototest.AssertDeepEqual(t, tc.route, got.Data) + + if tc.expectErr == "" { + require.NoError(t, err) + } else { + testutil.RequireErrorContains(t, err, tc.expectErr) + } + } + + cases := map[string]testcase{ + "hostnames not supported for services": { + route: &pbmesh.GRPCRoute{ + ParentRefs: []*pbmesh.ParentReference{ + newParentRef(catalog.ServiceType, "web", ""), + }, + Hostnames: []string{"foo.local"}, + }, + expectErr: `invalid "hostnames" field: should not populate hostnames`, + }, + } + + // TODO(rb): add rest of tests for the matching logic + // TODO(rb): add rest of tests for the retry and timeout logic + + // Add common parent refs test cases. + for name, parentTC := range getXRouteParentRefTestCases() { + cases["parent-ref: "+name] = testcase{ + route: &pbmesh.GRPCRoute{ + ParentRefs: parentTC.refs, + }, + expectErr: parentTC.expectErr, + } + } + // add common backend ref test cases. + for name, backendTC := range getXRouteBackendRefTestCases() { + var refs []*pbmesh.GRPCBackendRef + for _, br := range backendTC.refs { + refs = append(refs, &pbmesh.GRPCBackendRef{ + BackendRef: br, + }) + } + cases["backend-ref: "+name] = testcase{ + route: &pbmesh.GRPCRoute{ + ParentRefs: []*pbmesh.ParentReference{ + newParentRef(catalog.ServiceType, "web", ""), + }, + Rules: []*pbmesh.GRPCRouteRule{ + {BackendRefs: refs}, + }, + }, + expectErr: backendTC.expectErr, + } + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + run(t, tc) + }) + } +} diff --git a/internal/mesh/internal/types/http_route.go b/internal/mesh/internal/types/http_route.go index bd2a4a43f8461..c9391ffcbb695 100644 --- a/internal/mesh/internal/types/http_route.go +++ b/internal/mesh/internal/types/http_route.go @@ -4,6 +4,14 @@ package types import ( + "errors" + "fmt" + "net/http" + "strings" + + "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/v1alpha1" "github.com/hashicorp/consul/proto-public/pbresource" @@ -27,6 +35,488 @@ func RegisterHTTPRoute(r resource.Registry) { r.Register(resource.Registration{ Type: HTTPRouteV1Alpha1Type, Proto: &pbmesh.HTTPRoute{}, - Validate: nil, + Mutate: MutateHTTPRoute, + Validate: ValidateHTTPRoute, }) } + +func MutateHTTPRoute(res *pbresource.Resource) error { + var route pbmesh.HTTPRoute + + if err := res.Data.UnmarshalTo(&route); err != nil { + return resource.NewErrDataParse(&route, err) + } + + changed := false + + for _, rule := range route.Rules { + for _, match := range rule.Matches { + if match.Method != "" { + norm := strings.ToUpper(match.Method) + if match.Method != norm { + match.Method = norm + changed = true + } + } + } + } + + // TODO(rb): normalize parent/backend ref tenancies + + if !changed { + return nil + } + + return res.Data.MarshalFrom(&route) +} + +func ValidateHTTPRoute(res *pbresource.Resource) error { + var route pbmesh.HTTPRoute + + if err := res.Data.UnmarshalTo(&route); err != nil { + return resource.NewErrDataParse(&route, err) + } + + var merr error + if err := validateParentRefs(route.ParentRefs); err != nil { + merr = multierror.Append(merr, err) + } + + if len(route.Hostnames) > 0 { + merr = multierror.Append(merr, resource.ErrInvalidField{ + Name: "hostnames", + Wrapped: errors.New("should not populate hostnames"), + }) + } + + for i, rule := range route.Rules { + wrapRuleErr := func(err error) error { + return resource.ErrInvalidListElement{ + Name: "rules", + Index: i, + Wrapped: err, + } + } + + // TODO(rb): port a bunch of validation from ServiceRouterConfigEntry.Validate + + for j, match := range rule.Matches { + wrapMatchErr := func(err error) error { + return wrapRuleErr(resource.ErrInvalidListElement{ + Name: "matches", + Index: j, + Wrapped: err, + }) + } + + if match.Path != nil { + wrapMatchPathErr := func(err error) error { + return wrapMatchErr(resource.ErrInvalidField{ + Name: "path", + Wrapped: err, + }) + } + switch match.Path.Type { + case pbmesh.PathMatchType_PATH_MATCH_TYPE_UNSPECIFIED: + merr = multierror.Append(merr, wrapMatchPathErr( + resource.ErrInvalidField{ + Name: "type", + Wrapped: resource.ErrMissing, + }, + )) + case pbmesh.PathMatchType_PATH_MATCH_TYPE_EXACT: + if !strings.HasPrefix(match.Path.Value, "/") { + merr = multierror.Append(merr, wrapMatchPathErr( + resource.ErrInvalidField{ + Name: "value", + Wrapped: fmt.Errorf("exact patch value does not start with '/': %q", match.Path.Value), + }, + )) + } + case pbmesh.PathMatchType_PATH_MATCH_TYPE_PREFIX: + if !strings.HasPrefix(match.Path.Value, "/") { + merr = multierror.Append(merr, wrapMatchPathErr( + resource.ErrInvalidField{ + Name: "value", + Wrapped: fmt.Errorf("prefix patch value does not start with '/': %q", match.Path.Value), + }, + )) + } + } + } + + for k, hdr := range match.Headers { + wrapMatchHeaderErr := func(err error) error { + return wrapMatchErr(resource.ErrInvalidListElement{ + Name: "headers", + Index: k, + Wrapped: err, + }) + } + if hdr.Type == pbmesh.HeaderMatchType_HEADER_MATCH_TYPE_UNSPECIFIED { + merr = multierror.Append(merr, wrapMatchHeaderErr( + resource.ErrInvalidField{ + Name: "type", + Wrapped: resource.ErrMissing, + }), + ) + } + if hdr.Name == "" { + merr = multierror.Append(merr, wrapMatchHeaderErr( + resource.ErrInvalidField{ + Name: "name", + Wrapped: resource.ErrMissing, + }), + ) + } + } + + for k, qm := range match.QueryParams { + wrapMatchParamErr := func(err error) error { + return wrapMatchErr(resource.ErrInvalidListElement{ + Name: "query_params", + Index: k, + Wrapped: err, + }) + } + if qm.Type == pbmesh.QueryParamMatchType_QUERY_PARAM_MATCH_TYPE_UNSPECIFIED { + merr = multierror.Append(merr, wrapMatchParamErr( + resource.ErrInvalidField{ + Name: "type", + Wrapped: resource.ErrMissing, + }), + ) + } + if qm.Name == "" { + merr = multierror.Append(merr, wrapMatchParamErr( + resource.ErrInvalidField{ + Name: "name", + Wrapped: resource.ErrMissing, + }), + ) + } + } + + // This is just a simple placeholder validation stolen from the + // config entry version. + if match.Method != "" && !isValidHTTPMethod(match.Method) { + merr = multierror.Append(merr, wrapMatchErr( + resource.ErrInvalidField{ + Name: "method", + Wrapped: fmt.Errorf("not a valid http method: %q", match.Method), + }, + )) + } + } + + for j, filter := range rule.Filters { + wrapFilterErr := func(err error) error { + return wrapRuleErr(resource.ErrInvalidListElement{ + Name: "filters", + Index: j, + Wrapped: err, + }) + } + set := 0 + if filter.RequestHeaderModifier != nil { + set++ + } + if filter.ResponseHeaderModifier != nil { + set++ + } + if filter.UrlRewrite != nil { + set++ + if filter.UrlRewrite.PathPrefix == "" { + merr = multierror.Append(merr, wrapFilterErr( + resource.ErrInvalidField{ + Name: "url_rewrite", + Wrapped: resource.ErrInvalidField{ + Name: "path_prefix", + Wrapped: resource.ErrMissing, + }, + }, + )) + } + } + if set != 1 { + merr = multierror.Append(merr, wrapFilterErr( + errors.New("exactly one of request_header_modifier, response_header_modifier, or url_rewrite"), + )) + } + } + + if len(rule.BackendRefs) == 0 { + /* + BackendRefs (optional)¶ + + BackendRefs defines API objects where matching requests should be + sent. If unspecified, the rule performs no forwarding. If + unspecified and no filters are specified that would result in a + response being sent, a 404 error code is returned. + */ + merr = multierror.Append(merr, wrapRuleErr( + resource.ErrInvalidField{ + Name: "backend_refs", + Wrapped: resource.ErrEmpty, + }, + )) + } + for j, hbref := range rule.BackendRefs { + wrapBackendRefErr := func(err error) error { + return wrapRuleErr(resource.ErrInvalidListElement{ + Name: "backend_refs", + Index: j, + Wrapped: err, + }) + } + + for _, err := range validateBackendRef(hbref.BackendRef) { + merr = multierror.Append(merr, wrapBackendRefErr( + resource.ErrInvalidField{ + Name: "backend_ref", + Wrapped: err, + }, + )) + } + + if len(hbref.Filters) > 0 { + merr = multierror.Append(merr, wrapBackendRefErr( + resource.ErrInvalidField{ + Name: "filters", + Wrapped: errors.New("filters are not supported at this level yet"), + }, + )) + } + } + + if rule.Timeouts != nil { + // TODO(rb): validate timeouts + } + if rule.Retries != nil { + // TODO(rb): validate retries + for j, condition := range rule.Retries.OnConditions { + if !isValidRetryCondition(condition) { + merr = multierror.Append(merr, wrapRuleErr( + resource.ErrInvalidListElement{ + Name: "retries", + Index: j, + Wrapped: resource.ErrInvalidField{ + Name: "on_conditions", + Wrapped: fmt.Errorf("not a valid retry condition: %q", condition), + }, + }, + )) + } + } + } + } + + return merr +} + +func validateBackendRef(backendRef *pbmesh.BackendReference) []error { + var errs []error + if backendRef == nil { + errs = append(errs, resource.ErrMissing) + + } else if backendRef.Ref == nil { + errs = append(errs, resource.ErrInvalidField{ + Name: "ref", + Wrapped: resource.ErrMissing, + }) + + } else { + if !IsServiceType(backendRef.Ref.Type) { + errs = append(errs, resource.ErrInvalidField{ + Name: "ref", + Wrapped: resource.ErrInvalidReferenceType{ + AllowedType: catalog.ServiceType, + }, + }) + } + + if backendRef.Ref.Section != "" { + errs = append(errs, resource.ErrInvalidField{ + Name: "ref", + Wrapped: resource.ErrInvalidField{ + Name: "section", + Wrapped: errors.New("section not supported for service backend refs"), + }, + }) + } + + if backendRef.Datacenter != "" { + errs = append(errs, resource.ErrInvalidField{ + Name: "datacenter", + Wrapped: errors.New("datacenter is not yet supported on backend refs"), + }) + } + } + return errs +} + +type portedRefKey struct { + Key resource.ReferenceKey + Port string +} + +func validateParentRefs(parentRefs []*pbmesh.ParentReference) error { + var merr error + if len(parentRefs) == 0 { + merr = multierror.Append(merr, resource.ErrInvalidField{ + Name: "parent_refs", + Wrapped: resource.ErrEmpty, + }) + } + + var ( + seen = make(map[portedRefKey]struct{}) + seenAny = make(map[resource.ReferenceKey][]string) + ) + for i, parent := range parentRefs { + wrapErr := func(err error) error { + return resource.ErrInvalidListElement{ + Name: "parent_refs", + Index: i, + Wrapped: err, + } + } + if parent.Ref == nil { + merr = multierror.Append(merr, wrapErr( + resource.ErrInvalidField{ + Name: "ref", + Wrapped: resource.ErrMissing, + }, + )) + } else { + if !IsServiceType(parent.Ref.Type) { + merr = multierror.Append(merr, wrapErr( + resource.ErrInvalidField{ + Name: "ref", + Wrapped: resource.ErrInvalidReferenceType{ + AllowedType: catalog.ServiceType, + }, + }, + )) + } + if parent.Ref.Section != "" { + merr = multierror.Append(merr, wrapErr( + resource.ErrInvalidField{ + Name: "ref", + Wrapped: resource.ErrInvalidField{ + Name: "section", + Wrapped: errors.New("section not supported for service parent refs"), + }, + }, + )) + } + + prk := portedRefKey{ + Key: resource.NewReferenceKey(parent.Ref), + Port: parent.Port, + } + + _, portExist := seen[prk] + + if parent.Port == "" { + coveredPorts, exactExists := seenAny[prk.Key] + + if portExist { // check for duplicate wild + merr = multierror.Append(merr, wrapErr( + resource.ErrInvalidField{ + Name: "ref", + Wrapped: fmt.Errorf( + "parent ref %q for wildcard port exists twice", + resource.ReferenceToString(parent.Ref), + ), + }, + )) + } else if exactExists { // check for existing exact + merr = multierror.Append(merr, wrapErr( + resource.ErrInvalidField{ + Name: "ref", + Wrapped: fmt.Errorf( + "parent ref %q for ports %v covered by wildcard port already", + resource.ReferenceToString(parent.Ref), + coveredPorts, + ), + }, + )) + } else { + seen[prk] = struct{}{} + } + + } else { + prkWild := prk + prkWild.Port = "" + _, wildExist := seen[prkWild] + + if portExist { // check for duplicate exact + merr = multierror.Append(merr, wrapErr( + resource.ErrInvalidField{ + Name: "ref", + Wrapped: fmt.Errorf( + "parent ref %q for port %q exists twice", + resource.ReferenceToString(parent.Ref), + parent.Port, + ), + }, + )) + } else if wildExist { // check for existing wild + merr = multierror.Append(merr, wrapErr( + resource.ErrInvalidField{ + Name: "ref", + Wrapped: fmt.Errorf( + "parent ref %q for port %q covered by wildcard port already", + resource.ReferenceToString(parent.Ref), + parent.Port, + ), + }, + )) + } else { + seen[prk] = struct{}{} + seenAny[prk.Key] = append(seenAny[prk.Key], parent.Port) + } + } + } + } + + return merr +} + +func isValidHTTPMethod(method string) bool { + switch method { + case http.MethodGet, + http.MethodHead, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodConnect, + http.MethodOptions, + http.MethodTrace: + return true + default: + return false + } +} + +func isValidRetryCondition(retryOn string) bool { + switch retryOn { + case "5xx", + "gateway-error", + "reset", + "connect-failure", + "envoy-ratelimited", + "retriable-4xx", + "refused-stream", + "cancelled", + "deadline-exceeded", + "internal", + "resource-exhausted", + "unavailable": + return true + default: + return false + } +} diff --git a/internal/mesh/internal/types/http_route_test.go b/internal/mesh/internal/types/http_route_test.go new file mode 100644 index 0000000000000..68c5570148c49 --- /dev/null +++ b/internal/mesh/internal/types/http_route_test.go @@ -0,0 +1,236 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/internal/catalog" + "github.com/hashicorp/consul/internal/resource/resourcetest" + pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v1alpha1" + "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/proto/private/prototest" + "github.com/hashicorp/consul/sdk/testutil" +) + +// TODO(rb): add mutation tests + +func TestValidateHTTPRoute(t *testing.T) { + type testcase struct { + route *pbmesh.HTTPRoute + expectErr string + } + + run := func(t *testing.T, tc testcase) { + res := resourcetest.Resource(HTTPRouteType, "api"). + WithData(t, tc.route). + Build() + + err := ValidateHTTPRoute(res) + + // Verify that validate didn't actually change the object. + got := resourcetest.MustDecode[pbmesh.HTTPRoute, *pbmesh.HTTPRoute](t, res) + prototest.AssertDeepEqual(t, tc.route, got.Data) + + if tc.expectErr == "" { + require.NoError(t, err) + } else { + testutil.RequireErrorContains(t, err, tc.expectErr) + } + } + + cases := map[string]testcase{ + "hostnames not supported for services": { + route: &pbmesh.HTTPRoute{ + ParentRefs: []*pbmesh.ParentReference{ + newParentRef(catalog.ServiceType, "web", ""), + }, + Hostnames: []string{"foo.local"}, + }, + expectErr: `invalid "hostnames" field: should not populate hostnames`, + }, + } + + // TODO(rb): add rest of tests for the matching logic + // TODO(rb): add rest of tests for the retry and timeout logic + + // Add common parent refs test cases. + for name, parentTC := range getXRouteParentRefTestCases() { + cases["parent-ref: "+name] = testcase{ + route: &pbmesh.HTTPRoute{ + ParentRefs: parentTC.refs, + }, + expectErr: parentTC.expectErr, + } + } + // add common backend ref test cases. + for name, backendTC := range getXRouteBackendRefTestCases() { + var refs []*pbmesh.HTTPBackendRef + for _, br := range backendTC.refs { + refs = append(refs, &pbmesh.HTTPBackendRef{ + BackendRef: br, + }) + } + cases["backend-ref: "+name] = testcase{ + route: &pbmesh.HTTPRoute{ + ParentRefs: []*pbmesh.ParentReference{ + newParentRef(catalog.ServiceType, "web", ""), + }, + Rules: []*pbmesh.HTTPRouteRule{ + {BackendRefs: refs}, + }, + }, + expectErr: backendTC.expectErr, + } + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + run(t, tc) + }) + } +} + +type xRouteParentRefTestcase struct { + refs []*pbmesh.ParentReference + expectErr string +} + +func getXRouteParentRefTestCases() map[string]xRouteParentRefTestcase { + return map[string]xRouteParentRefTestcase{ + "no parent refs": { + expectErr: `invalid "parent_refs" field: cannot be empty`, + }, + "parent ref with nil ref": { + refs: []*pbmesh.ParentReference{ + newParentRef(catalog.ServiceType, "api", ""), + { + Ref: nil, + Port: "http", + }, + }, + expectErr: `invalid element at index 1 of list "parent_refs": invalid "ref" field: missing required field`, + }, + "parent ref with bad type ref": { + refs: []*pbmesh.ParentReference{ + newParentRef(catalog.ServiceType, "api", ""), + newParentRef(catalog.WorkloadType, "api", ""), + }, + expectErr: `invalid element at index 1 of list "parent_refs": invalid "ref" field: reference must have type catalog.v1alpha1.Service`, + }, + "parent ref with section": { + refs: []*pbmesh.ParentReference{ + newParentRef(catalog.ServiceType, "api", ""), + { + Ref: resourcetest.Resource(catalog.ServiceType, "web").Reference("section2"), + Port: "http", + }, + }, + expectErr: `invalid element at index 1 of list "parent_refs": invalid "ref" field: invalid "section" field: section not supported for service parent refs`, + }, + "duplicate exact parents": { + refs: []*pbmesh.ParentReference{ + newParentRef(catalog.ServiceType, "api", "http"), + newParentRef(catalog.ServiceType, "api", "http"), + }, + expectErr: `invalid element at index 1 of list "parent_refs": invalid "ref" field: parent ref "catalog.v1alpha1.Service/default.local.default/api" for port "http" exists twice`, + }, + "duplicate wild parents": { + refs: []*pbmesh.ParentReference{ + newParentRef(catalog.ServiceType, "api", ""), + newParentRef(catalog.ServiceType, "api", ""), + }, + expectErr: `invalid element at index 1 of list "parent_refs": invalid "ref" field: parent ref "catalog.v1alpha1.Service/default.local.default/api" for wildcard port exists twice`, + }, + "duplicate parents via exact+wild overlap": { + refs: []*pbmesh.ParentReference{ + newParentRef(catalog.ServiceType, "api", "http"), + newParentRef(catalog.ServiceType, "api", ""), + }, + expectErr: `invalid element at index 1 of list "parent_refs": invalid "ref" field: parent ref "catalog.v1alpha1.Service/default.local.default/api" for ports [http] covered by wildcard port already`, + }, + "good single parent ref": { + refs: []*pbmesh.ParentReference{ + newParentRef(catalog.ServiceType, "api", "http"), + }, + }, + "good muliple parent refs": { + refs: []*pbmesh.ParentReference{ + newParentRef(catalog.ServiceType, "api", "http"), + newParentRef(catalog.ServiceType, "web", ""), + }, + }, + } +} + +type xRouteBackendRefTestcase struct { + refs []*pbmesh.BackendReference + expectErr string +} + +func getXRouteBackendRefTestCases() map[string]xRouteBackendRefTestcase { + return map[string]xRouteBackendRefTestcase{ + "no backend refs": { + expectErr: `invalid "backend_refs" field: cannot be empty`, + }, + "backend ref with nil ref": { + refs: []*pbmesh.BackendReference{ + newBackendRef(catalog.ServiceType, "api", ""), + { + Ref: nil, + Port: "http", + }, + }, + expectErr: `invalid element at index 0 of list "rules": invalid element at index 1 of list "backend_refs": invalid "backend_ref" field: invalid "ref" field: missing required field`, + }, + "backend ref with bad type ref": { + refs: []*pbmesh.BackendReference{ + newBackendRef(catalog.ServiceType, "api", ""), + newBackendRef(catalog.WorkloadType, "api", ""), + }, + expectErr: `invalid element at index 0 of list "rules": invalid element at index 1 of list "backend_refs": invalid "backend_ref" field: invalid "ref" field: reference must have type catalog.v1alpha1.Service`, + }, + "backend ref with section": { + refs: []*pbmesh.BackendReference{ + newBackendRef(catalog.ServiceType, "api", ""), + { + Ref: resourcetest.Resource(catalog.ServiceType, "web").Reference("section2"), + Port: "http", + }, + }, + expectErr: `invalid element at index 0 of list "rules": invalid element at index 1 of list "backend_refs": invalid "backend_ref" field: invalid "ref" field: invalid "section" field: section not supported for service backend refs`, + }, + "backend ref with datacenter": { + refs: []*pbmesh.BackendReference{ + newBackendRef(catalog.ServiceType, "api", ""), + { + Ref: newRef(catalog.ServiceType, "db"), + Port: "http", + Datacenter: "dc2", + }, + }, + expectErr: `invalid element at index 0 of list "rules": invalid element at index 1 of list "backend_refs": invalid "backend_ref" field: invalid "datacenter" field: datacenter is not yet supported on backend refs`, + }, + } +} + +func newRef(typ *pbresource.Type, name string) *pbresource.Reference { + return resourcetest.Resource(typ, name).Reference("") +} + +func newBackendRef(typ *pbresource.Type, name, port string) *pbmesh.BackendReference { + return &pbmesh.BackendReference{ + Ref: newRef(typ, name), + Port: port, + } +} + +func newParentRef(typ *pbresource.Type, name, port string) *pbmesh.ParentReference { + return &pbmesh.ParentReference{ + Ref: newRef(typ, name), + Port: port, + } +} diff --git a/internal/mesh/internal/types/proxy_configuration.go b/internal/mesh/internal/types/proxy_configuration.go index 3349090b524a3..34b6300ed0364 100644 --- a/internal/mesh/internal/types/proxy_configuration.go +++ b/internal/mesh/internal/types/proxy_configuration.go @@ -25,8 +25,9 @@ var ( func RegisterProxyConfiguration(r resource.Registry) { r.Register(resource.Registration{ - Type: ProxyConfigurationV1Alpha1Type, - Proto: &pbmesh.ProxyConfiguration{}, + Type: ProxyConfigurationV1Alpha1Type, + Proto: &pbmesh.ProxyConfiguration{}, + // TODO(rb): add validation for proxy configuration Validate: nil, }) } diff --git a/internal/mesh/internal/types/tcp_route.go b/internal/mesh/internal/types/tcp_route.go index dcd1eca4a4917..0a4bc1c9a7623 100644 --- a/internal/mesh/internal/types/tcp_route.go +++ b/internal/mesh/internal/types/tcp_route.go @@ -4,6 +4,8 @@ package types import ( + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/consul/internal/resource" pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v1alpha1" "github.com/hashicorp/consul/proto-public/pbresource" @@ -25,8 +27,69 @@ var ( func RegisterTCPRoute(r resource.Registry) { r.Register(resource.Registration{ - Type: TCPRouteV1Alpha1Type, - Proto: &pbmesh.TCPRoute{}, - Validate: nil, + Type: TCPRouteV1Alpha1Type, + Proto: &pbmesh.TCPRoute{}, + // TODO(rb): normalize parent/backend ref tenancies in a Mutate hook + Validate: ValidateTCPRoute, }) } + +func ValidateTCPRoute(res *pbresource.Resource) error { + var route pbmesh.TCPRoute + + if err := res.Data.UnmarshalTo(&route); err != nil { + return resource.NewErrDataParse(&route, err) + } + + var merr error + + if err := validateParentRefs(route.ParentRefs); err != nil { + merr = multierror.Append(merr, err) + } + + for i, rule := range route.Rules { + wrapRuleErr := func(err error) error { + return resource.ErrInvalidListElement{ + Name: "rules", + Index: i, + Wrapped: err, + } + } + + if len(rule.BackendRefs) == 0 { + /* + BackendRefs (optional)¶ + + BackendRefs defines API objects where matching requests should be + sent. If unspecified, the rule performs no forwarding. If + unspecified and no filters are specified that would result in a + response being sent, a 404 error code is returned. + */ + merr = multierror.Append(merr, wrapRuleErr( + resource.ErrInvalidField{ + Name: "backend_refs", + Wrapped: resource.ErrEmpty, + }, + )) + } + for j, hbref := range rule.BackendRefs { + wrapBackendRefErr := func(err error) error { + return wrapRuleErr(resource.ErrInvalidListElement{ + Name: "backend_refs", + Index: j, + Wrapped: err, + }) + } + for _, err := range validateBackendRef(hbref.BackendRef) { + merr = multierror.Append(merr, wrapBackendRefErr( + resource.ErrInvalidField{ + Name: "backend_ref", + Wrapped: err, + }, + )) + } + } + } + + return merr +} diff --git a/internal/mesh/internal/types/tcp_route_test.go b/internal/mesh/internal/types/tcp_route_test.go new file mode 100644 index 0000000000000..acaaf142a1113 --- /dev/null +++ b/internal/mesh/internal/types/tcp_route_test.go @@ -0,0 +1,79 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/internal/catalog" + "github.com/hashicorp/consul/internal/resource/resourcetest" + pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v1alpha1" + "github.com/hashicorp/consul/proto/private/prototest" + "github.com/hashicorp/consul/sdk/testutil" +) + +func TestValidateTCPRoute(t *testing.T) { + type testcase struct { + route *pbmesh.TCPRoute + expectErr string + } + + run := func(t *testing.T, tc testcase) { + res := resourcetest.Resource(TCPRouteType, "api"). + WithData(t, tc.route). + Build() + + err := ValidateTCPRoute(res) + + // Verify that validate didn't actually change the object. + got := resourcetest.MustDecode[pbmesh.TCPRoute, *pbmesh.TCPRoute](t, res) + prototest.AssertDeepEqual(t, tc.route, got.Data) + + if tc.expectErr == "" { + require.NoError(t, err) + } else { + testutil.RequireErrorContains(t, err, tc.expectErr) + } + } + + cases := map[string]testcase{} + + // Add common parent refs test cases. + for name, parentTC := range getXRouteParentRefTestCases() { + cases["parent-ref: "+name] = testcase{ + route: &pbmesh.TCPRoute{ + ParentRefs: parentTC.refs, + }, + expectErr: parentTC.expectErr, + } + } + // add common backend ref test cases. + for name, backendTC := range getXRouteBackendRefTestCases() { + var refs []*pbmesh.TCPBackendRef + for _, br := range backendTC.refs { + refs = append(refs, &pbmesh.TCPBackendRef{ + BackendRef: br, + }) + } + cases["backend-ref: "+name] = testcase{ + route: &pbmesh.TCPRoute{ + ParentRefs: []*pbmesh.ParentReference{ + newParentRef(catalog.ServiceType, "web", ""), + }, + Rules: []*pbmesh.TCPRouteRule{ + {BackendRefs: refs}, + }, + }, + expectErr: backendTC.expectErr, + } + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + run(t, tc) + }) + } +} diff --git a/internal/mesh/internal/types/util.go b/internal/mesh/internal/types/util.go new file mode 100644 index 0000000000000..3c258891e1ba4 --- /dev/null +++ b/internal/mesh/internal/types/util.go @@ -0,0 +1,52 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package types + +import ( + "github.com/hashicorp/consul/internal/catalog" + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/proto-public/pbresource" +) + +func IsRouteType(typ *pbresource.Type) bool { + switch { + case resource.EqualType(typ, HTTPRouteType), + resource.EqualType(typ, GRPCRouteType), + resource.EqualType(typ, TCPRouteType): + return true + } + return false +} + +func IsFailoverPolicyType(typ *pbresource.Type) bool { + switch { + case resource.EqualType(typ, catalog.FailoverPolicyType): + return true + } + return false +} + +func IsDestinationPolicyType(typ *pbresource.Type) bool { + switch { + case resource.EqualType(typ, DestinationPolicyType): + return true + } + return false +} + +func IsServiceType(typ *pbresource.Type) bool { + switch { + case resource.EqualType(typ, catalog.ServiceType): + return true + } + return false +} + +func IsComputedRoutesType(typ *pbresource.Type) bool { + switch { + case resource.EqualType(typ, ComputedRoutesType): + return true + } + return false +} diff --git a/internal/mesh/internal/types/xroute.go b/internal/mesh/internal/types/xroute.go new file mode 100644 index 0000000000000..aac083269f292 --- /dev/null +++ b/internal/mesh/internal/types/xroute.go @@ -0,0 +1,20 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package types + +import ( + "google.golang.org/protobuf/proto" + + pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v1alpha1" +) + +type XRouteData interface { + proto.Message + XRouteWithRefs +} + +type XRouteWithRefs interface { + GetParentRefs() []*pbmesh.ParentReference + GetUnderlyingBackendRefs() []*pbmesh.BackendReference +} diff --git a/proto-public/pbmesh/v1alpha1/xroute_addons.go b/proto-public/pbmesh/v1alpha1/xroute_addons.go new file mode 100644 index 0000000000000..c5bae22b48c3c --- /dev/null +++ b/proto-public/pbmesh/v1alpha1/xroute_addons.go @@ -0,0 +1,91 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package meshv1alpha1 + +// GetUnderlyingBackendRefs will collect BackendReferences from all rules and +// bundle them up in one slice, unwrapping the HTTP-specifics in the process. +// +// This implements an XRouteWithRefs interface in the internal/mesh package. +// +// NOTE: no deduplication occurs. +func (x *HTTPRoute) GetUnderlyingBackendRefs() []*BackendReference { + if x == nil { + return nil + } + + estimate := 0 + for _, rule := range x.Rules { + estimate += len(rule.BackendRefs) + } + + backendRefs := make([]*BackendReference, 0, estimate) + for _, rule := range x.Rules { + for _, backendRef := range rule.BackendRefs { + backendRefs = append(backendRefs, backendRef.BackendRef) + } + } + return backendRefs +} + +// GetUnderlyingBackendRefs will collect BackendReferences from all rules and +// bundle them up in one slice, unwrapping the GRPC-specifics in the process. +// +// This implements an XRouteWithRefs interface in the internal/mesh package. +// +// NOTE: no deduplication occurs. +func (x *GRPCRoute) GetUnderlyingBackendRefs() []*BackendReference { + if x == nil { + return nil + } + + estimate := 0 + for _, rule := range x.Rules { + estimate += len(rule.BackendRefs) + } + + backendRefs := make([]*BackendReference, 0, estimate) + for _, rule := range x.Rules { + for _, backendRef := range rule.BackendRefs { + backendRefs = append(backendRefs, backendRef.BackendRef) + } + } + return backendRefs +} + +// GetUnderlyingBackendRefs will collect BackendReferences from all rules and +// bundle them up in one slice, unwrapping the TCP-specifics in the process. +// +// This implements an XRouteWithRefs interface in the internal/mesh package. +// +// NOTE: no deduplication occurs. +func (x *TCPRoute) GetUnderlyingBackendRefs() []*BackendReference { + if x == nil { + return nil + } + + estimate := 0 + for _, rule := range x.Rules { + estimate += len(rule.BackendRefs) + } + + backendRefs := make([]*BackendReference, 0, estimate) + + for _, rule := range x.Rules { + for _, backendRef := range rule.BackendRefs { + backendRefs = append(backendRefs, backendRef.BackendRef) + } + } + return backendRefs +} + +// IsHashBased returns true if the policy is a hash-based policy such as maglev +// or ring hash. +func (p LoadBalancerPolicy) IsHashBased() bool { + switch p { + case LoadBalancerPolicy_LOAD_BALANCER_POLICY_MAGLEV, + LoadBalancerPolicy_LOAD_BALANCER_POLICY_RING_HASH: + return true + } + return false +} diff --git a/proto-public/pbmesh/v1alpha1/xroute_addons_test.go b/proto-public/pbmesh/v1alpha1/xroute_addons_test.go new file mode 100644 index 0000000000000..cdf432d5c6e3a --- /dev/null +++ b/proto-public/pbmesh/v1alpha1/xroute_addons_test.go @@ -0,0 +1,174 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package meshv1alpha1 + +import ( + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + pbresource "github.com/hashicorp/consul/proto-public/pbresource" +) + +type routeWithAddons interface { + proto.Message + GetUnderlyingBackendRefs() []*BackendReference +} + +func TestXRoute_GetUnderlyingBackendRefs(t *testing.T) { + type testcase struct { + route routeWithAddons + expect []*BackendReference + } + + run := func(t *testing.T, tc testcase) { + got := tc.route.GetUnderlyingBackendRefs() + require.ElementsMatch(t, stringifyList(tc.expect), stringifyList(got)) + } + + cases := map[string]testcase{ + "http: nil": { + route: (*HTTPRoute)(nil), + }, + "grpc: nil": { + route: (*GRPCRoute)(nil), + }, + "tcp: nil": { + route: (*TCPRoute)(nil), + }, + "http: kitchen sink": { + route: &HTTPRoute{ + Rules: []*HTTPRouteRule{ + {BackendRefs: []*HTTPBackendRef{ + {BackendRef: newBackendRef("aa")}, + }}, + {BackendRefs: []*HTTPBackendRef{ + {BackendRef: newBackendRef("bb")}, + }}, + {BackendRefs: []*HTTPBackendRef{ + {BackendRef: newBackendRef("cc")}, + {BackendRef: newBackendRef("dd")}, + }}, + {BackendRefs: []*HTTPBackendRef{ + {BackendRef: newBackendRef("ee")}, + {BackendRef: newBackendRef("ff")}, + }}, + }, + }, + expect: []*BackendReference{ + newBackendRef("aa"), + newBackendRef("bb"), + newBackendRef("cc"), + newBackendRef("dd"), + newBackendRef("ee"), + newBackendRef("ff"), + }, + }, + "grpc: kitchen sink": { + route: &GRPCRoute{ + Rules: []*GRPCRouteRule{ + {BackendRefs: []*GRPCBackendRef{ + {BackendRef: newBackendRef("aa")}, + }}, + {BackendRefs: []*GRPCBackendRef{ + {BackendRef: newBackendRef("bb")}, + }}, + {BackendRefs: []*GRPCBackendRef{ + {BackendRef: newBackendRef("cc")}, + {BackendRef: newBackendRef("dd")}, + }}, + {BackendRefs: []*GRPCBackendRef{ + {BackendRef: newBackendRef("ee")}, + {BackendRef: newBackendRef("ff")}, + }}, + }, + }, + expect: []*BackendReference{ + newBackendRef("aa"), + newBackendRef("bb"), + newBackendRef("cc"), + newBackendRef("dd"), + newBackendRef("ee"), + newBackendRef("ff"), + }, + }, + "tcp: kitchen sink": { + route: &TCPRoute{ + Rules: []*TCPRouteRule{ + {BackendRefs: []*TCPBackendRef{ + {BackendRef: newBackendRef("aa")}, + }}, + {BackendRefs: []*TCPBackendRef{ + {BackendRef: newBackendRef("bb")}, + }}, + {BackendRefs: []*TCPBackendRef{ + {BackendRef: newBackendRef("cc")}, + {BackendRef: newBackendRef("dd")}, + }}, + {BackendRefs: []*TCPBackendRef{ + {BackendRef: newBackendRef("ee")}, + {BackendRef: newBackendRef("ff")}, + }}, + }, + }, + expect: []*BackendReference{ + newBackendRef("aa"), + newBackendRef("bb"), + newBackendRef("cc"), + newBackendRef("dd"), + newBackendRef("ee"), + newBackendRef("ff"), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + run(t, tc) + }) + } +} + +func protoToString[V proto.Message](pb V) string { + m := protojson.MarshalOptions{ + Indent: " ", + } + gotJSON, err := m.Marshal(pb) + if err != nil { + return "" + } + return string(gotJSON) +} + +func newRouteRef(name string) *pbresource.Reference { + return &pbresource.Reference{ + Type: &pbresource.Type{ + Group: "fake", + GroupVersion: "v1alpha1", + Kind: "fake", + }, + Tenancy: &pbresource.Tenancy{ + Partition: "default", + Namespace: "default", + PeerName: "local", + }, + Name: name, + } +} + +func newBackendRef(name string) *BackendReference { + return &BackendReference{ + Ref: newRouteRef(name), + } +} + +func stringifyList[V proto.Message](list []V) []string { + out := make([]string, 0, len(list)) + for _, item := range list { + out = append(out, protoToString(item)) + } + return out +}