From 4b04a600230b2598f516017a1d5e3abdabe10001 Mon Sep 17 00:00:00 2001 From: Jakub Dyszkiewicz Date: Fri, 13 Jan 2023 13:14:37 +0100 Subject: [PATCH] feat(kuma-cp): mesh proxy patch xds (#5604) Signed-off-by: Jakub Dyszkiewicz --- docs/guides/new-policies.md | 6 +- .../plugin/v1alpha1/cluster_mod.go | 70 ++ .../plugin/v1alpha1/cluster_mod_test.go | 217 +++++ .../plugin/v1alpha1/http_filter_mod.go | 164 ++++ .../plugin/v1alpha1/http_filter_mod_test.go | 734 +++++++++++++++++ .../plugin/v1alpha1/listener_mod.go | 80 ++ .../plugin/v1alpha1/listener_mod_test.go | 316 ++++++++ .../plugin/v1alpha1/network_filter_mod.go | 142 ++++ .../v1alpha1/network_filter_mod_test.go | 739 ++++++++++++++++++ .../meshproxypatch/plugin/v1alpha1/plugin.go | 56 +- .../plugin/v1alpha1/plugin_test.go | 106 +++ .../plugin/v1alpha1/suite_test.go | 12 + .../plugin/v1alpha1/virtual_host_mod.go | 123 +++ .../plugin/v1alpha1/virtual_host_test.go | 334 ++++++++ pkg/plugins/policies/policies.go | 2 + .../kubernetes/kubernetes_suite_test.go | 2 + .../meshproxypatch/meshproxypatch.go | 83 ++ .../meshproxypatch/meshproxypatch.go | 74 ++ .../e2e_env/universal/universal_suite_test.go | 2 + 19 files changed, 3255 insertions(+), 7 deletions(-) create mode 100644 pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/cluster_mod.go create mode 100644 pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/cluster_mod_test.go create mode 100644 pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/http_filter_mod.go create mode 100644 pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/http_filter_mod_test.go create mode 100644 pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/listener_mod.go create mode 100644 pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/listener_mod_test.go create mode 100644 pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/network_filter_mod.go create mode 100644 pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/network_filter_mod_test.go create mode 100644 pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/plugin_test.go create mode 100644 pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/suite_test.go create mode 100644 pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/virtual_host_mod.go create mode 100644 pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/virtual_host_test.go create mode 100644 test/e2e_env/kubernetes/meshproxypatch/meshproxypatch.go create mode 100644 test/e2e_env/universal/meshproxypatch/meshproxypatch.go diff --git a/docs/guides/new-policies.md b/docs/guides/new-policies.md index 12d11a7d35d2..34afe93bb452 100644 --- a/docs/guides/new-policies.md +++ b/docs/guides/new-policies.md @@ -10,7 +10,9 @@ The output of the tool will tell you where the important files are! ## Add plugin name to the configuration -Enabled plugin configuration is in `pkg/plugins/policies/policies.go`. Plugins name is equals to `KumactlArg` in file `zz_generated.resource.go`. It's important to place the plugin in the correct place because the order of executions is important. +To enable policy you need to adjust configuration of two places: +* Remove `+kuma:policy:skip_registration=true` from your policy schema. +* `pkg/plugins/policies/policies.go`. Plugins name is equals to `KumactlArg` in file `zz_generated.resource.go`. It's important to place the plugin in the correct place because the order of executions is important. ## How to map API to a Go struct @@ -86,4 +88,4 @@ type SomeStruct struct { type SomeStruct struct { MyField *ItemType `json"myField,omitempty"` } -``` \ No newline at end of file +``` diff --git a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/cluster_mod.go b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/cluster_mod.go new file mode 100644 index 000000000000..dd8cf8e7dd3e --- /dev/null +++ b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/cluster_mod.go @@ -0,0 +1,70 @@ +package v1alpha1 + +import ( + envoy_cluster "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" + envoy_resource "github.com/envoyproxy/go-control-plane/pkg/resource/v3" + "github.com/pkg/errors" + + core_xds "github.com/kumahq/kuma/pkg/core/xds" + api "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/api/v1alpha1" + util_proto "github.com/kumahq/kuma/pkg/util/proto" +) + +type clusterModificator api.ClusterMod + +func (c *clusterModificator) apply(resources *core_xds.ResourceSet) error { + clusterMod := &envoy_cluster.Cluster{} + if c.Value != nil { + if err := util_proto.FromYAML([]byte(*c.Value), clusterMod); err != nil { + return err + } + } + switch c.Operation { + case api.ModOpAdd: + c.add(resources, clusterMod) + case api.ModOpRemove: + c.remove(resources) + case api.ModOpPatch: + c.patch(resources, clusterMod) + default: + return errors.Errorf("invalid operation: %s", c.Operation) + } + return nil +} + +func (c *clusterModificator) patch(resources *core_xds.ResourceSet, clusterMod *envoy_cluster.Cluster) { + for _, cluster := range resources.Resources(envoy_resource.ClusterType) { + if c.clusterMatches(cluster) { + util_proto.Merge(cluster.Resource, clusterMod) + } + } +} + +func (c *clusterModificator) remove(resources *core_xds.ResourceSet) { + for name, resource := range resources.Resources(envoy_resource.ClusterType) { + if c.clusterMatches(resource) { + resources.Remove(envoy_resource.ClusterType, name) + } + } +} + +func (c *clusterModificator) add(resources *core_xds.ResourceSet, clusterMod *envoy_cluster.Cluster) *core_xds.ResourceSet { + return resources.Add(&core_xds.Resource{ + Name: clusterMod.Name, + Origin: Origin, + Resource: clusterMod, + }) +} + +func (c *clusterModificator) clusterMatches(cluster *core_xds.Resource) bool { + if c.Match == nil { + return true + } + if c.Match.Name != nil && *c.Match.Name != cluster.Name { + return false + } + if c.Match.Origin != nil && *c.Match.Origin != cluster.Origin { + return false + } + return true +} diff --git a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/cluster_mod_test.go b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/cluster_mod_test.go new file mode 100644 index 000000000000..6d1e20505064 --- /dev/null +++ b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/cluster_mod_test.go @@ -0,0 +1,217 @@ +package v1alpha1_test + +import ( + envoy_cluster "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/yaml" + + core_xds "github.com/kumahq/kuma/pkg/core/xds" + api "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/api/v1alpha1" + plugin "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1" + util_proto "github.com/kumahq/kuma/pkg/util/proto" + "github.com/kumahq/kuma/pkg/xds/generator" +) + +var _ = Describe("Cluster modifications", func() { + + type testCase struct { + clusters []string + modifications []string + expected string + } + + DescribeTable("should apply modifications", + func(given testCase) { + // given + set := core_xds.NewResourceSet() + for _, clusterYAML := range given.clusters { + cluster := &envoy_cluster.Cluster{} + err := util_proto.FromYAML([]byte(clusterYAML), cluster) + Expect(err).ToNot(HaveOccurred()) + set.Add(&core_xds.Resource{ + Name: cluster.Name, + Origin: generator.OriginInbound, + Resource: cluster, + }) + } + + var mods []api.Modification + for _, modificationYAML := range given.modifications { + modification := api.Modification{} + err := yaml.Unmarshal([]byte(modificationYAML), &modification) + Expect(err).ToNot(HaveOccurred()) + mods = append(mods, modification) + } + + // when + err := plugin.ApplyMods(set, mods) + + // then + Expect(err).ToNot(HaveOccurred()) + resp, err := set.List().ToDeltaDiscoveryResponse() + Expect(err).ToNot(HaveOccurred()) + actual, err := util_proto.ToYAML(resp) + Expect(err).ToNot(HaveOccurred()) + Expect(actual).To(MatchYAML(given.expected)) + }, + Entry("should add cluster", testCase{ + modifications: []string{` + cluster: + operation: Add + value: | + edsClusterConfig: + edsConfig: + ads: {} + name: test:cluster + type: EDS`, + }, + expected: ` + resources: + - name: test:cluster + resource: + '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + edsClusterConfig: + edsConfig: + ads: {} + name: test:cluster + type: EDS`, + }), + Entry("should replace cluster", testCase{ + clusters: []string{ + ` + connectTimeout: 5s + lbPolicy: CLUSTER_PROVIDED + name: test:cluster + type: ORIGINAL_DST`, + }, + modifications: []string{ + ` + cluster: + operation: Add + value: | + edsClusterConfig: + edsConfig: + ads: {} + name: test:cluster + type: EDS`, + }, + expected: ` + resources: + - name: test:cluster + resource: + '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + edsClusterConfig: + edsConfig: + ads: {} + name: test:cluster + type: EDS`, + }), + Entry("should remove cluster matching all", testCase{ + clusters: []string{ + ` + connectTimeout: 5s + lbPolicy: CLUSTER_PROVIDED + name: test:cluster + type: ORIGINAL_DST`, + }, + modifications: []string{ + ` + cluster: + operation: Remove`, + }, + expected: `{}`, + }), + Entry("should remove cluster matching name", testCase{ + clusters: []string{ + ` + connectTimeout: 5s + lbPolicy: CLUSTER_PROVIDED + name: test:cluster + type: ORIGINAL_DST`, + ` + connectTimeout: 5s + lbPolicy: CLUSTER_PROVIDED + name: test:cluster2 + type: ORIGINAL_DST`, + }, + modifications: []string{ + ` + cluster: + operation: Remove + match: + name: test:cluster`, + }, + expected: ` + resources: + - name: test:cluster2 + resource: + '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + connectTimeout: 5s + lbPolicy: CLUSTER_PROVIDED + name: test:cluster2 + type: ORIGINAL_DST`, + }), + Entry("should remove all inbound clusters", testCase{ + clusters: []string{ + ` + connectTimeout: 5s + lbPolicy: CLUSTER_PROVIDED + name: test:cluster + type: ORIGINAL_DST`, + }, + modifications: []string{ + ` + cluster: + operation: Remove + match: + origin: inbound`, + }, + expected: `{}`, + }), + Entry("should patch cluster matching name", testCase{ + clusters: []string{ + ` + lbPolicy: CLUSTER_PROVIDED + name: test:cluster + outlierDetection: + enforcingConsecutive5xx: 100 + enforcingConsecutiveGatewayFailure: 0 + enforcingConsecutiveLocalOriginFailure: 0 + enforcingFailurePercentage: 0 + enforcingSuccessRate: 0 + type: ORIGINAL_DST`, + }, + modifications: []string{ + ` + cluster: + operation: Patch + match: + name: test:cluster + value: | + connectTimeout: 5s + httpProtocolOptions: + acceptHttp10: true + outlierDetection: + enforcingSuccessRate: 100`, + }, + expected: ` + resources: + - name: test:cluster + resource: + '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + connectTimeout: 5s + httpProtocolOptions: + acceptHttp10: true + lbPolicy: CLUSTER_PROVIDED + name: test:cluster + outlierDetection: + enforcingConsecutive5xx: 100 + enforcingConsecutiveGatewayFailure: 0 + enforcingConsecutiveLocalOriginFailure: 0 + enforcingFailurePercentage: 0 + enforcingSuccessRate: 100 + type: ORIGINAL_DST`, + }), + ) +}) diff --git a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/http_filter_mod.go b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/http_filter_mod.go new file mode 100644 index 000000000000..e4f5df27ce21 --- /dev/null +++ b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/http_filter_mod.go @@ -0,0 +1,164 @@ +package v1alpha1 + +import ( + envoy_listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + envoy_hcm "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + envoy_resource "github.com/envoyproxy/go-control-plane/pkg/resource/v3" + "github.com/pkg/errors" + + mesh_proto "github.com/kumahq/kuma/api/mesh/v1alpha1" + core_xds "github.com/kumahq/kuma/pkg/core/xds" + api "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/api/v1alpha1" + util_proto "github.com/kumahq/kuma/pkg/util/proto" + envoy_metadata "github.com/kumahq/kuma/pkg/xds/envoy/metadata/v3" +) + +type httpFilterModificator api.HTTPFilterMod + +func (h *httpFilterModificator) apply(resources *core_xds.ResourceSet) error { + for _, resource := range resources.Resources(envoy_resource.ListenerType) { + if h.listenerMatches(resource) { + listener := resource.Resource.(*envoy_listener.Listener) + for _, chain := range listener.FilterChains { // apply on all filter chains. We could introduce filter chain matcher as an improvement. + for _, networkFilter := range chain.Filters { + if networkFilter.Name == "envoy.filters.network.http_connection_manager" { + hcm := &envoy_hcm.HttpConnectionManager{} + err := util_proto.UnmarshalAnyTo(networkFilter.ConfigType.(*envoy_listener.Filter_TypedConfig).TypedConfig, hcm) + if err != nil { + return err + } + if err := h.applyHCMModification(hcm); err != nil { + return err + } + any, err := util_proto.MarshalAnyDeterministic(hcm) + if err != nil { + return err + } + networkFilter.ConfigType.(*envoy_listener.Filter_TypedConfig).TypedConfig = any + } + } + } + } + } + return nil +} + +func (h *httpFilterModificator) applyHCMModification(hcm *envoy_hcm.HttpConnectionManager) error { + filter := &envoy_hcm.HttpFilter{} + if h.Value != nil { + if err := util_proto.FromYAML([]byte(*h.Value), filter); err != nil { + return err + } + } + switch h.Operation { + case api.ModOpAddFirst: + h.addFirst(hcm, filter) + case api.ModOpAddLast: + h.addLast(hcm, filter) + case api.ModOpAddAfter: + h.addAfter(hcm, filter) + case api.ModOpAddBefore: + h.addBefore(hcm, filter) + case api.ModOpRemove: + h.remove(hcm) + case api.ModOpPatch: + if err := h.patch(hcm, filter); err != nil { + return errors.Wrap(err, "could not patch the resource") + } + default: + return errors.Errorf("invalid operation: %s", h.Operation) + } + return nil +} + +func (h *httpFilterModificator) patch(hcm *envoy_hcm.HttpConnectionManager, filterPatch *envoy_hcm.HttpFilter) error { + for _, filter := range hcm.HttpFilters { + if h.filterMatches(filter) { + any, err := util_proto.MergeAnys(filter.GetTypedConfig(), filterPatch.GetTypedConfig()) + if err != nil { + return err + } + + filter.ConfigType = &envoy_hcm.HttpFilter_TypedConfig{ + TypedConfig: any, + } + } + } + return nil +} + +func (h *httpFilterModificator) remove(hcm *envoy_hcm.HttpConnectionManager) { + var filters []*envoy_hcm.HttpFilter + for _, filter := range hcm.HttpFilters { + if !h.filterMatches(filter) { + filters = append(filters, filter) + } + } + hcm.HttpFilters = filters +} + +func (h *httpFilterModificator) addBefore(hcm *envoy_hcm.HttpConnectionManager, filterMod *envoy_hcm.HttpFilter) { + idx := h.indexOfMatchedFilter(hcm) + if idx != -1 { + hcm.HttpFilters = append(hcm.HttpFilters, nil) + copy(hcm.HttpFilters[idx+1:], hcm.HttpFilters[idx:]) + hcm.HttpFilters[idx] = filterMod + } +} + +func (h *httpFilterModificator) addAfter(hcm *envoy_hcm.HttpConnectionManager, filterMod *envoy_hcm.HttpFilter) { + idx := h.indexOfMatchedFilter(hcm) + if idx != -1 { + hcm.HttpFilters = append(hcm.HttpFilters, nil) + copy(hcm.HttpFilters[idx+2:], hcm.HttpFilters[idx+1:]) + hcm.HttpFilters[idx+1] = filterMod + } +} + +func (h *httpFilterModificator) addLast(hcm *envoy_hcm.HttpConnectionManager, filterMod *envoy_hcm.HttpFilter) { + hcm.HttpFilters = append(hcm.HttpFilters, filterMod) +} + +func (h *httpFilterModificator) addFirst(hcm *envoy_hcm.HttpConnectionManager, filterMod *envoy_hcm.HttpFilter) { + hcm.HttpFilters = append([]*envoy_hcm.HttpFilter{filterMod}, hcm.HttpFilters...) +} + +func (h *httpFilterModificator) filterMatches(filter *envoy_hcm.HttpFilter) bool { + if h.Match == nil { + return true + } + if h.Match.Name != nil && *h.Match.Name != filter.Name { + return false + } + return true +} + +func (h *httpFilterModificator) listenerMatches(resource *core_xds.Resource) bool { + if h.Match == nil { + return true + } + if h.Match.ListenerName != nil && *h.Match.ListenerName != resource.Name { + return false + } + if h.Match.Origin != nil && *h.Match.Origin != resource.Origin { + return false + } + if len(h.Match.ListenerTags) > 0 { + if listenerProto, ok := resource.Resource.(*envoy_listener.Listener); ok { + listenerTags := envoy_metadata.ExtractTags(listenerProto.Metadata) + if !mesh_proto.TagSelector(h.Match.ListenerTags).Matches(listenerTags) { + return false + } + } + } + return true +} + +func (h *httpFilterModificator) indexOfMatchedFilter(hcm *envoy_hcm.HttpConnectionManager) int { + for i, filter := range hcm.HttpFilters { + if h.Match != nil && filter.Name == *h.Match.Name { + return i + } + } + return -1 +} diff --git a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/http_filter_mod_test.go b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/http_filter_mod_test.go new file mode 100644 index 000000000000..4891f4486f20 --- /dev/null +++ b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/http_filter_mod_test.go @@ -0,0 +1,734 @@ +package v1alpha1_test + +import ( + envoy_listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/yaml" + + core_xds "github.com/kumahq/kuma/pkg/core/xds" + api "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/api/v1alpha1" + plugin "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1" + util_proto "github.com/kumahq/kuma/pkg/util/proto" + "github.com/kumahq/kuma/pkg/xds/generator" +) + +var _ = Describe("HTTP Filter modifications", func() { + + type testCase struct { + listeners []string + modifications []string + expected string + } + + DescribeTable("should apply modifications", + func(given testCase) { + // given + set := core_xds.NewResourceSet() + for _, listenerYAML := range given.listeners { + listener := &envoy_listener.Listener{} + err := util_proto.FromYAML([]byte(listenerYAML), listener) + Expect(err).ToNot(HaveOccurred()) + set.Add(&core_xds.Resource{ + Name: listener.Name, + Origin: generator.OriginInbound, + Resource: listener, + }) + } + + var mods []api.Modification + for _, modificationYAML := range given.modifications { + modification := api.Modification{} + err := yaml.Unmarshal([]byte(modificationYAML), &modification) + Expect(err).ToNot(HaveOccurred()) + mods = append(mods, modification) + } + + // when + err := plugin.ApplyMods(set, mods) + + // then + Expect(err).ToNot(HaveOccurred()) + resp, err := set.List().ToDeltaDiscoveryResponse() + Expect(err).ToNot(HaveOccurred()) + actual, err := util_proto.ToYAML(resp) + Expect(err).ToNot(HaveOccurred()) + Expect(actual).To(MatchYAML(given.expected)) + }, + Entry("should add filter as the last", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + statPrefix: localhost_8080 + httpFilters: + - name: envoy.filters.http.router`, + }, + modifications: []string{` + httpFilter: + operation: AddLast + value: | + name: envoy.filters.http.cors +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + - name: envoy.filters.http.cors + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + Entry("should add filter as the first", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + statPrefix: localhost_8080 + httpFilters: + - name: envoy.filters.http.router`, + }, + modifications: []string{` + httpFilter: + operation: AddFirst + value: | + name: envoy.filters.http.cors +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.cors + - name: envoy.filters.http.router + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + Entry("should remove all filters from all listeners when there is no match section", testCase{ + listeners: []string{ + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + - name: envoy.filters.http.cors + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }, + modifications: []string{` + httpFilter: + operation: Remove +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + Entry("should remove all filters from all listeners when there is inbound match section", testCase{ + listeners: []string{ + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + - name: envoy.filters.http.cors + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }, + modifications: []string{` + httpFilter: + operation: Remove + match: + origin: inbound +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + Entry("should remove all filters from picked listener", testCase{ + listeners: []string{ + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + - name: envoy.filters.http.cors + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8081 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + - name: envoy.filters.http.cors + statPrefix: localhost_8081 + name: inbound:192.168.0.1:8081 + trafficDirection: INBOUND`, + }, + modifications: []string{` + httpFilter: + operation: Remove + match: + listenerName: inbound:192.168.0.1:8080 +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND + - name: inbound:192.168.0.1:8081 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8081 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + - name: envoy.filters.http.cors + statPrefix: localhost_8081 + name: inbound:192.168.0.1:8081 + trafficDirection: INBOUND`, + }), + Entry("should remove all filters of given name from all listeners", testCase{ + listeners: []string{ + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + - name: envoy.filters.http.cors + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8081 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + - name: envoy.filters.http.cors + statPrefix: localhost_8081 + name: inbound:192.168.0.1:8081 + trafficDirection: INBOUND`, + }, + modifications: []string{` + httpFilter: + operation: Remove + match: + name: envoy.filters.http.cors +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND + - name: inbound:192.168.0.1:8081 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8081 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + statPrefix: localhost_8081 + name: inbound:192.168.0.1:8081 + trafficDirection: INBOUND`, + }), + Entry("should add filter after already defined", testCase{ + listeners: []string{ + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + - name: envoy.filters.http.gzip + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }, + modifications: []string{` + httpFilter: + operation: AddAfter + match: + name: envoy.filters.http.router + value: | + name: envoy.filters.http.cors +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + - name: envoy.filters.http.cors + - name: envoy.filters.http.gzip + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + Entry("should not add filter when name is not matched", testCase{ + listeners: []string{ + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }, + modifications: []string{` + httpFilter: + operation: AddAfter + match: + name: envoy.filters.http.gzip + value: | + name: envoy.filters.http.cors +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + Entry("should add filter before already defined", testCase{ + listeners: []string{ + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + - name: envoy.filters.http.gzip + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }, + modifications: []string{` + httpFilter: + operation: AddBefore + match: + name: envoy.filters.http.gzip + value: | + name: envoy.filters.http.cors +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + - name: envoy.filters.http.cors + - name: envoy.filters.http.gzip + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + Entry("should patch resource matching filter name", testCase{ + listeners: []string{ + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + startChildSpan: true + - name: envoy.filters.http.gzip + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }, + modifications: []string{` + httpFilter: + operation: Patch + match: + name: envoy.filters.http.router + value: | + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + dynamicStats: false +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + startChildSpan: true + dynamicStats: false + - name: envoy.filters.http.gzip + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + Entry("should patch resource matching listener tags", testCase{ + listeners: []string{ + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + metadata: + filterMetadata: + io.kuma.tags: + kuma.io/service: backend + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + startChildSpan: true + - name: envoy.filters.http.gzip + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }, + modifications: []string{` + httpFilter: + operation: Patch + match: + name: envoy.filters.http.router + listenerTags: + kuma.io/service: backend + value: | + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + dynamicStats: false +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + metadata: + filterMetadata: + io.kuma.tags: + kuma.io/service: backend + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + startChildSpan: true + dynamicStats: false + - name: envoy.filters.http.gzip + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + Entry("should not patch resource not matching listener tags", testCase{ + listeners: []string{ + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + metadata: + filterMetadata: + io.kuma.tags: + kuma.io/service: backend + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + startChildSpan: true + - name: envoy.filters.http.gzip + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }, + modifications: []string{` + httpFilter: + operation: Patch + match: + name: envoy.filters.http.router + listenerTags: + kuma.io/service: web + value: | + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + dynamicStats: false +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + metadata: + filterMetadata: + io.kuma.tags: + kuma.io/service: backend + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + startChildSpan: true + - name: envoy.filters.http.gzip + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + ) +}) diff --git a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/listener_mod.go b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/listener_mod.go new file mode 100644 index 000000000000..d441cfe300ac --- /dev/null +++ b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/listener_mod.go @@ -0,0 +1,80 @@ +package v1alpha1 + +import ( + envoy_listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + envoy_resource "github.com/envoyproxy/go-control-plane/pkg/resource/v3" + "github.com/pkg/errors" + + mesh_proto "github.com/kumahq/kuma/api/mesh/v1alpha1" + core_xds "github.com/kumahq/kuma/pkg/core/xds" + api "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/api/v1alpha1" + util_proto "github.com/kumahq/kuma/pkg/util/proto" + envoy_metadata "github.com/kumahq/kuma/pkg/xds/envoy/metadata/v3" +) + +type listenerModificator api.ListenerMod + +func (l *listenerModificator) apply(resources *core_xds.ResourceSet) error { + listener := &envoy_listener.Listener{} + if l.Value != nil { + if err := util_proto.FromYAML([]byte(*l.Value), listener); err != nil { + return err + } + } + switch l.Operation { + case api.ModOpAdd: + l.add(resources, listener) + case api.ModOpRemove: + l.remove(resources) + case api.ModOpPatch: + l.patch(resources, listener) + default: + return errors.Errorf("invalid operation: %s", l.Operation) + } + return nil +} + +func (l *listenerModificator) patch(resources *core_xds.ResourceSet, listenerPatch *envoy_listener.Listener) { + for _, listener := range resources.Resources(envoy_resource.ListenerType) { + if l.listenerMatches(listener) { + util_proto.Merge(listener.Resource, listenerPatch) + } + } +} + +func (l *listenerModificator) remove(resources *core_xds.ResourceSet) { + for name, resource := range resources.Resources(envoy_resource.ListenerType) { + if l.listenerMatches(resource) { + resources.Remove(envoy_resource.ListenerType, name) + } + } +} + +func (l *listenerModificator) add(resources *core_xds.ResourceSet, listener *envoy_listener.Listener) *core_xds.ResourceSet { + return resources.Add(&core_xds.Resource{ + Name: listener.Name, + Origin: Origin, + Resource: listener, + }) +} + +func (l *listenerModificator) listenerMatches(listener *core_xds.Resource) bool { + if l.Match == nil { + return true + } + if l.Match.Name != nil && *l.Match.Name != listener.Name { + return false + } + if l.Match.Origin != nil && *l.Match.Origin != listener.Origin { + return false + } + if len(l.Match.Tags) > 0 { + if listenerProto, ok := listener.Resource.(*envoy_listener.Listener); ok { + listenerTags := envoy_metadata.ExtractTags(listenerProto.Metadata) + if !mesh_proto.TagSelector(l.Match.Tags).Matches(listenerTags) { + return false + } + } + } + return true +} diff --git a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/listener_mod_test.go b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/listener_mod_test.go new file mode 100644 index 000000000000..b833e0cc6b0d --- /dev/null +++ b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/listener_mod_test.go @@ -0,0 +1,316 @@ +package v1alpha1_test + +import ( + envoy_listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/yaml" + + core_xds "github.com/kumahq/kuma/pkg/core/xds" + api "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/api/v1alpha1" + plugin "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1" + util_proto "github.com/kumahq/kuma/pkg/util/proto" + "github.com/kumahq/kuma/pkg/xds/generator" +) + +var _ = Describe("Listener modifications", func() { + + type testCase struct { + listeners []string + modifications []string + expected string + } + + DescribeTable("should apply modifications", + func(given testCase) { + // given + set := core_xds.NewResourceSet() + for _, listenerYAML := range given.listeners { + listener := &envoy_listener.Listener{} + err := util_proto.FromYAML([]byte(listenerYAML), listener) + Expect(err).ToNot(HaveOccurred()) + set.Add(&core_xds.Resource{ + Name: listener.Name, + Origin: generator.OriginInbound, + Resource: listener, + }) + } + + var mods []api.Modification + for _, modificationYAML := range given.modifications { + modification := api.Modification{} + err := yaml.Unmarshal([]byte(modificationYAML), &modification) + Expect(err).ToNot(HaveOccurred()) + mods = append(mods, modification) + } + + // when + err := plugin.ApplyMods(set, mods) + + // then + Expect(err).ToNot(HaveOccurred()) + resp, err := set.List().ToDeltaDiscoveryResponse() + Expect(err).ToNot(HaveOccurred()) + actual, err := util_proto.ToYAML(resp) + Expect(err).ToNot(HaveOccurred()) + Expect(actual).To(MatchYAML(given.expected)) + }, + Entry("should add listener", testCase{ + modifications: []string{` + listener: + operation: Add + value: | + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: localhost:8080 + statPrefix: localhost_8080`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: localhost:8080 + statPrefix: localhost_8080 + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + Entry("should replace listener", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080`, + }, + modifications: []string{ + ` + listener: + operation: Add + value: | + name: inbound:192.168.0.1:8080 + address: + socketAddress: + address: 192.168.0.2 + portValue: 8090`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + name: inbound:192.168.0.1:8080 + address: + socketAddress: + address: 192.168.0.2 + portValue: 8090`, + }), + Entry("should remove listener matching all", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080`, + }, + modifications: []string{ + ` + listener: + operation: Remove`, + }, + expected: `{}`, + }), + Entry("should remove listener matching name", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080`, + ` + name: inbound:192.168.0.1:8081 + trafficDirection: INBOUND + address: + socketAddress: + address: 192.168.0.1 + portValue: 8081`, + }, + modifications: []string{ + ` + listener: + operation: Remove + match: + name: inbound:192.168.0.1:8080`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8081 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8081 + name: inbound:192.168.0.1:8081 + trafficDirection: INBOUND`, + }), + Entry("should remove all inbound listeners", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080`, + }, + modifications: []string{ + ` + listener: + operation: Remove + match: + origin: inbound`, + }, + expected: `{}`, + }), + Entry("should patch listener matching name", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080`, + }, + modifications: []string{ + ` + listener: + operation: Patch + match: + name: inbound:192.168.0.1:8080 + value: | + tcpFastOpenQueueLength: 32`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + name: inbound:192.168.0.1:8080 + tcpFastOpenQueueLength: 32 + trafficDirection: INBOUND`, + }), + Entry("should patch listener matching metadata", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + metadata: + filterMetadata: + io.kuma.tags: + kuma.io/service: backend`, + }, + modifications: []string{ + ` + listener: + operation: Patch + match: + name: inbound:192.168.0.1:8080 + tags: + kuma.io/service: backend + value: | + tcpFastOpenQueueLength: 32`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + metadata: + filterMetadata: + io.kuma.tags: + kuma.io/service: backend + name: inbound:192.168.0.1:8080 + tcpFastOpenQueueLength: 32 + trafficDirection: INBOUND`, + }), + Entry("should not patch listener with non-matching metadata", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + metadata: + filterMetadata: + io.kuma.tags: + kuma.io/service: backend`, + }, + modifications: []string{ + ` + listener: + operation: Patch + match: + name: inbound:192.168.0.1:8080 + tags: + kuma.io/service: web + value: | + tcpFastOpenQueueLength: 32`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + metadata: + filterMetadata: + io.kuma.tags: + kuma.io/service: backend + name: inbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + ) +}) diff --git a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/network_filter_mod.go b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/network_filter_mod.go new file mode 100644 index 000000000000..3b9ad0d89749 --- /dev/null +++ b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/network_filter_mod.go @@ -0,0 +1,142 @@ +package v1alpha1 + +import ( + envoy_listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + envoy_resource "github.com/envoyproxy/go-control-plane/pkg/resource/v3" + "github.com/pkg/errors" + + mesh_proto "github.com/kumahq/kuma/api/mesh/v1alpha1" + core_xds "github.com/kumahq/kuma/pkg/core/xds" + api "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/api/v1alpha1" + util_proto "github.com/kumahq/kuma/pkg/util/proto" + envoy_metadata "github.com/kumahq/kuma/pkg/xds/envoy/metadata/v3" +) + +type networkFilterModificator api.NetworkFilterMod + +func (n *networkFilterModificator) apply(resources *core_xds.ResourceSet) error { + filter := &envoy_listener.Filter{} + if n.Value != nil { + if err := util_proto.FromYAML([]byte(*n.Value), filter); err != nil { + return err + } + } + for _, resource := range resources.Resources(envoy_resource.ListenerType) { + if n.listenerMatches(resource) { + listener := resource.Resource.(*envoy_listener.Listener) + for _, chain := range listener.FilterChains { // apply on all filter chains. We could introduce filter chain matcher as an improvement. + switch n.Operation { + case api.ModOpAddFirst: + n.addFirst(chain, filter) + case api.ModOpAddLast: + n.addLast(chain, filter) + case api.ModOpAddAfter: + n.addAfter(chain, filter) + case api.ModOpAddBefore: + n.addBefore(chain, filter) + case api.ModOpRemove: + n.remove(chain) + case api.ModOpPatch: + if err := n.patch(chain, filter); err != nil { + return errors.Wrap(err, "could not patch the resource") + } + default: + return errors.Errorf("invalid operation: %s", n.Operation) + } + } + } + } + return nil +} + +func (n *networkFilterModificator) addFirst(chain *envoy_listener.FilterChain, filter *envoy_listener.Filter) { + chain.Filters = append([]*envoy_listener.Filter{filter}, chain.Filters...) +} + +func (n *networkFilterModificator) addLast(chain *envoy_listener.FilterChain, filter *envoy_listener.Filter) { + chain.Filters = append(chain.Filters, filter) +} + +func (n *networkFilterModificator) addAfter(chain *envoy_listener.FilterChain, filter *envoy_listener.Filter) { + idx := n.indexOfMatchedFilter(chain) + if idx != -1 { + chain.Filters = append(chain.Filters, nil) + copy(chain.Filters[idx+2:], chain.Filters[idx+1:]) + chain.Filters[idx+1] = filter + } +} + +func (n *networkFilterModificator) addBefore(chain *envoy_listener.FilterChain, filter *envoy_listener.Filter) { + idx := n.indexOfMatchedFilter(chain) + if idx != -1 { + chain.Filters = append(chain.Filters, nil) + copy(chain.Filters[idx+1:], chain.Filters[idx:]) + chain.Filters[idx] = filter + } +} + +func (n *networkFilterModificator) remove(chain *envoy_listener.FilterChain) { + var filters []*envoy_listener.Filter + for _, filter := range chain.Filters { + if !n.filterMatches(filter) { + filters = append(filters, filter) + } + } + chain.Filters = filters +} + +func (n *networkFilterModificator) patch(chain *envoy_listener.FilterChain, filterPatch *envoy_listener.Filter) error { + for _, filter := range chain.Filters { + if n.filterMatches(filter) { + any, err := util_proto.MergeAnys(filter.GetTypedConfig(), filterPatch.GetTypedConfig()) + if err != nil { + return err + } + + filter.ConfigType = &envoy_listener.Filter_TypedConfig{ + TypedConfig: any, + } + } + } + return nil +} + +func (n *networkFilterModificator) filterMatches(filter *envoy_listener.Filter) bool { + if n.Match == nil { + return true + } + if n.Match.Name != nil && *n.Match.Name != filter.Name { + return false + } + return true +} + +func (n *networkFilterModificator) listenerMatches(resource *core_xds.Resource) bool { + if n.Match == nil { + return true + } + if n.Match.ListenerName != nil && *n.Match.ListenerName != resource.Name { + return false + } + if n.Match.Origin != nil && *n.Match.Origin != resource.Origin { + return false + } + if len(n.Match.ListenerTags) > 0 { + if listenerProto, ok := resource.Resource.(*envoy_listener.Listener); ok { + listenerTags := envoy_metadata.ExtractTags(listenerProto.Metadata) + if !mesh_proto.TagSelector(n.Match.ListenerTags).Matches(listenerTags) { + return false + } + } + } + return true +} + +func (n *networkFilterModificator) indexOfMatchedFilter(chain *envoy_listener.FilterChain) int { + for i, filter := range chain.Filters { + if n.Match != nil && filter.Name == *n.Match.Name { + return i + } + } + return -1 +} diff --git a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/network_filter_mod_test.go b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/network_filter_mod_test.go new file mode 100644 index 000000000000..8b96632bc637 --- /dev/null +++ b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/network_filter_mod_test.go @@ -0,0 +1,739 @@ +package v1alpha1_test + +import ( + envoy_listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/yaml" + + core_xds "github.com/kumahq/kuma/pkg/core/xds" + api "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/api/v1alpha1" + plugin "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1" + util_proto "github.com/kumahq/kuma/pkg/util/proto" + "github.com/kumahq/kuma/pkg/xds/generator" +) + +var _ = Describe("Network Filter modifications", func() { + + type testCase struct { + listeners []string + modifications []string + expected string + } + + DescribeTable("should apply modifications", + func(given testCase) { + // given + set := core_xds.NewResourceSet() + for _, listenerYAML := range given.listeners { + listener := &envoy_listener.Listener{} + err := util_proto.FromYAML([]byte(listenerYAML), listener) + Expect(err).ToNot(HaveOccurred()) + set.Add(&core_xds.Resource{ + Name: listener.Name, + Origin: generator.OriginInbound, + Resource: listener, + }) + } + + var mods []api.Modification + for _, modificationYAML := range given.modifications { + modification := api.Modification{} + err := yaml.Unmarshal([]byte(modificationYAML), &modification) + Expect(err).ToNot(HaveOccurred()) + mods = append(mods, modification) + } + + // when + err := plugin.ApplyMods(set, mods) + + // then + Expect(err).ToNot(HaveOccurred()) + resp, err := set.List().ToDeltaDiscoveryResponse() + Expect(err).ToNot(HaveOccurred()) + actual, err := util_proto.ToYAML(resp) + Expect(err).ToNot(HaveOccurred()) + Expect(actual).To(MatchYAML(given.expected)) + }, + Entry("should not add filter when there is no filter match", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080`, + }, + modifications: []string{` + networkFilter: + operation: AddFirst + value: | + name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + name: inbound:192.168.0.1:8080`, + }), + Entry("should add filter as the first", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: "xyz"`, + }, + modifications: []string{` + networkFilter: + operation: AddFirst + value: | + name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - filters: + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: xyz + name: inbound:192.168.0.1:8080`, + }), + Entry("should add filter as the last", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: "xyz"`, + }, + modifications: []string{` + networkFilter: + operation: AddLast + value: | + name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: xyz + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend + name: inbound:192.168.0.1:8080`, + }), + Entry("should remove all filters from all listeners when there is no match section", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: "xyz" + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend`, + ` + name: inbound:192.168.0.1:8081 + filterChains: + - filters: + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend`, + }, + modifications: []string{` + networkFilter: + operation: Remove +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - {} + name: inbound:192.168.0.1:8080 + - name: inbound:192.168.0.1:8081 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - {} + name: inbound:192.168.0.1:8081`, + }), + Entry("should remove all filters from all listeners when there is inbound match section", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: "xyz" + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend`, + ` + name: inbound:192.168.0.1:8081 + filterChains: + - filters: + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend`, + }, + modifications: []string{` + networkFilter: + operation: Remove + match: + origin: inbound +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - {} + name: inbound:192.168.0.1:8080 + - name: inbound:192.168.0.1:8081 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - {} + name: inbound:192.168.0.1:8081`, + }), + Entry("should remove all filters from picked listener", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: "xyz" + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend`, + ` + name: inbound:192.168.0.1:8081 + filterChains: + - filters: + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend`, + }, + modifications: []string{` + networkFilter: + operation: Remove + match: + listenerName: inbound:192.168.0.1:8080 +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - {} + name: inbound:192.168.0.1:8080 + - name: inbound:192.168.0.1:8081 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - filters: + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend + name: inbound:192.168.0.1:8081`, + }), + Entry("should remove all filters of given name from all listeners", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: "xyz" + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend`, + ` + name: inbound:192.168.0.1:8081 + filterChains: + - filters: + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend`, + }, + modifications: []string{` + networkFilter: + operation: Remove + match: + name: envoy.filters.network.tcp_proxy +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: xyz + name: inbound:192.168.0.1:8080 + - name: inbound:192.168.0.1:8081 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - {} + name: inbound:192.168.0.1:8081`, + }), + Entry("should add filter after already defined (last)", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: "xyz"`, + }, + modifications: []string{` + networkFilter: + operation: AddAfter + match: + name: envoy.filters.network.direct_response + value: | + name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: xyz + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend + name: inbound:192.168.0.1:8080`, + }), + Entry("should add filter after already defined", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: "xyz" + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend +`, + }, + modifications: []string{` + networkFilter: + operation: AddAfter + match: + name: envoy.filters.network.direct_response + value: | + name: envoy.filters.network.echo + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.echo.v3.Echo +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: xyz + - name: envoy.filters.network.echo + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.echo.v3.Echo + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend + name: inbound:192.168.0.1:8080`, + }), + Entry("should not add filter when name is not matched", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + filterChains: + - {}`, + }, + modifications: []string{` + networkFilter: + operation: AddAfter + match: + name: envoy.filters.network.direct_response + value: | + name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - {} + name: inbound:192.168.0.1:8080`, + }), + Entry("should add filter before already defined (first)", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: "xyz"`, + }, + modifications: []string{` + networkFilter: + operation: AddBefore + match: + name: envoy.filters.network.direct_response + value: | + name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - filters: + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: xyz + name: inbound:192.168.0.1:8080`, + }), + Entry("should add filter before already defined", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: "xyz" + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend +`, + }, + modifications: []string{` + networkFilter: + operation: AddBefore + match: + name: envoy.filters.network.tcp_proxy + value: | + name: envoy.filters.network.echo + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.echo.v3.Echo +`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - filters: + - name: envoy.filters.network.direct_response + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.direct_response.v3.Config + response: + inlineString: xyz + - name: envoy.filters.network.echo + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.echo.v3.Echo + - name: envoy.filters.network.tcp_proxy + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + cluster: backend + name: inbound:192.168.0.1:8080`, + }), + Entry("should patch resource matching filter name", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: backend + rds: + configSource: + ads: {} + routeConfigName: outbound:backend + httpFilters: + - name: router +`, + }, + modifications: []string{` + networkFilter: + operation: Patch + match: + name: envoy.filters.network.http_connection_manager + value: | + name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + streamIdleTimeout: 5s + requestTimeout: 2s + drainTimeout: 10s`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + drainTimeout: 10s + httpFilters: + - name: router + rds: + configSource: + ads: {} + routeConfigName: outbound:backend + requestTimeout: 2s + statPrefix: backend + streamIdleTimeout: 5s + name: inbound:192.168.0.1:8080`, + }), + Entry("should patch resource providing config", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager +`, + }, + modifications: []string{` + networkFilter: + operation: Patch + match: + name: envoy.filters.network.http_connection_manager + value: | + name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + statPrefix: backend`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + statPrefix: backend + name: inbound:192.168.0.1:8080`, + }), + Entry("should patch resource matching listener tags", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + metadata: + filterMetadata: + io.kuma.tags: + kuma.io/service: backend + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager +`, + }, + modifications: []string{` + networkFilter: + operation: Patch + match: + name: envoy.filters.network.http_connection_manager + listenerTags: + kuma.io/service: backend + value: | + name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + statPrefix: backend`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + metadata: + filterMetadata: + io.kuma.tags: + kuma.io/service: backend + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + statPrefix: backend + name: inbound:192.168.0.1:8080`, + }), + Entry("should not patch resource with non matching listener tags", testCase{ + listeners: []string{ + ` + name: inbound:192.168.0.1:8080 + metadata: + filterMetadata: + io.kuma.tags: + kuma.io/service: backend + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager +`, + }, + modifications: []string{` + networkFilter: + operation: Patch + match: + name: envoy.filters.network.http_connection_manager + listenerTags: + kuma.io/service: web + value: | + name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + statPrefix: backend`, + }, + expected: ` + resources: + - name: inbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + metadata: + filterMetadata: + io.kuma.tags: + kuma.io/service: backend + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + name: inbound:192.168.0.1:8080`, + }), + ) +}) diff --git a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/plugin.go b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/plugin.go index bf7e4a41a3ee..f4229087f61a 100644 --- a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/plugin.go +++ b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/plugin.go @@ -1,7 +1,8 @@ package v1alpha1 import ( - "github.com/kumahq/kuma/pkg/core" + "github.com/pkg/errors" + core_plugins "github.com/kumahq/kuma/pkg/core/plugins" core_mesh "github.com/kumahq/kuma/pkg/core/resources/apis/mesh" core_xds "github.com/kumahq/kuma/pkg/core/xds" @@ -10,12 +11,17 @@ import ( xds_context "github.com/kumahq/kuma/pkg/xds/context" ) -var _ core_plugins.PolicyPlugin = &plugin{} -var log = core.Log.WithName("MeshProxyPatch") +var Origin = "mesh-proxy-patch" + +type modificator interface { + apply(*core_xds.ResourceSet) error +} type plugin struct { } +var _ core_plugins.PolicyPlugin = &plugin{} + func NewPlugin() core_plugins.Plugin { return &plugin{} } @@ -24,7 +30,47 @@ func (p plugin) MatchedPolicies(dataplane *core_mesh.DataplaneResource, resource return matchers.MatchedPolicies(api.MeshProxyPatchType, dataplane, resources) } -func (p plugin) Apply(rs *core_xds.ResourceSet, ctx xds_context.Context, proxy *core_xds.Proxy) error { - log.Info("apply is not implemented") +func (p plugin) Apply(rs *core_xds.ResourceSet, _ xds_context.Context, proxy *core_xds.Proxy) error { + policies, ok := proxy.Policies.Dynamic[api.MeshProxyPatchType] + if !ok { + return nil + } + if len(policies.SingleItemRules.Rules) == 0 { + return nil + } + rule := policies.SingleItemRules.Rules.Compute(core_xds.MeshSubset()) + conf := rule.Conf.(api.Conf) + if err := ApplyMods(rs, conf.AppendModifications); err != nil { + return err + } + return nil +} + +func ApplyMods(resources *core_xds.ResourceSet, modifications []api.Modification) error { + for i, modification := range modifications { + var modificator modificator + switch { + case modification.Cluster != nil: + mod := clusterModificator(*modification.Cluster) + modificator = &mod + case modification.Listener != nil: + mod := listenerModificator(*modification.Listener) + modificator = &mod + case modification.NetworkFilter != nil: + mod := networkFilterModificator(*modification.NetworkFilter) + modificator = &mod + case modification.HTTPFilter != nil: + mod := httpFilterModificator(*modification.HTTPFilter) + modificator = &mod + case modification.VirtualHost != nil: + mod := virtualHostModificator(*modification.VirtualHost) + modificator = &mod + default: + return errors.Errorf("invalid modification") + } + if err := modificator.apply(resources); err != nil { + return errors.Wrapf(err, "could not apply %d modification", i) + } + } return nil } diff --git a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/plugin_test.go b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/plugin_test.go new file mode 100644 index 000000000000..07f5fcad6e12 --- /dev/null +++ b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/plugin_test.go @@ -0,0 +1,106 @@ +package v1alpha1_test + +import ( + envoy_resource "github.com/envoyproxy/go-control-plane/pkg/resource/v3" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + core_plugins "github.com/kumahq/kuma/pkg/core/plugins" + core_model "github.com/kumahq/kuma/pkg/core/resources/model" + core_xds "github.com/kumahq/kuma/pkg/core/xds" + api "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/api/v1alpha1" + plugin "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1" + policies_xds "github.com/kumahq/kuma/pkg/plugins/policies/xds" + "github.com/kumahq/kuma/pkg/test/resources/samples" + "github.com/kumahq/kuma/pkg/util/pointer" + xds_context "github.com/kumahq/kuma/pkg/xds/context" + envoy_common "github.com/kumahq/kuma/pkg/xds/envoy" + "github.com/kumahq/kuma/pkg/xds/envoy/clusters" + "github.com/kumahq/kuma/pkg/xds/generator" +) + +var _ = Describe("MeshProxyPatch", func() { + type testCase struct { + resources []core_xds.Resource + rules core_xds.SingleItemRules + expectedClusters []string + } + + DescribeTable("should generate proper Envoy config", + func(given testCase) { + resources := core_xds.NewResourceSet() + for _, res := range given.resources { + r := res + resources.Add(&r) + } + + context := xds_context.Context{} + proxy := core_xds.Proxy{ + APIVersion: envoy_common.APIV3, + Dataplane: samples.DataplaneBackend(), + Policies: core_xds.MatchedPolicies{ + Dynamic: map[core_model.ResourceType]core_xds.TypedMatchingPolicies{ + api.MeshProxyPatchType: { + Type: api.MeshProxyPatchType, + SingleItemRules: given.rules, + }, + }, + }, + } + plugin := plugin.NewPlugin().(core_plugins.PolicyPlugin) + + Expect(plugin.Apply(resources, context, &proxy)).To(Succeed()) + policies_xds.ResourceArrayShouldEqual(resources.ListOf(envoy_resource.ClusterType), given.expectedClusters) + }, + Entry("add and patch a cluster", testCase{ + resources: []core_xds.Resource{ + { + Name: "echo-http", + Origin: generator.OriginOutbound, + Resource: clusters.NewClusterBuilder(envoy_common.APIV3). + Configure(policies_xds.WithName("echo-http")). + MustBuild(), + }, + }, + rules: core_xds.SingleItemRules{ + Rules: []*core_xds.Rule{ + { + Subset: core_xds.Subset{}, + Conf: api.Conf{ + AppendModifications: []api.Modification{ + { + Cluster: &api.ClusterMod{ + Operation: api.ModOpAdd, + Value: pointer.To(` +name: new-cluster +connectTimeout: 5s +`), + }, + }, + { + Cluster: &api.ClusterMod{ + Operation: api.ModOpPatch, + Match: &api.ClusterMatch{ + Name: pointer.To("echo-http"), + }, + Value: pointer.To(` +connectTimeout: 100s +`), + }, + }, + }, + }, + }, + }, + }, + expectedClusters: []string{` +name: echo-http +connectTimeout: 100s +`, + ` +name: new-cluster +connectTimeout: 5s`, + }, + }), + ) +}) diff --git a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/suite_test.go b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/suite_test.go new file mode 100644 index 000000000000..793712822f47 --- /dev/null +++ b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/suite_test.go @@ -0,0 +1,12 @@ +package v1alpha1_test + +import ( + "testing" + + "github.com/kumahq/kuma/pkg/test" + _ "github.com/kumahq/kuma/pkg/xds/envoy" +) + +func TestModifications(t *testing.T) { + test.RunSpecs(t, "MeshProxyPatch Plugin Suite") +} diff --git a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/virtual_host_mod.go b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/virtual_host_mod.go new file mode 100644 index 000000000000..69127fccfb30 --- /dev/null +++ b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/virtual_host_mod.go @@ -0,0 +1,123 @@ +package v1alpha1 + +import ( + envoy_listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + envoy_route "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + envoy_hcm "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + envoy_resource "github.com/envoyproxy/go-control-plane/pkg/resource/v3" + "github.com/pkg/errors" + + core_xds "github.com/kumahq/kuma/pkg/core/xds" + api "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/api/v1alpha1" + util_proto "github.com/kumahq/kuma/pkg/util/proto" +) + +// virtualHostModificator assumes that the routes are specified as `routeConfig` in Listeners, not through RDS +// If we ever change it to RDS we need to modify RouteConfiguration objects +type virtualHostModificator api.VirtualHostMod + +func (c *virtualHostModificator) apply(resources *core_xds.ResourceSet) error { + virtualHost := &envoy_route.VirtualHost{} + if c.Value != nil { + if err := util_proto.FromYAML([]byte(*c.Value), virtualHost); err != nil { + return err + } + } + + for _, resource := range resources.Resources(envoy_resource.ListenerType) { + listener := resource.Resource.(*envoy_listener.Listener) + if !c.originMatches(resource) { + continue + } + for _, chain := range listener.FilterChains { // apply on all filter chains. We could introduce filter chain matcher as an improvement. + for _, networkFilter := range chain.Filters { + if networkFilter.Name == "envoy.filters.network.http_connection_manager" { + hcm := &envoy_hcm.HttpConnectionManager{} + err := util_proto.UnmarshalAnyTo(networkFilter.ConfigType.(*envoy_listener.Filter_TypedConfig).TypedConfig, hcm) + if err != nil { + return err + } + if err := c.applyHCMModification(hcm, virtualHost); err != nil { + return err + } + any, err := util_proto.MarshalAnyDeterministic(hcm) + if err != nil { + return err + } + networkFilter.ConfigType.(*envoy_listener.Filter_TypedConfig).TypedConfig = any + } + } + } + } + return nil +} + +func (c *virtualHostModificator) applyHCMModification(hcm *envoy_hcm.HttpConnectionManager, virtualHost *envoy_route.VirtualHost) error { + routeCfg := hcm.GetRouteConfig() + if routeCfg == nil { + return nil // ignore HCMs without embedded routes + } + if !c.routeConfigurationMatches(routeCfg) { + return nil + } + switch c.Operation { + case api.ModOpAdd: + c.add(routeCfg, virtualHost) + case api.ModOpRemove: + c.remove(routeCfg) + case api.ModOpPatch: + c.patch(routeCfg, virtualHost) + default: + return errors.Errorf("invalid operation: %s", c.Operation) + } + return nil +} + +func (c *virtualHostModificator) patch(routeCfg *envoy_route.RouteConfiguration, vHostPatch *envoy_route.VirtualHost) { + for _, vHost := range routeCfg.VirtualHosts { + if c.virtualHostMatches(vHost) { + util_proto.Merge(vHost, vHostPatch) + } + } +} + +func (c *virtualHostModificator) remove(routeCfg *envoy_route.RouteConfiguration) { + var vHosts []*envoy_route.VirtualHost + for _, vHost := range routeCfg.VirtualHosts { + if !c.virtualHostMatches(vHost) { + vHosts = append(vHosts, vHost) + } + } + routeCfg.VirtualHosts = vHosts +} + +func (c *virtualHostModificator) add(routeCfg *envoy_route.RouteConfiguration, vHost *envoy_route.VirtualHost) { + routeCfg.VirtualHosts = append(routeCfg.VirtualHosts, vHost) +} + +func (c *virtualHostModificator) virtualHostMatches(vHost *envoy_route.VirtualHost) bool { + if c.Match == nil { + return true + } + if c.Match.Name != nil && *c.Match.Name != vHost.Name { + return false + } + return true +} + +func (c *virtualHostModificator) originMatches(routeCfg *core_xds.Resource) bool { + if c.Match == nil { + return true + } + return c.Match.Origin == nil || (*c.Match.Origin == routeCfg.Origin) +} + +func (c *virtualHostModificator) routeConfigurationMatches(routeCfg *envoy_route.RouteConfiguration) bool { + if c.Match == nil { + return true + } + if c.Match.RouteConfigurationName != nil && *c.Match.RouteConfigurationName != routeCfg.Name { + return false + } + return true +} diff --git a/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/virtual_host_test.go b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/virtual_host_test.go new file mode 100644 index 000000000000..05851ca4a818 --- /dev/null +++ b/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1/virtual_host_test.go @@ -0,0 +1,334 @@ +package v1alpha1_test + +import ( + envoy_listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/yaml" + + core_xds "github.com/kumahq/kuma/pkg/core/xds" + api "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/api/v1alpha1" + plugin "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/plugin/v1alpha1" + util_proto "github.com/kumahq/kuma/pkg/util/proto" + "github.com/kumahq/kuma/pkg/xds/generator" +) + +var _ = Describe("Virtual Host modifications", func() { + + type testCase struct { + routeCfgs []string + modifications []string + expected string + } + + DescribeTable("should apply modifications", + func(given testCase) { + // given + set := core_xds.NewResourceSet() + for _, routeCfgYAML := range given.routeCfgs { + routeCfg := &envoy_listener.Listener{} + err := util_proto.FromYAML([]byte(routeCfgYAML), routeCfg) + Expect(err).ToNot(HaveOccurred()) + set.Add(&core_xds.Resource{ + Name: routeCfg.Name, + Origin: generator.OriginOutbound, + Resource: routeCfg, + }) + } + + var mods []api.Modification + for _, modificationYAML := range given.modifications { + modification := api.Modification{} + err := yaml.Unmarshal([]byte(modificationYAML), &modification) + Expect(err).ToNot(HaveOccurred()) + mods = append(mods, modification) + } + + // when + err := plugin.ApplyMods(set, mods) + + // then + Expect(err).ToNot(HaveOccurred()) + resp, err := set.List().ToDeltaDiscoveryResponse() + Expect(err).ToNot(HaveOccurred()) + actual, err := util_proto.ToYAML(resp) + Expect(err).ToNot(HaveOccurred()) + Expect(actual).To(MatchYAML(given.expected)) + }, + Entry("should add virtual host", testCase{ + routeCfgs: []string{ + ` + name: outbound:192.168.0.1:8080 + trafficDirection: INBOUND + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + statPrefix: localhost_8080 + httpFilters: + - name: envoy.filters.http.router + routeConfig: + name: outbound:backend +`, + }, + modifications: []string{` + virtualHost: + operation: Add + value: | + name: backend + domains: + - backend.com + routes: + - match: + prefix: / + route: + cluster: backend`, + }, + expected: ` + resources: + - name: outbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + routeConfig: + name: outbound:backend + virtualHosts: + - domains: + - backend.com + name: backend + routes: + - match: + prefix: / + route: + cluster: backend + statPrefix: localhost_8080 + name: outbound:192.168.0.1:8080 + trafficDirection: INBOUND +`, + }), + Entry("should remove virtual host", testCase{ + routeCfgs: []string{ + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + routeConfig: + name: outbound:backend + virtualHosts: + - domains: + - backend.com + name: backend + routes: + - match: + prefix: / + route: + cluster: backend + statPrefix: localhost_8080 + name: outbound:192.168.0.1:8080 + trafficDirection: INBOUND +`, + }, + modifications: []string{` + virtualHost: + operation: Remove + match: + name: backend`, + }, + expected: ` + resources: + - name: outbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + routeConfig: + name: outbound:backend + statPrefix: localhost_8080 + name: outbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + Entry("should patch a virtual host", testCase{ + routeCfgs: []string{ + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + routeConfig: + name: outbound:backend + virtualHosts: + - domains: + - backend.com + name: backend + routes: + - match: + prefix: / + route: + cluster: backend + statPrefix: localhost_8080 + name: outbound:192.168.0.1:8080 + trafficDirection: INBOUND +`, + }, + modifications: []string{` + virtualHost: + operation: Patch + match: + origin: outbound + value: | + retryPolicy: + retryOn: 5xx + numRetries: 3`, + }, + expected: ` + resources: + - name: outbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + routeConfig: + name: outbound:backend + virtualHosts: + - domains: + - backend.com + name: backend + retryPolicy: + numRetries: 3 + retryOn: 5xx + routes: + - match: + prefix: / + route: + cluster: backend + statPrefix: localhost_8080 + name: outbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + Entry("should patch a virtual host adding new route", testCase{ + routeCfgs: []string{ + ` + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + routeConfig: + name: outbound:backend + virtualHosts: + - domains: + - backend.com + name: backend + routes: + - match: + prefix: / + route: + cluster: backend + statPrefix: localhost_8080 + name: outbound:192.168.0.1:8080 + trafficDirection: INBOUND +`, + }, + modifications: []string{` + virtualHost: + operation: Patch + match: + routeConfigurationName: outbound:backend + value: | + routes: + - match: + prefix: /web + route: + cluster: web`, + }, + expected: ` + resources: + - name: outbound:192.168.0.1:8080 + resource: + '@type': type.googleapis.com/envoy.config.listener.v3.Listener + address: + socketAddress: + address: 192.168.0.1 + portValue: 8080 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + routeConfig: + name: outbound:backend + virtualHosts: + - domains: + - backend.com + name: backend + routes: + - match: + prefix: / + route: + cluster: backend + - match: + prefix: /web + route: + cluster: web + statPrefix: localhost_8080 + name: outbound:192.168.0.1:8080 + trafficDirection: INBOUND`, + }), + ) +}) diff --git a/pkg/plugins/policies/policies.go b/pkg/plugins/policies/policies.go index a4d81cf7b7fa..9238de39a801 100644 --- a/pkg/plugins/policies/policies.go +++ b/pkg/plugins/policies/policies.go @@ -5,6 +5,7 @@ import ( meshaccesslog_api "github.com/kumahq/kuma/pkg/plugins/policies/meshaccesslog/api/v1alpha1" meshcircuitbreaker_api "github.com/kumahq/kuma/pkg/plugins/policies/meshcircuitbreaker/api/v1alpha1" meshhealthcheck_api "github.com/kumahq/kuma/pkg/plugins/policies/meshhealthcheck/api/v1alpha1" + meshproxypatch_api "github.com/kumahq/kuma/pkg/plugins/policies/meshproxypatch/api/v1alpha1" meshratelimit_api "github.com/kumahq/kuma/pkg/plugins/policies/meshratelimit/api/v1alpha1" meshretry_api "github.com/kumahq/kuma/pkg/plugins/policies/meshretry/api/v1alpha1" meshtimeout_api "github.com/kumahq/kuma/pkg/plugins/policies/meshtimeout/api/v1alpha1" @@ -21,4 +22,5 @@ var Policies = []plugins.PluginName{ plugins.PluginName(meshcircuitbreaker_api.MeshCircuitBreakerResourceTypeDescriptor.KumactlArg), plugins.PluginName(meshhealthcheck_api.MeshHealthCheckResourceTypeDescriptor.KumactlArg), plugins.PluginName(meshretry_api.MeshRetryResourceTypeDescriptor.KumactlArg), + plugins.PluginName(meshproxypatch_api.MeshProxyPatchResourceTypeDescriptor.KumactlArg), } diff --git a/test/e2e_env/kubernetes/kubernetes_suite_test.go b/test/e2e_env/kubernetes/kubernetes_suite_test.go index 6a8df7062a93..e8fbafefa91a 100644 --- a/test/e2e_env/kubernetes/kubernetes_suite_test.go +++ b/test/e2e_env/kubernetes/kubernetes_suite_test.go @@ -24,6 +24,7 @@ import ( "github.com/kumahq/kuma/test/e2e_env/kubernetes/membership" "github.com/kumahq/kuma/test/e2e_env/kubernetes/meshcircuitbreaker" "github.com/kumahq/kuma/test/e2e_env/kubernetes/meshhealthcheck" + "github.com/kumahq/kuma/test/e2e_env/kubernetes/meshproxypatch" "github.com/kumahq/kuma/test/e2e_env/kubernetes/meshratelimit" "github.com/kumahq/kuma/test/e2e_env/kubernetes/meshtimeout" "github.com/kumahq/kuma/test/e2e_env/kubernetes/meshtrafficpermission" @@ -126,3 +127,4 @@ var _ = Describe("MeshTimeout API", meshtimeout.MeshTimeout, Ordered) var _ = Describe("MeshHealthCheck API", meshhealthcheck.API, Ordered) var _ = Describe("MeshCircuitBreaker API", meshcircuitbreaker.API, Ordered) var _ = Describe("MeshCircuitBreaker", meshcircuitbreaker.MeshCircuitBreaker, Ordered) +var _ = Describe("MeshProxyPatch", meshproxypatch.MeshProxyPatch, Ordered) diff --git a/test/e2e_env/kubernetes/meshproxypatch/meshproxypatch.go b/test/e2e_env/kubernetes/meshproxypatch/meshproxypatch.go new file mode 100644 index 000000000000..551e16a8d9ee --- /dev/null +++ b/test/e2e_env/kubernetes/meshproxypatch/meshproxypatch.go @@ -0,0 +1,83 @@ +package meshproxypatch + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/kumahq/kuma/test/e2e_env/kubernetes/env" + . "github.com/kumahq/kuma/test/framework" + "github.com/kumahq/kuma/test/framework/client" + "github.com/kumahq/kuma/test/framework/deployments/testserver" +) + +func MeshProxyPatch() { + const meshName = "mesh-proxy-patch" + const namespace = "mesh-proxy-patch" + + BeforeAll(func() { + err := NewClusterSetup(). + Install(MeshKubernetes(meshName)). + Install(NamespaceWithSidecarInjection(namespace)). + Install(testserver.Install( + testserver.WithName("test-client"), + testserver.WithMesh(meshName), + testserver.WithNamespace(namespace), + )). + Install(testserver.Install( + testserver.WithName("test-server"), + testserver.WithMesh(meshName), + testserver.WithNamespace(namespace), + )). + Setup(env.Cluster) + Expect(err).ToNot(HaveOccurred()) + }) + E2EAfterAll(func() { + Expect(env.Cluster.TriggerDeleteNamespace(namespace)).To(Succeed()) + Expect(env.Cluster.DeleteMesh(meshName)).To(Succeed()) + }) + + It("should add a header using Lua filter", func() { + // given + meshProxyPatch := fmt.Sprintf(` +apiVersion: kuma.io/v1alpha1 +kind: MeshProxyPatch +metadata: + name: backend-lua-filter + namespace: %s + labels: + kuma.io/mesh: %s +spec: + targetRef: + kind: MeshService + name: test-client_mesh-proxy-patch_svc_80 + default: + appendModifications: + - httpFilter: + operation: AddBefore + match: + name: envoy.filters.http.router + origin: outbound + value: | + name: envoy.filters.http.lua + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + function envoy_on_request(request_handle) + request_handle:headers():add("X-Header", "test") + end +`, Config.KumaNamespace, meshName) + + // when + err := env.Cluster.Install(YamlK8s(meshProxyPatch)) + + // then + Expect(err).ToNot(HaveOccurred()) + Eventually(func(g Gomega) { + responses, err := client.CollectResponses(env.Cluster, "test-client", "test-server_mesh-proxy-patch_svc_80.mesh", client.FromKubernetesPod(namespace, "test-client")) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(responses[0].Received.Headers["X-Header"]).To(ContainElements("test")) + }, "30s", "1s").Should(Succeed()) + }) +} diff --git a/test/e2e_env/universal/meshproxypatch/meshproxypatch.go b/test/e2e_env/universal/meshproxypatch/meshproxypatch.go new file mode 100644 index 000000000000..e091f800b872 --- /dev/null +++ b/test/e2e_env/universal/meshproxypatch/meshproxypatch.go @@ -0,0 +1,74 @@ +package meshproxypatch + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/kumahq/kuma/test/e2e_env/universal/env" + . "github.com/kumahq/kuma/test/framework" + "github.com/kumahq/kuma/test/framework/client" +) + +func MeshProxyPatch() { + const mesh = "mesh-proxy-patch" + + BeforeAll(func() { + err := NewClusterSetup(). + Install(MeshUniversal(mesh)). + Install(TestServerUniversal("test-server", mesh, + WithTransparentProxy(true), + WithArgs([]string{"echo", "--instance", "echo-v1"}), + WithServiceName("test-server"), + )). + Install(DemoClientUniversal(AppModeDemoClient, mesh, WithTransparentProxy(true))). + Setup(env.Cluster) + Expect(err).ToNot(HaveOccurred()) + }) + E2EAfterAll(func() { + Expect(env.Cluster.DeleteMeshApps(mesh)).To(Succeed()) + Expect(env.Cluster.DeleteMesh(mesh)).To(Succeed()) + }) + + It("should add a header using Lua filter", func() { + // given + proxyTemplate := fmt.Sprintf(` +type: MeshProxyPatch +mesh: %s +name: backend-lua-filter +spec: + targetRef: + kind: MeshService + name: demo-client + default: + appendModifications: + - httpFilter: + operation: AddBefore + match: + name: envoy.filters.http.router + origin: outbound + listenerTags: + kuma.io/service: test-server + value: | + name: envoy.filters.http.lua + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + function envoy_on_request(request_handle) + request_handle:headers():add("X-Header", "test") + end +`, mesh) + + // when + err := env.Cluster.Install(YamlUniversal(proxyTemplate)) + + // then + Expect(err).ToNot(HaveOccurred()) + Eventually(func(g Gomega) { + responses, err := client.CollectResponses(env.Cluster, "demo-client", "test-server.mesh") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(responses[0].Received.Headers["X-Header"]).To(ContainElements("test")) + }, "30s", "1s").Should(Succeed()) + }) +} diff --git a/test/e2e_env/universal/universal_suite_test.go b/test/e2e_env/universal/universal_suite_test.go index b09eab9738ea..3a421d319b73 100644 --- a/test/e2e_env/universal/universal_suite_test.go +++ b/test/e2e_env/universal/universal_suite_test.go @@ -22,6 +22,7 @@ import ( "github.com/kumahq/kuma/test/e2e_env/universal/membership" "github.com/kumahq/kuma/test/e2e_env/universal/meshaccesslog" "github.com/kumahq/kuma/test/e2e_env/universal/meshhealthcheck" + "github.com/kumahq/kuma/test/e2e_env/universal/meshproxypatch" "github.com/kumahq/kuma/test/e2e_env/universal/meshratelimit" "github.com/kumahq/kuma/test/e2e_env/universal/meshtrafficpermission" "github.com/kumahq/kuma/test/e2e_env/universal/mtls" @@ -108,6 +109,7 @@ var _ = Describe("Timeout", timeout.Policy, Ordered) var _ = Describe("Retry", retry.Policy, Ordered) var _ = Describe("RateLimit", ratelimit.Policy, Ordered) var _ = Describe("ProxyTemplate", proxytemplate.ProxyTemplate, Ordered) +var _ = Describe("MeshProxyPatch", meshproxypatch.MeshProxyPatch, Ordered) var _ = Describe("Matching", matching.Matching, Ordered) var _ = Describe("Mtls", mtls.Policy, Ordered) var _ = Describe("Reachable Services", reachableservices.ReachableServices, Ordered)