consul/agent/envoyextensions/builtin/property-override/property_override_test.go

450 lines
12 KiB
Go
Raw Normal View History

package propertyoverride
import (
"fmt"
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
"strings"
"testing"
clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
"github.com/hashicorp/consul/api"
"github.com/hashicorp/consul/envoyextensions/extensioncommon"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
)
// TestConstructor tests raw input to the Constructor function called to initialize
// the property-override extension. This includes implicit validation of the deserialized
// input to the extension.
func TestConstructor(t *testing.T) {
// These helpers aid in constructing valid raw example input (map[string]any)
// for the Constructor, with optional overrides for fields under test.
applyOverrides := func(m map[string]any, overrides map[string]any) map[string]any {
for k, v := range overrides {
if v == nil {
delete(m, k)
} else {
m[k] = v
}
}
return m
}
makeResourceFilter := func(overrides map[string]any) map[string]any {
f := map[string]any{
"ResourceType": ResourceTypeRoute,
"TrafficDirection": TrafficDirectionOutbound,
}
return applyOverrides(f, overrides)
}
makePatch := func(overrides map[string]any) map[string]any {
p := map[string]any{
"ResourceFilter": makeResourceFilter(map[string]any{}),
"Op": OpAdd,
"Path": "/name",
"Value": "foo",
}
return applyOverrides(p, overrides)
}
makeArguments := func(overrides map[string]any) map[string]any {
a := map[string]any{
"Patches": []map[string]any{
makePatch(map[string]any{}),
},
"Debug": true,
"ProxyType": api.ServiceKindConnectProxy,
}
return applyOverrides(a, overrides)
}
type testCase struct {
extensionName string
arguments map[string]any
expected propertyOverride
ok bool
errMsg string
}
validTestCase := func(o Op, d TrafficDirection, t ResourceType) testCase {
var v any = "foo"
if o != OpAdd {
v = nil
}
// Use a valid field (name) for all resource types.
path := "/name"
return testCase{
arguments: makeArguments(map[string]any{
"Patches": []map[string]any{
makePatch(map[string]any{
"ResourceFilter": makeResourceFilter(map[string]any{
"ResourceType": t,
"TrafficDirection": d,
}),
"Op": o,
"Path": path,
"Value": v,
}),
},
}),
expected: propertyOverride{
Patches: []Patch{
{
ResourceFilter: ResourceFilter{
ResourceType: t,
TrafficDirection: d,
},
Op: o,
Path: path,
Value: v,
},
},
Debug: true,
ProxyType: api.ServiceKindConnectProxy,
},
ok: true,
}
}
cases := map[string]testCase{
"with no arguments": {
arguments: nil,
ok: false,
errMsg: "at least one patch is required",
},
"with an invalid name": {
arguments: makeArguments(map[string]any{}),
extensionName: "bad",
ok: false,
errMsg: "expected extension name \"builtin/property-override\" but got \"bad\"",
},
"empty Patches": {
arguments: makeArguments(map[string]any{"Patches": []map[string]any{}}),
ok: false,
errMsg: "at least one patch is required",
},
"patch with no ResourceFilter": {
arguments: makeArguments(map[string]any{"Patches": []map[string]any{
makePatch(map[string]any{
"ResourceFilter": nil,
}),
}}),
ok: false,
errMsg: "field ResourceFilter is required",
},
"patch with no ResourceType": {
arguments: makeArguments(map[string]any{"Patches": []map[string]any{
makePatch(map[string]any{
"ResourceFilter": makeResourceFilter(map[string]any{
"ResourceType": nil,
}),
}),
}}),
ok: false,
errMsg: "field ResourceType is required",
},
"patch with invalid ResourceType": {
arguments: makeArguments(map[string]any{"Patches": []map[string]any{
makePatch(map[string]any{
"ResourceFilter": makeResourceFilter(map[string]any{
"ResourceType": "foo",
}),
}),
}}),
ok: false,
errMsg: "invalid ResourceType",
},
"patch with no TrafficDirection": {
arguments: makeArguments(map[string]any{"Patches": []map[string]any{
makePatch(map[string]any{
"ResourceFilter": makeResourceFilter(map[string]any{
"TrafficDirection": nil,
}),
}),
}}),
ok: false,
errMsg: "field TrafficDirection is required",
},
"patch with invalid TrafficDirection": {
arguments: makeArguments(map[string]any{"Patches": []map[string]any{
makePatch(map[string]any{
"ResourceFilter": makeResourceFilter(map[string]any{
"TrafficDirection": "foo",
}),
}),
}}),
ok: false,
errMsg: "invalid TrafficDirection",
},
"patch with no Op": {
arguments: makeArguments(map[string]any{"Patches": []map[string]any{
makePatch(map[string]any{
"Op": nil,
}),
}}),
ok: false,
errMsg: "field Op is required",
},
"patch with invalid Op": {
arguments: makeArguments(map[string]any{"Patches": []map[string]any{
makePatch(map[string]any{
"Op": "foo",
}),
}}),
ok: false,
errMsg: "invalid Op",
},
"patch with invalid Envoy resource Path": {
arguments: makeArguments(map[string]any{"Patches": []map[string]any{
makePatch(map[string]any{
"Path": "/invalid",
}),
}}),
ok: false,
errMsg: "no match for field", // this error comes from the patcher dry-run attempt
},
"non-Add patch with Value": {
arguments: makeArguments(map[string]any{"Patches": []map[string]any{
makePatch(map[string]any{
"Op": OpRemove,
"Value": 1,
}),
}}),
ok: false,
errMsg: fmt.Sprintf("field Value is not supported for %s operation", OpRemove),
},
// See decode.HookWeakDecodeFromSlice for more details. In practice, we can end up
// with a "Patches" field decoded to the single "Patch" value contained in the
// serialized slice (raised from the containing slice). Using WeakDecode solves
// for this. Ideally, we would kill that decoding hook entirely, but this test
// enforces expected behavior until we do. Multi-member slices should be unaffected
// by WeakDecode as it is a more-permissive version of the default behavior.
"single value Patches decoded as map construction succeeds": {
arguments: makeArguments(map[string]any{"Patches": makePatch(map[string]any{})}),
expected: validTestCase(OpAdd, TrafficDirectionOutbound, ResourceTypeRoute).expected,
ok: true,
},
"invalid ProxyType": {
arguments: makeArguments(map[string]any{
"Patches": []map[string]any{
makePatch(map[string]any{}),
},
"ProxyType": "invalid",
}),
ok: false,
errMsg: "invalid ProxyType",
},
"unsupported ProxyType": {
arguments: makeArguments(map[string]any{
"Patches": []map[string]any{
makePatch(map[string]any{}),
},
"ProxyType": api.ServiceKindMeshGateway,
}),
ok: false,
errMsg: "invalid ProxyType",
},
}
for o := range Ops {
for d := range TrafficDirections {
for t := range ResourceTypes {
cases["valid everything: "+strings.Join([]string{o, d, t}, ",")] =
validTestCase(Op(o), TrafficDirection(d), ResourceType(t))
}
}
}
for n, tc := range cases {
t.Run(n, func(t *testing.T) {
extensionName := api.BuiltinPropertyOverrideExtension
if tc.extensionName != "" {
extensionName = tc.extensionName
}
// Build the wrapping RuntimeConfig struct, which contains the serialized
// arguments for constructing the property-override extension.
svc := api.CompoundServiceName{Name: "svc"}
ext := extensioncommon.RuntimeConfig{
ServiceName: svc,
EnvoyExtension: api.EnvoyExtension{
Name: extensionName,
Arguments: tc.arguments,
},
}
// Construct the actual extension
e, err := Constructor(ext.EnvoyExtension)
if tc.ok {
require.NoError(t, err)
require.Equal(t, &extensioncommon.BasicEnvoyExtender{Extension: &tc.expected}, e)
} else {
require.ErrorContains(t, err, tc.errMsg)
}
})
}
}
func Test_patchResourceType(t *testing.T) {
makeExtension := func(patches ...Patch) *propertyOverride {
return &propertyOverride{
Patches: patches,
}
}
makePatchWithPath := func(t ResourceType, d TrafficDirection, p string) Patch {
return Patch{
ResourceFilter: ResourceFilter{
ResourceType: t,
TrafficDirection: d,
},
Op: OpAdd,
Path: p,
Value: 1,
}
}
makePatch := func(t ResourceType, d TrafficDirection) Patch {
return makePatchWithPath(t, d, "/foo")
}
clusterOutbound := makePatch(ResourceTypeCluster, TrafficDirectionOutbound)
clusterInbound := makePatch(ResourceTypeCluster, TrafficDirectionInbound)
routeOutbound := makePatch(ResourceTypeRoute, TrafficDirectionOutbound)
routeOutbound2 := makePatchWithPath(ResourceTypeRoute, TrafficDirectionOutbound, "/bar")
routeInbound := makePatch(ResourceTypeRoute, TrafficDirectionInbound)
type args struct {
d TrafficDirection
k proto.Message
p *propertyOverride
t ResourceType
}
type testCase struct {
args args
expectPatched bool
wantApplied []Patch
}
cases := map[string]testCase{
"outbound gets matching patch": {
args: args{
d: TrafficDirectionOutbound,
k: &clusterv3.Cluster{},
p: makeExtension(clusterOutbound),
t: ResourceTypeCluster,
},
expectPatched: true,
wantApplied: []Patch{clusterOutbound},
},
"inbound gets matching patch": {
args: args{
d: TrafficDirectionInbound,
k: &clusterv3.Cluster{},
p: makeExtension(clusterInbound),
t: ResourceTypeCluster,
},
expectPatched: true,
wantApplied: []Patch{clusterInbound},
},
"multiple resources same direction only gets matching resource": {
args: args{
d: TrafficDirectionOutbound,
k: &clusterv3.Cluster{},
p: makeExtension(clusterOutbound, routeOutbound),
t: ResourceTypeCluster,
},
expectPatched: true,
wantApplied: []Patch{clusterOutbound},
},
"multiple directions same resource only gets matching direction": {
args: args{
d: TrafficDirectionOutbound,
k: &clusterv3.Cluster{},
p: makeExtension(clusterOutbound, clusterInbound),
t: ResourceTypeCluster,
},
expectPatched: true,
wantApplied: []Patch{clusterOutbound},
},
"multiple directions and resources only gets matching patch": {
args: args{
d: TrafficDirectionInbound,
k: &routev3.RouteConfiguration{},
p: makeExtension(clusterOutbound, clusterInbound, routeOutbound, routeInbound),
t: ResourceTypeRoute,
},
expectPatched: true,
wantApplied: []Patch{routeInbound},
},
"multiple directions and resources multiple matches gets all matching patches": {
args: args{
d: TrafficDirectionOutbound,
k: &routev3.RouteConfiguration{},
p: makeExtension(clusterOutbound, clusterInbound, routeOutbound, routeInbound, routeOutbound2),
t: ResourceTypeRoute,
},
expectPatched: true,
wantApplied: []Patch{routeOutbound, routeOutbound2},
},
"multiple directions and resources no matches gets no patches": {
args: args{
d: TrafficDirectionOutbound,
k: &routev3.RouteConfiguration{},
p: makeExtension(clusterInbound, routeOutbound, routeInbound, routeOutbound2),
t: ResourceTypeCluster,
},
expectPatched: false,
wantApplied: nil,
},
}
for n, tc := range cases {
t.Run(n, func(t *testing.T) {
mockPatcher := MockPatcher[proto.Message]{}
_, patched, err := patchResourceType[proto.Message](tc.args.k, tc.args.p, tc.args.t, tc.args.d, &mockPatcher)
require.NoError(t, err, "unexpected error from mock")
require.Equal(t, tc.expectPatched, patched)
require.Equal(t, tc.wantApplied, mockPatcher.appliedPatches)
})
}
}
type MockPatcher[K proto.Message] struct {
appliedPatches []Patch
}
func (m *MockPatcher[K]) applyPatch(k K, p Patch, _ bool) (result K, e error) {
m.appliedPatches = append(m.appliedPatches, p)
return k, nil
}
func TestCanApply(t *testing.T) {
cases := map[string]struct {
ext *propertyOverride
conf *extensioncommon.RuntimeConfig
canApply bool
}{
"valid proxy type": {
ext: &propertyOverride{
ProxyType: api.ServiceKindConnectProxy,
},
conf: &extensioncommon.RuntimeConfig{
Kind: api.ServiceKindConnectProxy,
},
canApply: true,
},
"invalid proxy type": {
ext: &propertyOverride{
ProxyType: api.ServiceKindTerminatingGateway,
},
conf: &extensioncommon.RuntimeConfig{
Kind: api.ServiceKindMeshGateway,
},
canApply: false,
},
}
for n, tc := range cases {
t.Run(n, func(t *testing.T) {
require.Equal(t, tc.canApply, tc.ext.CanApply(tc.conf))
})
}
}