diff --git a/api/v1alpha1/envoyextensionypolicy_types.go b/api/v1alpha1/envoyextensionypolicy_types.go index aae5f5aa00..5de6b62ce5 100644 --- a/api/v1alpha1/envoyextensionypolicy_types.go +++ b/api/v1alpha1/envoyextensionypolicy_types.go @@ -41,9 +41,22 @@ type EnvoyExtensionPolicy struct { // +kubebuilder:validation:XValidation:rule="has(self.targetRef) ? self.targetRef.kind in ['Gateway', 'ListenerSet', 'HTTPRoute', 'GRPCRoute', 'UDPRoute', 'TCPRoute', 'TLSRoute'] : true", message="this policy can only have a targetRef.kind of Gateway/ListenerSet/HTTPRoute/GRPCRoute/TCPRoute/UDPRoute/TLSRoute" // +kubebuilder:validation:XValidation:rule="has(self.targetRefs) ? self.targetRefs.all(ref, ref.group == 'gateway.networking.k8s.io') : true ", message="this policy can only have a targetRefs[*].group of gateway.networking.k8s.io" // +kubebuilder:validation:XValidation:rule="has(self.targetRefs) ? self.targetRefs.all(ref, ref.kind in ['Gateway', 'ListenerSet', 'HTTPRoute', 'GRPCRoute', 'UDPRoute', 'TCPRoute', 'TLSRoute']) : true ", message="this policy can only have a targetRefs[*].kind of Gateway/ListenerSet/HTTPRoute/GRPCRoute/TCPRoute/UDPRoute/TLSRoute" +// +kubebuilder:validation:XValidation:rule="!has(self.mergeType) || ((!has(self.targetRef) || self.targetRef.kind in ['HTTPRoute', 'GRPCRoute', 'UDPRoute', 'TCPRoute', 'TLSRoute']) && (!has(self.targetRefs) || self.targetRefs.all(ref, ref.kind in ['HTTPRoute', 'GRPCRoute', 'UDPRoute', 'TCPRoute', 'TLSRoute'])) && (!has(self.targetSelectors) || self.targetSelectors.all(sel, sel.kind in ['HTTPRoute', 'GRPCRoute', 'UDPRoute', 'TCPRoute', 'TLSRoute'])))", message="mergeType can only be used with xRoute targets" type EnvoyExtensionPolicySpec struct { PolicyTargetReferences `json:",inline"` + // MergeType determines how this configuration is merged with existing EnvoyExtensionPolicy + // configurations targeting a parent resource. When set, this configuration will be merged + // into the closest parent EnvoyExtensionPolicy in the route's attachment hierarchy (for + // example, one targeting a Gateway, Gateway listener, ListenerSet, or ListenerSet + // listener). + // Currently, this field can only be set when targeting xRoute resources. + // If unset, no merging occurs, and only the most specific configuration takes effect. + // + // +kubebuilder:validation:XValidation:rule="self != 'Replace'",message="Replace is not a valid MergeType for EnvoyExtensionPolicy" + // +optional + MergeType *MergeType `json:"mergeType,omitempty"` + // Wasm is a list of Wasm extensions to be loaded by the Gateway. // Order matters, as the extensions will be loaded in the order they are // defined in this list. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index b966f945df..c05f3c7b36 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -2344,6 +2344,11 @@ func (in *EnvoyExtensionPolicyList) DeepCopyObject() runtime.Object { func (in *EnvoyExtensionPolicySpec) DeepCopyInto(out *EnvoyExtensionPolicySpec) { *out = *in in.PolicyTargetReferences.DeepCopyInto(&out.PolicyTargetReferences) + if in.MergeType != nil { + in, out := &in.MergeType, &out.MergeType + *out = new(MergeType) + **out = **in + } if in.Wasm != nil { in, out := &in.Wasm, &out.Wasm *out = make([]Wasm, len(*in)) diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml index 4c2ab56c4f..4b3a2a2d13 100644 --- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml +++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml @@ -1796,6 +1796,19 @@ spec: || (self.type == 'ValueRef' && !has(self.inline) && has(self.valueRef)) maxItems: 16 type: array + mergeType: + description: |- + MergeType determines how this configuration is merged with existing EnvoyExtensionPolicy + configurations targeting a parent resource. When set, this configuration will be merged + into the closest parent EnvoyExtensionPolicy in the route's attachment hierarchy (for + example, one targeting a Gateway, Gateway listener, ListenerSet, or ListenerSet + listener). + Currently, this field can only be set when targeting xRoute resources. + If unset, no merging occurs, and only the most specific configuration takes effect. + type: string + x-kubernetes-validations: + - message: Replace is not a valid MergeType for EnvoyExtensionPolicy + rule: self != 'Replace' targetRef: description: |- TargetRef is the name of the resource this policy is being attached to. @@ -2358,6 +2371,13 @@ spec: rule: 'has(self.targetRefs) ? self.targetRefs.all(ref, ref.kind in [''Gateway'', ''ListenerSet'', ''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute'']) : true ' + - message: mergeType can only be used with xRoute targets + rule: '!has(self.mergeType) || ((!has(self.targetRef) || self.targetRef.kind + in [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute'']) + && (!has(self.targetRefs) || self.targetRefs.all(ref, ref.kind in + [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute''])) + && (!has(self.targetSelectors) || self.targetSelectors.all(sel, sel.kind + in [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute''])))' status: description: Status defines the current status of EnvoyExtensionPolicy. properties: diff --git a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml index 0c2a075728..5b804ef8b5 100644 --- a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml +++ b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml @@ -1795,6 +1795,19 @@ spec: || (self.type == 'ValueRef' && !has(self.inline) && has(self.valueRef)) maxItems: 16 type: array + mergeType: + description: |- + MergeType determines how this configuration is merged with existing EnvoyExtensionPolicy + configurations targeting a parent resource. When set, this configuration will be merged + into the closest parent EnvoyExtensionPolicy in the route's attachment hierarchy (for + example, one targeting a Gateway, Gateway listener, ListenerSet, or ListenerSet + listener). + Currently, this field can only be set when targeting xRoute resources. + If unset, no merging occurs, and only the most specific configuration takes effect. + type: string + x-kubernetes-validations: + - message: Replace is not a valid MergeType for EnvoyExtensionPolicy + rule: self != 'Replace' targetRef: description: |- TargetRef is the name of the resource this policy is being attached to. @@ -2357,6 +2370,13 @@ spec: rule: 'has(self.targetRefs) ? self.targetRefs.all(ref, ref.kind in [''Gateway'', ''ListenerSet'', ''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute'']) : true ' + - message: mergeType can only be used with xRoute targets + rule: '!has(self.mergeType) || ((!has(self.targetRef) || self.targetRef.kind + in [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute'']) + && (!has(self.targetRefs) || self.targetRefs.all(ref, ref.kind in + [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute''])) + && (!has(self.targetSelectors) || self.targetSelectors.all(sel, sel.kind + in [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute''])))' status: description: Status defines the current status of EnvoyExtensionPolicy. properties: diff --git a/internal/gatewayapi/envoyextensionpolicy.go b/internal/gatewayapi/envoyextensionpolicy.go index a06a2bdfb2..4fcd4c724a 100644 --- a/internal/gatewayapi/envoyextensionpolicy.go +++ b/internal/gatewayapi/envoyextensionpolicy.go @@ -21,6 +21,7 @@ import ( "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + gwapiv1b1 "sigs.k8s.io/gateway-api/apis/v1beta1" egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" "github.com/envoyproxy/gateway/internal/gatewayapi/luavalidator" @@ -85,6 +86,7 @@ func (t *Translator) ProcessEnvoyExtensionPolicies( // First build a map out of the routes and gateways for faster lookup since users might have thousands of routes or more. routeMapSize := len(routes) gatewayMapSize := len(gateways) + policyMapSize := len(envoyExtensionPolicies) listenerSetMapSize := len(resources.ListenerSets) routeMap := make(map[policyTargetRouteKey]*policyRouteTargetContext, routeMapSize) @@ -109,12 +111,22 @@ func (t *Translator) ProcessEnvoyExtensionPolicies( listenerSetMap[key] = &policyListenerSetTargetContext{ListenerSet: ls} } - handledPolicies := make(map[types.NamespacedName]*egv1a1.EnvoyExtensionPolicy) + handledPolicies := make(map[types.NamespacedName]*egv1a1.EnvoyExtensionPolicy, policyMapSize) + + // Map of attached policy to Gateway. Used for policy merge process. + gatewayPolicyMap := make(map[NamespacedNameWithSection]*egv1a1.EnvoyExtensionPolicy, gatewayMapSize) + + // Map of attached policy to ListenerSet. Used for policy merge process. + listenerSetPolicyMap := make(map[NamespacedNameWithSection]*egv1a1.EnvoyExtensionPolicy, listenerSetMapSize) // overrides records child scopes whose policies displace policies attached // to their parent scopes. overrides := newPolicyScopeGraph() + // merged records Route scopes whose policies were merged into policies + // attached to their parent scopes. + merged := newPolicyScopeGraph() + // Translate // 1. First translate Policies targeting RouteRules // 2. Next translate Policies targeting xRoutes @@ -123,6 +135,11 @@ func (t *Translator) ProcessEnvoyExtensionPolicies( // 5. Then translate Policies targeting Gateway Listeners // 6. Finally, the policies targeting Gateways + // Build gateway policy maps, which are needed when processing the policies targeting xRoutes. + t.buildGatewayEnvoyExtensionPolicyMap(envoyExtensionPolicies, gateways, gatewayMap, gatewayPolicyMap, resources.ReferenceGrants) + // Build ListenerSet policy maps, which are needed when processing the policies targeting xRoutes. + t.buildListenerSetEnvoyExtensionPolicyMap(envoyExtensionPolicies, listenerSetMap, listenerSetPolicyMap, resources) + // Process the policies targeting RouteRules for i, currPolicy := range envoyExtensionPolicies { policyName := utils.NamespacedName(currPolicy) @@ -138,7 +155,7 @@ func (t *Translator) ProcessEnvoyExtensionPolicies( } t.processEnvoyExtensionPolicyForRoute(resources, xdsIR, - routeMap, listenerSetMap, overrides, policy, currTarget) + routeMap, listenerSetMap, gatewayPolicyMap, listenerSetPolicyMap, overrides, merged, policy, currTarget) } } } @@ -164,7 +181,7 @@ func (t *Translator) ProcessEnvoyExtensionPolicies( } t.processEnvoyExtensionPolicyForRoute(resources, xdsIR, - routeMap, listenerSetMap, overrides, policy, currTarget) + routeMap, listenerSetMap, gatewayPolicyMap, listenerSetPolicyMap, overrides, merged, policy, currTarget) } } } @@ -188,7 +205,7 @@ func (t *Translator) ProcessEnvoyExtensionPolicies( } t.processEnvoyExtensionPolicyForListenerSet(resources, xdsIR, - gatewayMap, listenerSetMap, overrides, policy, currTarget) + gatewayMap, listenerSetMap, overrides, merged, policy, currTarget) } } } @@ -215,7 +232,7 @@ func (t *Translator) ProcessEnvoyExtensionPolicies( } t.processEnvoyExtensionPolicyForListenerSet(resources, xdsIR, - gatewayMap, listenerSetMap, overrides, policy, currTarget) + gatewayMap, listenerSetMap, overrides, merged, policy, currTarget) } } } @@ -236,7 +253,7 @@ func (t *Translator) ProcessEnvoyExtensionPolicies( } t.processEnvoyExtensionPolicyForGateway(resources, xdsIR, - gatewayMap, overrides, policy, currTarget) + gatewayMap, overrides, merged, policy, currTarget) } } } @@ -262,7 +279,7 @@ func (t *Translator) ProcessEnvoyExtensionPolicies( } t.processEnvoyExtensionPolicyForGateway(resources, xdsIR, - gatewayMap, overrides, policy, currTarget) + gatewayMap, overrides, merged, policy, currTarget) } } } @@ -276,12 +293,117 @@ func (t *Translator) ProcessEnvoyExtensionPolicies( return res } +func (t *Translator) buildGatewayEnvoyExtensionPolicyMap( + envoyExtensionPolicies []*egv1a1.EnvoyExtensionPolicy, + gateways []*GatewayContext, + gatewayMap map[types.NamespacedName]*policyGatewayTargetContext, + gatewayPolicyMap map[NamespacedNameWithSection]*egv1a1.EnvoyExtensionPolicy, + referenceGrants []*gwapiv1b1.ReferenceGrant, +) { + for _, currPolicy := range envoyExtensionPolicies { + targetRefs := resolvePolicyTargets( + currPolicy.Spec.PolicyTargetReferences, + gateways, + referenceGrants, + egv1a1.GroupName, + egv1a1.KindEnvoyExtensionPolicy, + currPolicy.Namespace, + t.GetNamespace) + for _, currTarget := range targetRefs { + if currTarget.Kind == resource.KindGateway { + // Check if the gateway exists + key := types.NamespacedName{ + Name: string(currTarget.Name), + Namespace: string(currTarget.Namespace), + } + gateway, ok := gatewayMap[key] + if !ok { + continue + } + + // Check if the specified listener exists when sectionName is set + if currTarget.SectionName != nil { + if err := validateGatewayListenerSectionName( + *currTarget.SectionName, + key, + gatewayDirectListeners(gateway.GatewayContext), + ); err != nil { + continue + } + } + + mapKey := NamespacedNameWithSection{ + NamespacedName: key, + SectionName: ptr.Deref(currTarget.SectionName, ""), + } + + // Only store the first policy for this Gateway/Listener - conflicts are handled elsewhere + if _, ok := gatewayPolicyMap[mapKey]; ok { + continue + } + gatewayPolicyMap[mapKey] = currPolicy + } + } + } +} + +func (t *Translator) buildListenerSetEnvoyExtensionPolicyMap( + envoyExtensionPolicies []*egv1a1.EnvoyExtensionPolicy, + listenerSetMap map[types.NamespacedName]*policyListenerSetTargetContext, + listenerSetPolicyMap map[NamespacedNameWithSection]*egv1a1.EnvoyExtensionPolicy, + resources *resource.Resources, +) { + for _, currPolicy := range envoyExtensionPolicies { + targetRefs := resolvePolicyTargets( + currPolicy.Spec.PolicyTargetReferences, + resources.ListenerSets, + resources.ReferenceGrants, + egv1a1.GroupName, + egv1a1.KindEnvoyExtensionPolicy, + currPolicy.Namespace, + t.GetNamespace) + for _, currTarget := range targetRefs { + if currTarget.Kind != resource.KindListenerSet { + continue + } + key := types.NamespacedName{ + Name: string(currTarget.Name), + Namespace: string(currTarget.Namespace), + } + ls, ok := listenerSetMap[key] + if !ok { + continue + } + if currTarget.SectionName != nil { + if err := validateListenerSetListenerSectionName( + *currTarget.SectionName, + key, + ls.Spec.Listeners, + ); err != nil { + continue + } + } + mapKey := NamespacedNameWithSection{ + NamespacedName: key, + SectionName: ptr.Deref(currTarget.SectionName, ""), + } + if _, ok := listenerSetPolicyMap[mapKey]; ok { + continue + } + listenerSetPolicyMap[mapKey] = currPolicy + } + } +} + func (t *Translator) processEnvoyExtensionPolicyForRoute( resources *resource.Resources, xdsIR resource.XdsIRMap, routeMap map[policyTargetRouteKey]*policyRouteTargetContext, listenerSetMap map[types.NamespacedName]*policyListenerSetTargetContext, + gatewayPolicyMap map[NamespacedNameWithSection]*egv1a1.EnvoyExtensionPolicy, + listenerSetPolicyMap map[NamespacedNameWithSection]*egv1a1.EnvoyExtensionPolicy, overrides policyScopeGraph, + merged policyScopeGraph, policy *egv1a1.EnvoyExtensionPolicy, currTarget policyTargetReferenceWithSectionName, ) { @@ -303,6 +425,7 @@ func (t *Translator) processEnvoyExtensionPolicyForRoute( // Find the parent resource that the route belongs to and record its // ancestor status and override relationship. parentRefs := GetManagedParentReferences(targetedRoute) + parentRefCtxs := make([]*RouteParentContext, 0, len(parentRefs)) routeNN := utils.NamespacedName(targetedRoute) routeAsChildScope := routeScope(routeNN) for _, p := range parentRefs { @@ -324,6 +447,12 @@ func (t *Translator) processEnvoyExtensionPolicyForRoute( // Do need a section name since the policy is targeting to a route ancestorRef := getAncestorRefForPolicy(parentNN, p.SectionName) ancestorRefs = append(ancestorRefs, &ancestorRef) + + // Only process parentRefs that were handled by this translator + // (skip those referencing Gateways with different GatewayClasses) + if parentRefCtx := targetedRoute.GetRouteParentContext(p); parentRefCtx != nil { + parentRefCtxs = append(parentRefCtxs, parentRefCtx) + } } else if *p.Kind == resource.KindListenerSet { // The Route attaches through a ListenerSet. Resolve the ListenerSet // so its parent Gateway can be registered as structural containment; @@ -349,6 +478,12 @@ func (t *Translator) processEnvoyExtensionPolicyForRoute( // ListenerSet itself. ancestorRef := getAncestorRefForListenerSetPolicy(parentNN, p.SectionName) ancestorRefs = append(ancestorRefs, &ancestorRef) + + // Only process parentRefs that were handled by this translator + // (skip those referencing Gateways with different GatewayClasses) + if parentRefCtx := targetedRoute.GetRouteParentContext(p); parentRefCtx != nil { + parentRefCtxs = append(parentRefCtxs, parentRefCtx) + } } } @@ -363,14 +498,121 @@ func (t *Translator) processEnvoyExtensionPolicyForRoute( return } - // Set conditions for translation error if it got any - if err := t.translateEnvoyExtensionPolicyForRoute(policy, targetedRoute, currTarget, xdsIR, resources); err != nil { - status.SetTranslationErrorForPolicyAncestors(&policy.Status, - ancestorRefs, - t.GatewayControllerName, - policy.Generation, - status.Error2ConditionMsg(err), - ) + // Check if merging is enabled + if policy.Spec.MergeType == nil { + // No merging - use existing translation logic + if err := t.translateEnvoyExtensionPolicyForRoute(policy, &envoyExtensionPolicyOwners{}, targetedRoute, currTarget, xdsIR, resources, nil); err != nil { + status.SetTranslationErrorForPolicyAncestors(&policy.Status, + ancestorRefs, + t.GatewayControllerName, + policy.Generation, + status.Error2ConditionMsg(err), + ) + } + } else { + // Merge with the closest policy in the Route's attachment hierarchy. + // Gateway listeners check the Gateway listener policy first, then the + // Gateway policy. ListenerSet listeners check the ListenerSet listener + // policy, then the ListenerSet policy, then the parent Gateway policy; + // they intentionally skip Gateway listener policies because those are + // sibling scopes. + for _, parentRefCtx := range parentRefCtxs { + for _, listener := range parentRefCtx.listeners { + gwNN := utils.NamespacedName(listener.gateway.Gateway) + + var ( + parentPolicy *egv1a1.EnvoyExtensionPolicy + parentScope policyScope + ancestorRef gwapiv1.ParentReference + ) + if listener.isFromListenerSet() { + lsNN := types.NamespacedName{ + Namespace: listener.listenerSet.Namespace, + Name: listener.listenerSet.Name, + } + ancestorRef = getAncestorRefForListenerSetPolicy(lsNN, &listener.Name) + + lsListenerKey := NamespacedNameWithSection{NamespacedName: lsNN, SectionName: listener.Name} + lsKey := NamespacedNameWithSection{NamespacedName: lsNN} + gwKey := NamespacedNameWithSection{NamespacedName: gwNN} + + if p, ok := listenerSetPolicyMap[lsListenerKey]; ok { + parentPolicy, parentScope = p, listenerSetListenerScope(lsNN, listener.Name) + } else if p, ok := listenerSetPolicyMap[lsKey]; ok { + parentPolicy, parentScope = p, listenerSetScope(lsNN) + } else if p, ok := gatewayPolicyMap[gwKey]; ok { + parentPolicy, parentScope = p, gatewayScope(gwNN) + } + } else { + ancestorRef = getAncestorRefForPolicy(gwNN, &listener.Name) + + listenerKey := NamespacedNameWithSection{NamespacedName: gwNN, SectionName: listener.Name} + gwKey := NamespacedNameWithSection{NamespacedName: gwNN} + + if p, ok := gatewayPolicyMap[listenerKey]; ok { + parentPolicy, parentScope = p, gatewayListenerScope(gwNN, listener.Name) + } else if p, ok := gatewayPolicyMap[gwKey]; ok { + parentPolicy, parentScope = p, gatewayScope(gwNN) + } + } + + if parentPolicy == nil { + // No parent policy found, fall back to current policy + if err := t.translateEnvoyExtensionPolicyForRoute(policy, &envoyExtensionPolicyOwners{}, targetedRoute, currTarget, xdsIR, resources, listener); err != nil { + status.SetConditionForPolicyAncestor(&policy.Status, + &ancestorRef, + t.GatewayControllerName, + gwapiv1.PolicyConditionAccepted, metav1.ConditionFalse, + egv1a1.PolicyReasonInvalid, + status.Error2ConditionMsg(err), + policy.Generation, + ) + } + continue + } + + // Merge with parent policy + mergedPolicy, owners, err := mergeEnvoyExtensionPolicy(policy, parentPolicy) + if err != nil { + status.SetConditionForPolicyAncestor(&policy.Status, + &ancestorRef, + t.GatewayControllerName, + gwapiv1.PolicyConditionAccepted, metav1.ConditionFalse, + egv1a1.PolicyReasonInvalid, + fmt.Sprintf("error merging policies: %v", err), + policy.Generation, + ) + continue + } + + // Apply merged policy + if err := t.translateEnvoyExtensionPolicyForRoute(mergedPolicy, owners, targetedRoute, currTarget, xdsIR, resources, listener); err != nil { + status.SetConditionForPolicyAncestor(&policy.Status, + &ancestorRef, + t.GatewayControllerName, + gwapiv1.PolicyConditionAccepted, metav1.ConditionFalse, + egv1a1.PolicyReasonInvalid, + status.Error2ConditionMsg(err), + policy.Generation, + ) + continue + } + + // Record the merged route under the parent scope so the parent's + // status can list the routes that were merged into it. + merged.Add(parentScope, routeAsChildScope) + + status.SetConditionForPolicyAncestor(&policy.Status, + &ancestorRef, + t.GatewayControllerName, + egv1a1.PolicyConditionMerged, + metav1.ConditionTrue, + egv1a1.PolicyReasonMerged, + fmt.Sprintf("Merged with policy %s/%s", parentPolicy.Namespace, parentPolicy.Name), + policy.Generation, + ) + } + } } // Set Accepted condition if it is unset @@ -381,6 +623,12 @@ func (t *Translator) processEnvoyExtensionPolicyForRoute( status.SetDeprecatedFieldsWarningForPolicyAncestors(&policy.Status, ancestorRefs, t.GatewayControllerName, policy.Generation, deprecatedFields) } + // Check if this policy is overridden by other policies targeting at route rule levels + // If policy target is route rule, we can skip the check + if currTarget.SectionName != nil { + return + } + // Check if this policy is overridden by other policies targeting at route rule levels key := policyTargetRouteKey{ Kind: string(currTarget.Kind), @@ -407,6 +655,7 @@ func (t *Translator) processEnvoyExtensionPolicyForListenerSet( gatewayMap map[types.NamespacedName]*policyGatewayTargetContext, listenerSetMap map[types.NamespacedName]*policyListenerSetTargetContext, overrides policyScopeGraph, + merged policyScopeGraph, policy *egv1a1.EnvoyExtensionPolicy, currTarget policyTargetReferenceWithSectionName, ) { @@ -481,7 +730,22 @@ func (t *Translator) processEnvoyExtensionPolicyForListenerSet( lsParentScope = listenerSetListenerScope(listenerSetNN, *currTarget.SectionName) } - overriddenMessage := formatPolicyScopes(overrides.GetWithDescendants(lsParentScope)) + mergedScopes := merged.GetDirectChildren(lsParentScope) + mergedMessage := formatPolicyScopes(mergedScopes) + // Merged routes are excluded from the override message so a route doesn't + // appear in both sections. + overriddenMessage := formatPolicyScopes(overrides.GetWithDescendants(lsParentScope).Difference(mergedScopes)) + if mergedMessage != "" { + status.SetConditionForPolicyAncestor(&policy.Status, + &ancestorRef, + t.GatewayControllerName, + egv1a1.PolicyConditionMerged, + metav1.ConditionTrue, + egv1a1.PolicyReasonMerged, + "This policy is being merged by other envoyExtensionPolicies for "+mergedMessage, + policy.Generation, + ) + } if overriddenMessage != "" { status.SetConditionForPolicyAncestor(&policy.Status, &ancestorRef, @@ -500,6 +764,7 @@ func (t *Translator) processEnvoyExtensionPolicyForGateway( xdsIR resource.XdsIRMap, gatewayMap map[types.NamespacedName]*policyGatewayTargetContext, overrides policyScopeGraph, + merged policyScopeGraph, policy *egv1a1.EnvoyExtensionPolicy, currTarget policyTargetReferenceWithSectionName, ) { @@ -564,15 +829,31 @@ func (t *Translator) processEnvoyExtensionPolicyForGateway( } else { parentScope = gatewayListenerScope(gatewayNN, *currTarget.SectionName) } - overriddenTargetsMessage := formatPolicyScopes(overrides.GetWithDescendants(parentScope)) - if overriddenTargetsMessage != "" { + + mergedScopes := merged.GetDirectChildren(parentScope) + mergedMessage := formatPolicyScopes(mergedScopes) + // Merged routes are excluded from the override message so a route doesn't + // appear in both sections. + overriddenMessage := formatPolicyScopes(overrides.GetWithDescendants(parentScope).Difference(mergedScopes)) + if mergedMessage != "" { + status.SetConditionForPolicyAncestor(&policy.Status, + &ancestorRef, + t.GatewayControllerName, + egv1a1.PolicyConditionMerged, + metav1.ConditionTrue, + egv1a1.PolicyReasonMerged, + "This policy is being merged by other envoyExtensionPolicies for "+mergedMessage, + policy.Generation, + ) + } + if overriddenMessage != "" { status.SetConditionForPolicyAncestor(&policy.Status, &ancestorRef, t.GatewayControllerName, egv1a1.PolicyConditionOverridden, metav1.ConditionTrue, egv1a1.PolicyReasonOverridden, - "This policy is being overridden by other envoyExtensionPolicies for "+overriddenTargetsMessage, + "This policy is being overridden by other envoyExtensionPolicies for "+overriddenMessage, policy.Generation, ) } @@ -758,10 +1039,12 @@ func resolveEnvoyExtensionPolicyRouteTargetRef( func (t *Translator) translateEnvoyExtensionPolicyForRoute( policy *egv1a1.EnvoyExtensionPolicy, + owners *envoyExtensionPolicyOwners, route RouteContext, target policyTargetReferenceWithSectionName, xdsIR resource.XdsIRMap, resources *resource.Resources, + targetListener *ListenerContext, ) error { var ( wasms []ir.Wasm @@ -771,7 +1054,7 @@ func (t *Translator) translateEnvoyExtensionPolicyForRoute( errs error ) - if wasms, wasmError, wasmFailOpen = t.buildWasms(policy, resources); wasmError != nil { + if wasms, wasmError, wasmFailOpen = t.buildWasms(policy, owners, resources); wasmError != nil { wasmError = perr.WithMessage(wasmError, "Wasm") errs = errors.Join(errs, wasmError) } @@ -780,6 +1063,16 @@ func (t *Translator) translateEnvoyExtensionPolicyForRoute( prefix := irRoutePrefix(route) parentRefs := GetParentReferences(route) routesWithDirectResponse := sets.New[string]() + + var targetListenerName string + var targetGatewayNN types.NamespacedName + if targetListener != nil { + targetListenerName = irListenerName(targetListener) + targetGatewayNN = types.NamespacedName{ + Namespace: targetListener.gateway.Namespace, + Name: targetListener.gateway.Name, + } + } for _, p := range parentRefs { // Skip if this parentRef was not processed by this translator // (e.g., references a Gateway with a different GatewayClass) @@ -792,25 +1085,40 @@ func (t *Translator) translateEnvoyExtensionPolicyForRoute( continue } - if luas, luaError = t.buildLuas(policy, gtwCtx.envoyProxy); luaError != nil { + // If targetListener is set, only apply within its parent Gateway. + if targetListener != nil { + gtwNN := types.NamespacedName{ + Namespace: gtwCtx.Namespace, + Name: gtwCtx.Name, + } + if gtwNN != targetGatewayNN { + continue + } + } + + if luas, luaError = t.buildLuas(policy, owners, gtwCtx.envoyProxy); luaError != nil { luaError = perr.WithMessage(luaError, "Lua") errs = errors.Join(errs, luaError) } var extProcs []ir.ExtProc - if extProcs, extProcError, extProcFailOpen = t.buildExtProcs(policy, resources, gtwCtx); extProcError != nil { + if extProcs, extProcError, extProcFailOpen = t.buildExtProcs(policy, owners, resources, gtwCtx); extProcError != nil { extProcError = perr.WithMessage(extProcError, "ExtProc") errs = errors.Join(errs, extProcError) } var dynamicModules []ir.DynamicModule - if dynamicModules, dynamicModuleError = t.buildDynamicModules(policy, gtwCtx.envoyProxy); dynamicModuleError != nil { + if dynamicModules, dynamicModuleError = t.buildDynamicModules(policy, owners, gtwCtx.envoyProxy); dynamicModuleError != nil { dynamicModuleError = perr.WithMessage(dynamicModuleError, "DynamicModule") errs = errors.Join(errs, dynamicModuleError) } irKey := t.getIRKey(gtwCtx.Gateway) for _, listener := range parentRefCtx.listeners { + // If targetListener is set, only apply to that exact listener. + if targetListener != nil && targetListenerName != irListenerName(listener) { + continue + } irListener := xdsIR[irKey].GetHTTPListener(irListenerName(listener)) if irListener != nil { for _, r := range irListener.Routes { @@ -921,19 +1229,20 @@ func (t *Translator) translateEnvoyExtensionPolicyForListeners( errs error ) - if extProcs, extProcError, extProcFailOpen = t.buildExtProcs(policy, resources, gateway); extProcError != nil { + noOwners := &envoyExtensionPolicyOwners{} + if extProcs, extProcError, extProcFailOpen = t.buildExtProcs(policy, noOwners, resources, gateway); extProcError != nil { extProcError = perr.WithMessage(extProcError, "ExtProc") errs = errors.Join(errs, extProcError) } - if wasms, wasmError, wasmFailOpen = t.buildWasms(policy, resources); wasmError != nil { + if wasms, wasmError, wasmFailOpen = t.buildWasms(policy, noOwners, resources); wasmError != nil { wasmError = perr.WithMessage(wasmError, "Wasm") errs = errors.Join(errs, wasmError) } - if luas, luaError = t.buildLuas(policy, gateway.envoyProxy); luaError != nil { + if luas, luaError = t.buildLuas(policy, noOwners, gateway.envoyProxy); luaError != nil { luaError = perr.WithMessage(luaError, "Lua") errs = errors.Join(errs, luaError) } - if dynamicModules, dynamicModuleError = t.buildDynamicModules(policy, gateway.envoyProxy); dynamicModuleError != nil { + if dynamicModules, dynamicModuleError = t.buildDynamicModules(policy, noOwners, gateway.envoyProxy); dynamicModuleError != nil { dynamicModuleError = perr.WithMessage(dynamicModuleError, "DynamicModule") errs = errors.Join(errs, dynamicModuleError) } @@ -1004,6 +1313,7 @@ func (t *Translator) translateEnvoyExtensionPolicyForListeners( func (t *Translator) buildLuas( policy *egv1a1.EnvoyExtensionPolicy, + owners *envoyExtensionPolicyOwners, envoyProxy *egv1a1.EnvoyProxy, ) ([]ir.Lua, error) { if policy == nil { @@ -1017,9 +1327,10 @@ func (t *Translator) buildLuas( luaIRList := make([]ir.Lua, 0, len(policy.Spec.Lua)) + ownerPolicy := policyOwnerOr(owners.lua, policy) for idx, ep := range policy.Spec.Lua { - name := irConfigNameForLua(policy, idx) - luaIR, err := t.buildLua(name, policy, ep, envoyProxy) + name := irConfigNameForLua(ownerPolicy, idx) + luaIR, err := t.buildLua(name, ownerPolicy, ep, envoyProxy) if err != nil { return nil, err } @@ -1081,7 +1392,12 @@ func (t *Translator) getLuaBodyFromLocalObjectReference( } } -func (t *Translator) buildExtProcs(policy *egv1a1.EnvoyExtensionPolicy, resources *resource.Resources, gtwCtx *GatewayContext) ([]ir.ExtProc, error, bool) { +func (t *Translator) buildExtProcs( + policy *egv1a1.EnvoyExtensionPolicy, + owners *envoyExtensionPolicyOwners, + resources *resource.Resources, + gtwCtx *GatewayContext, +) ([]ir.ExtProc, error, bool) { var ( failOpen bool errs error @@ -1094,9 +1410,10 @@ func (t *Translator) buildExtProcs(policy *egv1a1.EnvoyExtensionPolicy, resource extProcIRList := make([]ir.ExtProc, 0, len(policy.Spec.ExtProc)) hasFailClose := false + ownerPolicy := policyOwnerOr(owners.extProc, policy) for idx, ep := range policy.Spec.ExtProc { - name := irConfigNameForExtProc(policy, idx) - extProcIR, err := t.buildExtProc(name, policy, &ep, idx, resources, gtwCtx) + name := irConfigNameForExtProc(ownerPolicy, idx) + extProcIR, err := t.buildExtProc(name, ownerPolicy, &ep, idx, resources, gtwCtx) if err != nil { errs = errors.Join(errs, err) if ep.FailOpen == nil || !*ep.FailOpen { @@ -1233,6 +1550,7 @@ func irConfigNameForLua(policy *egv1a1.EnvoyExtensionPolicy, index int) string { func (t *Translator) buildWasms( policy *egv1a1.EnvoyExtensionPolicy, + owners *envoyExtensionPolicyOwners, resources *resource.Resources, ) ([]ir.Wasm, error, bool) { var ( @@ -1255,9 +1573,10 @@ func (t *Translator) buildWasms( } hasFailClose := false + ownerPolicy := policyOwnerOr(owners.wasm, policy) for idx, wasm := range policy.Spec.Wasm { - name := irConfigNameForWasm(policy, idx) - wasmIR, err := t.buildWasm(name, &wasm, policy, idx, resources) + name := irConfigNameForWasm(ownerPolicy, idx) + wasmIR, err := t.buildWasm(name, &wasm, ownerPolicy, idx, resources) if err != nil { errs = errors.Join(errs, err) if wasm.FailOpen == nil || !*wasm.FailOpen { @@ -1483,6 +1802,7 @@ func irConfigNameForDynamicModule(policy *egv1a1.EnvoyExtensionPolicy, index int func (t *Translator) buildDynamicModules( policy *egv1a1.EnvoyExtensionPolicy, + owners *envoyExtensionPolicyOwners, envoyProxy *egv1a1.EnvoyProxy, ) ([]ir.DynamicModule, error) { var errs error @@ -1501,9 +1821,9 @@ func (t *Translator) buildDynamicModules( } dmIRList := make([]ir.DynamicModule, 0, len(policy.Spec.DynamicModule)) - + ownerPolicy := policyOwnerOr(owners.dynamicModule, policy) for idx, dm := range policy.Spec.DynamicModule { - name := irConfigNameForDynamicModule(policy, idx) + name := irConfigNameForDynamicModule(ownerPolicy, idx) // Validate module exists in registry entry, ok := registry[dm.Name] @@ -1561,3 +1881,43 @@ func (t *Translator) buildDynamicModules( return dmIRList, errs } + +type envoyExtensionPolicyOwners struct { + wasm *egv1a1.EnvoyExtensionPolicy + extProc *egv1a1.EnvoyExtensionPolicy + lua *egv1a1.EnvoyExtensionPolicy + dynamicModule *egv1a1.EnvoyExtensionPolicy +} + +// mergeEnvoyExtensionPolicy merges a route-level EnvoyExtensionPolicy with a parent (Gateway/Listener) EnvoyExtensionPolicy. +func mergeEnvoyExtensionPolicy(routePolicy, parentPolicy *egv1a1.EnvoyExtensionPolicy) (*egv1a1.EnvoyExtensionPolicy, *envoyExtensionPolicyOwners, error) { + if routePolicy.Spec.MergeType == nil || parentPolicy == nil { + return routePolicy, nil, nil + } + mergedPolicy, err := utils.Merge(parentPolicy, routePolicy, *routePolicy.Spec.MergeType) + if err != nil { + return nil, nil, err + } + return mergedPolicy, buildEnvoyExtensionPolicyOwners(routePolicy, parentPolicy), nil +} + +// buildEnvoyExtensionPolicyOwners determines, for each merged field, which policy +// (route or parent) is considered the owner. The owner is used later to resolve +// references (e.g. Secrets, BackendRefs) scoped to the owning policy's namespace, +// and to derive IR resource names tied to the owning policy. +func buildEnvoyExtensionPolicyOwners(route, parent *egv1a1.EnvoyExtensionPolicy) *envoyExtensionPolicyOwners { + return &envoyExtensionPolicyOwners{ + wasm: ownerOf(route, parent, func(p *egv1a1.EnvoyExtensionPolicy) bool { + return len(p.Spec.Wasm) > 0 + }), + extProc: ownerOf(route, parent, func(p *egv1a1.EnvoyExtensionPolicy) bool { + return len(p.Spec.ExtProc) > 0 + }), + lua: ownerOf(route, parent, func(p *egv1a1.EnvoyExtensionPolicy) bool { + return len(p.Spec.Lua) > 0 + }), + dynamicModule: ownerOf(route, parent, func(p *egv1a1.EnvoyExtensionPolicy) bool { + return len(p.Spec.DynamicModule) > 0 + }), + } +} diff --git a/internal/gatewayapi/envoyextensionpolicy_test.go b/internal/gatewayapi/envoyextensionpolicy_test.go index 9a29f5a863..5a846f9de4 100644 --- a/internal/gatewayapi/envoyextensionpolicy_test.go +++ b/internal/gatewayapi/envoyextensionpolicy_test.go @@ -9,6 +9,11 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + + egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" ) func Test_hasTag(t *testing.T) { @@ -105,3 +110,108 @@ func TestValidateDynamicModuleRemoteURL(t *testing.T) { }) } } + +func Test_envoyExtensionPolicyOwnerChoose(t *testing.T) { + t.Run("route policy overrides parent for the same owner fields", func(t *testing.T) { + parentPolicy := &egv1a1.EnvoyExtensionPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "parent", Namespace: "parent-ns"}, + Spec: egv1a1.EnvoyExtensionPolicySpec{ + Wasm: []egv1a1.Wasm{{ + Name: new("parent-wasm"), + }}, + ExtProc: []egv1a1.ExtProc{{ + BackendCluster: egv1a1.BackendCluster{ + BackendRefs: []egv1a1.BackendRef{{ + BackendObjectReference: gwapiv1.BackendObjectReference{Name: "parent-extproc"}, + }}, + }, + }}, + Lua: []egv1a1.Lua{{ + Type: egv1a1.LuaValueTypeValueRef, + ValueRef: &gwapiv1.LocalObjectReference{Name: "parent-lua-cm"}, + }}, + DynamicModule: []egv1a1.DynamicModule{{ + Name: "parent-dm", + }}, + }, + } + + routePolicy := &egv1a1.EnvoyExtensionPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "route", Namespace: "route-ns"}, + Spec: egv1a1.EnvoyExtensionPolicySpec{ + MergeType: new(egv1a1.StrategicMerge), + Wasm: []egv1a1.Wasm{{ + Name: new("route-wasm"), + }}, + ExtProc: []egv1a1.ExtProc{{ + BackendCluster: egv1a1.BackendCluster{ + BackendRefs: []egv1a1.BackendRef{{ + BackendObjectReference: gwapiv1.BackendObjectReference{Name: "route-extproc"}, + }}, + }, + }}, + Lua: []egv1a1.Lua{{ + Type: egv1a1.LuaValueTypeValueRef, + ValueRef: &gwapiv1.LocalObjectReference{Name: "route-lua-cm"}, + }}, + DynamicModule: []egv1a1.DynamicModule{{ + Name: "route-dm", + }}, + }, + } + + _, owners, err := mergeEnvoyExtensionPolicy(routePolicy, parentPolicy) + require.NoError(t, err) + require.NotNil(t, owners) + + assert.Same(t, routePolicy, owners.wasm) + assert.Same(t, routePolicy, owners.extProc) + assert.Same(t, routePolicy, owners.lua) + assert.Same(t, routePolicy, owners.dynamicModule) + }) + + t.Run("uses parent owner when route does not set the field", func(t *testing.T) { + parentPolicy := &egv1a1.EnvoyExtensionPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "parent", Namespace: "parent-ns"}, + Spec: egv1a1.EnvoyExtensionPolicySpec{ + MergeType: new(egv1a1.StrategicMerge), + Wasm: []egv1a1.Wasm{{ + Name: new("parent-wasm"), + }}, + ExtProc: []egv1a1.ExtProc{{ + BackendCluster: egv1a1.BackendCluster{ + BackendRefs: []egv1a1.BackendRef{{ + BackendObjectReference: gwapiv1.BackendObjectReference{Name: "parent-extproc"}, + }}, + }, + }}, + Lua: []egv1a1.Lua{{ + Type: egv1a1.LuaValueTypeValueRef, + ValueRef: &gwapiv1.LocalObjectReference{Name: "parent-lua-cm"}, + }}, + DynamicModule: []egv1a1.DynamicModule{{ + Name: "parent-dm", + }}, + }, + } + + routePolicy := &egv1a1.EnvoyExtensionPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "route", Namespace: "route-ns"}, + Spec: egv1a1.EnvoyExtensionPolicySpec{ + MergeType: new(egv1a1.StrategicMerge), + Wasm: []egv1a1.Wasm{{ + Name: new("route-wasm"), + }}, + }, + } + + _, owners, err := mergeEnvoyExtensionPolicy(routePolicy, parentPolicy) + require.NoError(t, err) + require.NotNil(t, owners) + + assert.Same(t, routePolicy, owners.wasm) + assert.Same(t, parentPolicy, owners.extProc) + assert.Same(t, parentPolicy, owners.lua) + assert.Same(t, parentPolicy, owners.dynamicModule) + }) +} diff --git a/internal/gatewayapi/helpers.go b/internal/gatewayapi/helpers.go index 2f901fb9f4..8ddb39cc61 100644 --- a/internal/gatewayapi/helpers.go +++ b/internal/gatewayapi/helpers.go @@ -1449,6 +1449,29 @@ func getPolicyTargetRefs[T client.Object](policy egv1a1.PolicyTargetReferences, return ret } +// policyOwnerOr returns owner if non-nil, otherwise fallback. +// Used to resolve per-field owners from PolicyOwners: the owner is the policy +// that contributed the field (route overrides parent), falling back to the active policy +// when no merge occurred or the field was not set by either side. +func policyOwnerOr[T any](owner, fallback *T) *T { + if owner != nil { + return owner + } + return fallback +} + +// ownerOf returns route if routeOwns(route) is true, otherwise parent. +// Use this when ownership of a merged field is determined by a single predicate. +func ownerOf[T any]( + route, parent *T, + routeOwns func(*T) bool, +) *T { + if routeOwns(route) { + return route + } + return parent +} + // Sets *target to value if and only if *target is nil func setIfNil[T any](target **T, value *T) { if *target == nil { diff --git a/internal/gatewayapi/securitypolicy.go b/internal/gatewayapi/securitypolicy.go index 0e8d64a1a6..71434fcc86 100644 --- a/internal/gatewayapi/securitypolicy.go +++ b/internal/gatewayapi/securitypolicy.go @@ -3043,17 +3043,6 @@ type securityPolicyOwners struct { jwtProviders *egv1a1.SecurityPolicy } -// policyOwnerOr returns owner if non-nil, otherwise fallback. -// Used to resolve per-field owners from securityPolicyOwners: the owner is the policy -// that contributed the field (route overrides parent), falling back to the active policy -// when no merge occurred or the field was not set by either side. -func policyOwnerOr(owner, fallback *egv1a1.SecurityPolicy) *egv1a1.SecurityPolicy { - if owner != nil { - return owner - } - return fallback -} - // mergeSecurityPolicy merges a route-level SecurityPolicy with a parent (Gateway/Listener) SecurityPolicy. func mergeSecurityPolicy(routePolicy, parentPolicy *egv1a1.SecurityPolicy) (*egv1a1.SecurityPolicy, *securityPolicyOwners, error) { if routePolicy.Spec.MergeType == nil || parentPolicy == nil { @@ -3066,18 +3055,6 @@ func mergeSecurityPolicy(routePolicy, parentPolicy *egv1a1.SecurityPolicy) (*egv return mergedPolicy, buildSecurityPolicyOwners(routePolicy, parentPolicy), nil } -// ownerOf returns route if routeOwns(route) is true, otherwise parent. -// Use this when ownership of a merged field is determined by a single predicate. -func ownerOf( - route, parent *egv1a1.SecurityPolicy, - routeOwns func(*egv1a1.SecurityPolicy) bool, -) *egv1a1.SecurityPolicy { - if routeOwns(route) { - return route - } - return parent -} - // buildSecurityPolicyOwners determines, for each merged field, which policy // (route or parent) is considered the owner. The owner is used later to resolve // references (e.g. Secrets, BackendRefs) scoped to the owning policy's namespace, diff --git a/internal/gatewayapi/testdata/envoyextensionpolicy-with-merge-needs-fieldowner.in.yaml b/internal/gatewayapi/testdata/envoyextensionpolicy-with-merge-needs-fieldowner.in.yaml new file mode 100644 index 0000000000..4213bb5222 --- /dev/null +++ b/internal/gatewayapi/testdata/envoyextensionpolicy-with-merge-needs-fieldowner.in.yaml @@ -0,0 +1,151 @@ +configmaps: +- apiVersion: v1 + kind: ConfigMap + metadata: + name: cm-policy-for-gateway + namespace: envoy-gateway + data: + lua: | + function envoy_on_request(request_handle) + request_handle:logInfo('hello from parent') + end +- apiVersion: v1 + kind: ConfigMap + metadata: + name: cm-policy-for-http-route + namespace: default + data: + lua: | + function envoy_on_response(response_handle) + response_handle:logWarn('hello from route') + end +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/foo" + backendRefs: + - name: service-1 + port: 8080 +services: +- apiVersion: v1 + kind: Service + metadata: + namespace: envoy-gateway + name: grpc-backend + spec: + ports: + - port: 9000 + name: grpc + protocol: TCP +endpointSlices: +- apiVersion: discovery.k8s.io/v1 + kind: EndpointSlice + metadata: + namespace: envoy-gateway + name: endpointslice-grpc-backend + labels: + kubernetes.io/service-name: grpc-backend + addressType: IPv4 + ports: + - name: grpc + protocol: TCP + port: 9000 + endpoints: + - addresses: + - 8.8.8.8 + conditions: + ready: true +envoyextensionpolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + namespace: envoy-gateway + name: policy-for-gateway + spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + sectionName: http + lua: + - type: ValueRef + valueRef: + name: cm-policy-for-gateway + kind: ConfigMap + group: v1 + extProc: + - backendRefs: + - name: grpc-backend + port: 9000 + processingMode: + allowModeOverride: true + request: + body: Buffered + attributes: + - request.path + response: + body: Streamed + attributes: + - xds.route_metadata + - connection.requested_server_name + metadata: + accessibleNamespaces: + - envoy.filters.http.ext_authz + writableNamespaces: + - envoy.filters.http.my_custom + messageTimeout: 5s + failOpen: true +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + namespace: default + name: policy-for-route + spec: + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + lua: + - type: ValueRef + valueRef: + name: cm-policy-for-http-route + kind: ConfigMap + group: v1 + wasm: + - name: wasm-filter-1 + code: + type: HTTP + http: + url: https://www.example.com/wasm-filter-1.wasm + sha256: 2d89c4c6ab2a1c615c7696ed37ade9e50654ac70384b5d45100eb08e62130ff4 + env: + hostKeys: + - SOME_KEY + - ANOTHER_KEY diff --git a/internal/gatewayapi/testdata/envoyextensionpolicy-with-merge-needs-fieldowner.out.yaml b/internal/gatewayapi/testdata/envoyextensionpolicy-with-merge-needs-fieldowner.out.yaml new file mode 100644 index 0000000000..ec09c6d70a --- /dev/null +++ b/internal/gatewayapi/testdata/envoyextensionpolicy-with-merge-needs-fieldowner.out.yaml @@ -0,0 +1,343 @@ +envoyExtensionPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + name: policy-for-route + namespace: default + spec: + lua: + - type: ValueRef + valueRef: + group: v1 + kind: ConfigMap + name: cm-policy-for-http-route + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + wasm: + - code: + http: + sha256: 2d89c4c6ab2a1c615c7696ed37ade9e50654ac70384b5d45100eb08e62130ff4 + url: https://www.example.com/wasm-filter-1.wasm + type: HTTP + env: + hostKeys: + - SOME_KEY + - ANOTHER_KEY + name: wasm-filter-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + conditions: + - lastTransitionTime: null + message: Merged with policy envoy-gateway/policy-for-gateway + reason: Merged + status: "True" + type: Merged + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + name: policy-for-gateway + namespace: envoy-gateway + spec: + extProc: + - backendRefs: + - name: grpc-backend + port: 9000 + failOpen: true + messageTimeout: 5s + metadata: + accessibleNamespaces: + - envoy.filters.http.ext_authz + writableNamespaces: + - envoy.filters.http.my_custom + processingMode: + allowModeOverride: true + request: + attributes: + - request.path + body: Buffered + response: + attributes: + - xds.route_metadata + - connection.requested_server_name + body: Streamed + lua: + - type: ValueRef + valueRef: + group: v1 + kind: ConfigMap + name: cm-policy-for-gateway + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + sectionName: http + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: 'This policy is being merged by other envoyExtensionPolicies for + these routes: [default/httproute-1]' + reason: Merged + status: "True" + type: Merged + controllerName: gateway.envoyproxy.io/gatewayclass-controller +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + listeners: + - allowedRoutes: + namespaces: + from: All + name: http + port: 80 + protocol: HTTP + status: + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-1 + namespace: default + spec: + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - path: + value: /foo + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +infraIR: + envoy-gateway/gateway-1: + proxy: + listeners: + - name: envoy-gateway/gateway-1/http + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + ownerReference: + kind: GatewayClass + name: envoy-gateway-class + name: envoy-gateway/gateway-1 + namespace: envoy-gateway-system +xdsIR: + envoy-gateway/gateway-1: + accessLog: + json: + - path: /dev/stdout + globalResources: + envoyClientCertificate: + certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUREVENDQWZXZ0F3SUJBZ0lVRUZNaFA5ZUo5WEFCV3NRNVptNmJSazJjTE5Rd0RRWUpLb1pJaHZjTkFRRUwKQlFBd0ZqRVVNQklHQTFVRUF3d0xabTl2TG1KaGNpNWpiMjB3SGhjTk1qUXdNakk1TURrek1ERXdXaGNOTXpRdwpNakkyTURrek1ERXdXakFXTVJRd0VnWURWUVFEREF0bWIyOHVZbUZ5TG1OdmJUQ0NBU0l3RFFZSktvWklodmNOCkFRRUJCUUFEZ2dFUEFEQ0NBUW9DZ2dFQkFKbEk2WXhFOVprQ1BzNnBDUXhickNtZWl4OVA1RGZ4OVJ1NUxENFQKSm1kVzdJS2R0UVYvd2ZMbXRzdTc2QithVGRDaldlMEJUZmVPT1JCYlIzY1BBRzZFbFFMaWNsUVVydW4zcStncwpKcEsrSTdjSStqNXc4STY4WEg1V1E3clZVdGJ3SHBxYncrY1ZuQnFJVU9MaUlhdGpJZjdLWDUxTTF1RjljZkVICkU0RG5jSDZyYnI1OS9SRlpCc2toeHM1T3p3Sklmb2hreXZGd2V1VHd4Sy9WcGpJKzdPYzQ4QUJDWHBOTzlEL3EKRWgrck9hdWpBTWNYZ0hRSVRrQ2lpVVRjVW82TFNIOXZMWlB0YXFmem9acTZuaE1xcFc2NUUxcEF3RjNqeVRUeAphNUk4SmNmU0Zqa2llWjIwTFVRTW43TThVNHhIamFvL2d2SDBDQWZkQjdSTFUyc0NBd0VBQWFOVE1GRXdIUVlEClZSME9CQllFRk9SQ0U4dS8xRERXN2loWnA3Y3g5dFNtUG02T01COEdBMVVkSXdRWU1CYUFGT1JDRTh1LzFERFcKN2loWnA3Y3g5dFNtUG02T01BOEdBMVVkRXdFQi93UUZNQU1CQWY4d0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQgpBRnQ1M3pqc3FUYUg1YThFMmNodm1XQWdDcnhSSzhiVkxNeGl3TkdqYm1FUFJ6K3c2TngrazBBOEtFY0lEc0tjClNYY2k1OHU0b1didFZKQmx6YS9adWpIUjZQMUJuT3BsK2FveTc4NGJiZDRQMzl3VExvWGZNZmJCQ20xdmV2aDkKQUpLbncyWnRxcjRta2JMY3hFcWxxM3NCTEZBUzlzUUxuS05DZTJjR0xkVHAyYm9HK3FjZ3lRZ0NJTTZmOEVNdgpXUGlmQ01NR3V6Sy9HUkY0YlBPL1lGNDhld0R1M1VlaWgwWFhkVUFPRTlDdFVhOE5JaGMxVVBhT3pQcnRZVnFyClpPR2t2L0t1K0I3OGg4U0VzTzlYclFjdXdiT25KeDZLdFIrYWV5a3ZBcFhDUTNmWkMvYllLQUFSK1A4QUpvUVoKYndJVW1YaTRnajVtK2JLUGhlK2lyK0U9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0= + name: envoy-gateway-system/envoy + privateKey: '[redacted]' + proxyServiceCluster: + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + settings: + - addressType: IP + endpoints: + - host: 7.6.5.4 + port: 8080 + zone: zone1 + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + protocol: TCP + http: + - address: 0.0.0.0 + externalPort: 80 + hostnames: + - '*' + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + name: envoy-gateway/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + metadata: + kind: Service + name: service-1 + namespace: default + sectionName: "8080" + name: httproute/default/httproute-1/rule/0/backend/0 + protocol: HTTP + weight: 1 + envoyExtensions: + extProcs: + - allowModeOverride: true + authority: grpc-backend.envoy-gateway:9000 + destination: + metadata: + kind: EnvoyExtensionPolicy + name: policy-for-gateway + namespace: envoy-gateway + name: envoyextensionpolicy/envoy-gateway/policy-for-gateway/extproc/0 + settings: + - addressType: IP + endpoints: + - host: 8.8.8.8 + port: 9000 + metadata: + kind: Service + name: grpc-backend + namespace: envoy-gateway + sectionName: "9000" + name: envoyextensionpolicy/envoy-gateway/policy-for-gateway/extproc/0/backend/0 + protocol: GRPC + weight: 1 + failOpen: true + forwardingMetadataNamespaces: + - envoy.filters.http.ext_authz + messageTimeout: 5s + name: envoyextensionpolicy/envoy-gateway/policy-for-gateway/extproc/0 + receivingMetadataNamespaces: + - envoy.filters.http.my_custom + requestAttributes: + - request.path + requestBodyProcessingMode: Buffered + requestHeaderProcessing: true + responseAttributes: + - xds.route_metadata + - connection.requested_server_name + responseBodyProcessingMode: Streamed + responseHeaderProcessing: true + luas: + - Code: | + function envoy_on_response(response_handle) + response_handle:logWarn('hello from route') + end + FilterContext: null + Name: envoyextensionpolicy/default/policy-for-route/lua/0 + wasms: + - config: null + failOpen: false + hostKeys: + - SOME_KEY + - ANOTHER_KEY + httpWasmCode: + originalDownloadingURL: https://www.example.com/wasm-filter-1.wasm + servingURL: https://envoy-gateway.envoy-gateway-system.svc.cluster.local:18002/5c90b9a82642ce00a7753923fabead306b9d9a54a7c0bd2463a1af3efcfb110b.wasm + sha256: 2d89c4c6ab2a1c615c7696ed37ade9e50654ac70384b5d45100eb08e62130ff4 + name: envoyextensionpolicy/default/policy-for-route/wasm/0 + wasmName: wasm-filter-1 + hostname: '*' + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/* + pathMatch: + distinct: false + name: "" + prefix: /foo + readyListener: + address: 0.0.0.0 + ipFamily: IPv4 + path: /ready + port: 19003 diff --git a/internal/utils/merge_test.go b/internal/utils/merge_test.go index 2c91e27bcd..ad6f9af0e2 100644 --- a/internal/utils/merge_test.go +++ b/internal/utils/merge_test.go @@ -34,6 +34,8 @@ func TestMergePolicy(t *testing.T) { runMergePolicyTest[*egv1a1.BackendTrafficPolicy](t, caseFile) case "securitypolicy": runMergePolicyTest[*egv1a1.SecurityPolicy](t, caseFile) + case "envoyextensionpolicy": + runMergePolicyTest[*egv1a1.EnvoyExtensionPolicy](t, caseFile) default: t.Fatalf("unsupported policy type %q in %s", policyType, caseFile) } diff --git a/internal/utils/testdata/envoyextensionpolicy_all.in.yaml b/internal/utils/testdata/envoyextensionpolicy_all.in.yaml new file mode 100644 index 0000000000..dcdd1908e6 --- /dev/null +++ b/internal/utils/testdata/envoyextensionpolicy_all.in.yaml @@ -0,0 +1,33 @@ +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyExtensionPolicy +metadata: + name: original +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + wasm: + - name: wasm-parent + rootID: parent-root + code: + type: HTTP + http: + url: https://wasm.parent.example.com/filter.wasm + failOpen: false + extProc: + - backendRefs: + - name: ext-proc-parent + kind: Service + port: 9001 + messageTimeout: 5s + failOpen: false + lua: + - type: ValueRef + valueRef: + group: v1 + kind: ConfigMap + name: parent-lua + dynamicModule: + - name: parent-dm + filterName: parent-filter diff --git a/internal/utils/testdata/envoyextensionpolicy_all.jsonmerge.out.yaml b/internal/utils/testdata/envoyextensionpolicy_all.jsonmerge.out.yaml new file mode 100644 index 0000000000..97f2dc6d22 --- /dev/null +++ b/internal/utils/testdata/envoyextensionpolicy_all.jsonmerge.out.yaml @@ -0,0 +1,37 @@ +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyExtensionPolicy +metadata: + name: patched +spec: + dynamicModule: + - filterName: parent-filter + name: parent-dm + extProc: + - backendRefs: + - kind: Service + name: ext-proc-route + port: 9002 + failOpen: true + messageTimeout: 10s + lua: + - type: ValueRef + valueRef: + group: v1 + kind: ConfigMap + name: route-lua + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: route-1 + wasm: + - code: + http: + sha256: null + url: https://wasm.parent.example.com/filter.wasm + type: HTTP + failOpen: false + name: wasm-parent + rootID: parent-root +status: + ancestors: null diff --git a/internal/utils/testdata/envoyextensionpolicy_all.patch.yaml b/internal/utils/testdata/envoyextensionpolicy_all.patch.yaml new file mode 100644 index 0000000000..49350a565a --- /dev/null +++ b/internal/utils/testdata/envoyextensionpolicy_all.patch.yaml @@ -0,0 +1,23 @@ +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyExtensionPolicy +metadata: + name: patched +spec: + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: route-1 + extProc: + - backendRefs: + - name: ext-proc-route + kind: Service + port: 9002 + messageTimeout: 10s + failOpen: true + lua: + - type: ValueRef + valueRef: + group: v1 + kind: ConfigMap + name: route-lua diff --git a/internal/utils/testdata/envoyextensionpolicy_all.strategicmerge.out.yaml b/internal/utils/testdata/envoyextensionpolicy_all.strategicmerge.out.yaml new file mode 100644 index 0000000000..97f2dc6d22 --- /dev/null +++ b/internal/utils/testdata/envoyextensionpolicy_all.strategicmerge.out.yaml @@ -0,0 +1,37 @@ +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyExtensionPolicy +metadata: + name: patched +spec: + dynamicModule: + - filterName: parent-filter + name: parent-dm + extProc: + - backendRefs: + - kind: Service + name: ext-proc-route + port: 9002 + failOpen: true + messageTimeout: 10s + lua: + - type: ValueRef + valueRef: + group: v1 + kind: ConfigMap + name: route-lua + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: route-1 + wasm: + - code: + http: + sha256: null + url: https://wasm.parent.example.com/filter.wasm + type: HTTP + failOpen: false + name: wasm-parent + rootID: parent-root +status: + ancestors: null diff --git a/release-notes/current/new_features/9638-merge-support-envoyextensionpolicy.md b/release-notes/current/new_features/9638-merge-support-envoyextensionpolicy.md new file mode 100644 index 0000000000..8c283b4faf --- /dev/null +++ b/release-notes/current/new_features/9638-merge-support-envoyextensionpolicy.md @@ -0,0 +1 @@ +Added support for `MergeType` in `EnvoyExtensionPolicy` to enable route-level policies to merge with parent Gateway, ListenerSet policies. diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index 12e60a35cc..066eebc812 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -1621,6 +1621,7 @@ _Appears in:_ | `targetRef` | _[LocalPolicyTargetReferenceWithSectionName](#localpolicytargetreferencewithsectionname)_ | true | | TargetRef is the name of the resource this policy is being attached to.
This policy and the TargetRef MUST be in the same namespace for this
Policy to have effect
Deprecated: use targetRefs/targetSelectors instead | | `targetRefs` | _LocalPolicyTargetReferenceWithSectionName array_ | true | | TargetRefs are the names of the Gateway resources this policy
is being attached to. | | `targetSelectors` | _[TargetSelector](#targetselector) array_ | true | | TargetSelectors allow targeting resources for this policy based on labels | +| `mergeType` | _[MergeType](#mergetype)_ | false | | MergeType determines how this configuration is merged with existing EnvoyExtensionPolicy
configurations targeting a parent resource. When set, this configuration will be merged
into the closest parent EnvoyExtensionPolicy in the route's attachment hierarchy (for
example, one targeting a Gateway, Gateway listener, ListenerSet, or ListenerSet
listener).
Currently, this field can only be set when targeting xRoute resources.
If unset, no merging occurs, and only the most specific configuration takes effect. | | `wasm` | _[Wasm](#wasm) array_ | false | | Wasm is a list of Wasm extensions to be loaded by the Gateway.
Order matters, as the extensions will be loaded in the order they are
defined in this list. | | `extProc` | _[ExtProc](#extproc) array_ | false | | ExtProc is an ordered list of external processing filters
that should be added to the envoy filter chain | | `lua` | _[Lua](#lua) array_ | false | | Lua is an ordered list of Lua filters
that should be added to the envoy filter chain | @@ -4297,6 +4298,7 @@ MergeType defines the type of merge operation _Appears in:_ - [BackendTrafficPolicySpec](#backendtrafficpolicyspec) +- [EnvoyExtensionPolicySpec](#envoyextensionpolicyspec) - [EnvoyProxySpec](#envoyproxyspec) - [KubernetesPatchSpec](#kubernetespatchspec) - [SecurityPolicySpec](#securitypolicyspec) diff --git a/test/cel-validation/envoyextensionpolicy_test.go b/test/cel-validation/envoyextensionpolicy_test.go index dfa6598ce9..858892ea42 100644 --- a/test/cel-validation/envoyextensionpolicy_test.go +++ b/test/cel-validation/envoyextensionpolicy_test.go @@ -109,6 +109,96 @@ func TestEnvoyExtensionPolicyTarget(t *testing.T) { }, wantErrors: []string{}, }, + { + desc: "valid mergeType with xRoute targetRef", + mutate: func(eep *egv1a1.EnvoyExtensionPolicy) { + eep.Spec = egv1a1.EnvoyExtensionPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("HTTPRoute"), + Name: gwapiv1.ObjectName("backend"), + }, + }, + }, + MergeType: new(egv1a1.StrategicMerge), + } + }, + wantErrors: []string{}, + }, + { + desc: "valid mergeType with xRoute targetSelector", + mutate: func(eep *egv1a1.EnvoyExtensionPolicy) { + eep.Spec = egv1a1.EnvoyExtensionPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetSelectors: []egv1a1.TargetSelector{ + { + Kind: gwapiv1.Kind("HTTPRoute"), + MatchLabels: map[string]string{"app": "foo"}, + }, + }, + }, + MergeType: new(egv1a1.StrategicMerge), + } + }, + wantErrors: []string{}, + }, + { + desc: "mergeType rejected on ListenerSet targetRef", + mutate: func(eep *egv1a1.EnvoyExtensionPolicy) { + eep.Spec = egv1a1.EnvoyExtensionPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("ListenerSet"), + Name: gwapiv1.ObjectName("listener-set"), + }, + }, + }, + MergeType: new(egv1a1.StrategicMerge), + } + }, + wantErrors: []string{"mergeType can only be used with xRoute targets"}, + }, + { + desc: "mergeType rejected on ListenerSet targetRefs", + mutate: func(eep *egv1a1.EnvoyExtensionPolicy) { + eep.Spec = egv1a1.EnvoyExtensionPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRefs: []gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + { + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("ListenerSet"), + Name: gwapiv1.ObjectName("listener-set"), + }, + }, + }, + }, + MergeType: new(egv1a1.StrategicMerge), + } + }, + wantErrors: []string{"mergeType can only be used with xRoute targets"}, + }, + { + desc: "mergeType rejected on ListenerSet targetSelector", + mutate: func(eep *egv1a1.EnvoyExtensionPolicy) { + eep.Spec = egv1a1.EnvoyExtensionPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetSelectors: []egv1a1.TargetSelector{ + { + Kind: gwapiv1.Kind("ListenerSet"), + MatchLabels: map[string]string{"app": "foo"}, + }, + }, + }, + MergeType: new(egv1a1.StrategicMerge), + } + }, + wantErrors: []string{"mergeType can only be used with xRoute targets"}, + }, { desc: "both targetRef and targetRefs", mutate: func(eep *egv1a1.EnvoyExtensionPolicy) { diff --git a/test/e2e/testdata/envoyextensionpolicy-merged.yaml b/test/e2e/testdata/envoyextensionpolicy-merged.yaml new file mode 100644 index 0000000000..803810150d --- /dev/null +++ b/test/e2e/testdata/envoyextensionpolicy-merged.yaml @@ -0,0 +1,178 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: eep-merged-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + sectionName: http + hostnames: ["www.example.com"] + rules: + - matches: + - path: + type: PathPrefix + value: /merged + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: eep-merged-parent-lua + namespace: gateway-conformance-infra +data: + lua: | + function envoy_on_response(response_handle) + response_handle:headers():add("X-Custom-Lua-Header", "merged-parent") + end +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyExtensionPolicy +metadata: + name: eep-merged-gateway + namespace: gateway-conformance-infra +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: same-namespace + lua: + - type: ValueRef + valueRef: + name: eep-merged-parent-lua + kind: ConfigMap + group: v1 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: BackendTLSPolicy +metadata: + name: eep-merged-grpc-ext-proc-btls + namespace: gateway-conformance-infra +spec: + targetRefs: + - group: '' + kind: Service + name: grpc-ext-proc + sectionName: grpc + validation: + caCertificateRefs: + - name: grpc-ext-proc-ca + group: '' + kind: ConfigMap + hostname: grpc-ext-proc.envoygateway +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyExtensionPolicy +metadata: + name: eep-merged-route + namespace: gateway-conformance-infra +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: eep-merged-route + mergeType: StrategicMerge + extProc: + - backendRefs: + - name: grpc-ext-proc + namespace: gateway-conformance-infra + port: 9002 + processingMode: + request: {} + response: {} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: eep-merge-policy-gateway + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + allowedListeners: + namespaces: + from: Same + listeners: + - name: core + port: 18197 + protocol: HTTP + allowedRoutes: + namespaces: + from: Same +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: ListenerSet +metadata: + name: eep-merge-policy-ls + namespace: gateway-conformance-infra +spec: + parentRef: + name: eep-merge-policy-gateway + namespace: gateway-conformance-infra + listeners: + - name: merge-http + port: 18198 + protocol: HTTP + allowedRoutes: + namespaces: + from: Same +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: eep-merge-policy-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: ListenerSet + namespace: gateway-conformance-infra + name: eep-merge-policy-ls + sectionName: merge-http + rules: + - matches: + - path: + type: PathPrefix + value: /ls-merged + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyExtensionPolicy +metadata: + name: eep-merge-listenerset + namespace: gateway-conformance-infra +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: ListenerSet + name: eep-merge-policy-ls + lua: + - type: Inline + inline: | + function envoy_on_response(response_handle) + response_handle:headers():add("X-ListenerSet-Merged", "true") + end +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyExtensionPolicy +metadata: + name: eep-merge-route + namespace: gateway-conformance-infra +spec: + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: eep-merge-policy-route + extProc: + - backendRefs: + - name: grpc-ext-proc + namespace: gateway-conformance-infra + port: 9002 + processingMode: + request: {} + response: {} diff --git a/test/e2e/tests/envoyextensionpolicy_merge.go b/test/e2e/tests/envoyextensionpolicy_merge.go new file mode 100644 index 0000000000..82604b68ed --- /dev/null +++ b/test/e2e/tests/envoyextensionpolicy_merge.go @@ -0,0 +1,166 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +//go:build e2e + +package tests + +import ( + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + httputils "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + + "github.com/envoyproxy/gateway/internal/gatewayapi" + "github.com/envoyproxy/gateway/internal/gatewayapi/resource" +) + +func init() { + ConformanceTests = append(ConformanceTests, EnvoyExtensionPolicyMergedTest) +} + +var EnvoyExtensionPolicyMergedTest = suite.ConformanceTest{ + ShortName: "EnvoyExtensionPolicyMerged", + Description: "Test route-level EnvoyExtensionPolicy merge with Gateway and ListenerSet policies", + Manifests: []string{"testdata/ext-proc-service.yaml", "testdata/envoyextensionpolicy-merged.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + t.Run("EnvoyExtensionPolicyMerged", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "eep-merged-route", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted( + t, + suite.Client, + suite.TimeoutConfig, + suite.ControllerName, + kubernetes.NewGatewayRef(gwNN), + routeNN, + ) + + ancestorRef := gwapiv1.ParentReference{ + Group: gatewayapi.GroupPtr(gwapiv1.GroupName), + Kind: gatewayapi.KindPtr(resource.KindGateway), + Namespace: gatewayapi.NamespacePtr(gwNN.Namespace), + Name: gwapiv1.ObjectName(gwNN.Name), + SectionName: new(gwapiv1.SectionName("http")), + } + + EnvoyExtensionPolicyMustBeAccepted(t, + suite.Client, + types.NamespacedName{Name: "eep-merged-route", Namespace: ns}, + suite.ControllerName, + ancestorRef, + ) + + EnvoyExtensionPolicyMustBeMerged(t, + suite.Client, + types.NamespacedName{Name: "eep-merged-route", Namespace: ns}, + suite.ControllerName, + ancestorRef, + ) + + podReady := corev1.PodCondition{Type: corev1.PodReady, Status: corev1.ConditionTrue} + WaitForPods(t, suite.Client, ns, map[string]string{"app": "grpc-ext-proc"}, corev1.PodRunning, &podReady) + + expectedResponse := httputils.ExpectedResponse{ + Request: httputils.Request{ + Host: "www.example.com", + Path: "/merged", + Headers: map[string]string{ + "x-request-client-header": "original", + }, + }, + ExpectedRequest: &httputils.ExpectedRequest{ + Request: httputils.Request{ + Path: "/merged", + Headers: map[string]string{ + "x-request-ext-processed": "true", + "x-request-client-header-received": "original", + "x-request-client-header": "mutated", + }, + }, + }, + Response: httputils.Response{ + StatusCodes: []int{200}, + Headers: map[string]string{ + "X-Custom-Lua-Header": "merged-parent", + "x-response-ext-processed": "true", + }, + }, + Namespace: ns, + } + + httputils.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expectedResponse) + }) + + t.Run("route policy merges with listenerset policy", func(t *testing.T) { + ns := "gateway-conformance-infra" + gwNN := types.NamespacedName{Name: "eep-merge-policy-gateway", Namespace: ns} + lsNN := types.NamespacedName{Name: "eep-merge-policy-ls", Namespace: ns} + routeNN := types.NamespacedName{Name: "eep-merge-policy-route", Namespace: ns} + + gwAddr, err := kubernetes.WaitForGatewayAddress(t, suite.Client, suite.TimeoutConfig, kubernetes.NewGatewayRef(gwNN)) + require.NoError(t, err) + + kubernetes.RouteMustHaveParents( + t, + suite.Client, + suite.TimeoutConfig, + routeNN, + []gwapiv1.RouteParentStatus{ + createListenerSetParent(suite.ControllerName, lsNN.Name, "merge-http"), + }, + false, + &gwapiv1.HTTPRoute{}, + ) + + lsAncestorRef := listenerSetPolicyAncestor(ns, lsNN.Name, "") + routeAncestorRef := listenerSetPolicyAncestor(ns, lsNN.Name, "merge-http") + EnvoyExtensionPolicyMustBeAccepted(t, suite.Client, + types.NamespacedName{Name: "eep-merge-listenerset", Namespace: ns}, + suite.ControllerName, lsAncestorRef) + EnvoyExtensionPolicyMustBeMerged(t, suite.Client, + types.NamespacedName{Name: "eep-merge-listenerset", Namespace: ns}, + suite.ControllerName, lsAncestorRef) + EnvoyExtensionPolicyMustBeMerged(t, suite.Client, + types.NamespacedName{Name: "eep-merge-route", Namespace: ns}, + suite.ControllerName, routeAncestorRef) + + expectedResponse := httputils.ExpectedResponse{ + Request: httputils.Request{ + Path: "/ls-merged", + Headers: map[string]string{ + "x-request-client-header": "original", + }, + }, + ExpectedRequest: &httputils.ExpectedRequest{ + Request: httputils.Request{ + Path: "/ls-merged", + Headers: map[string]string{ + "x-request-ext-processed": "true", + "x-request-client-header-received": "original", + "x-request-client-header": "mutated", + }, + }, + }, + Response: httputils.Response{ + StatusCodes: []int{200}, + Headers: map[string]string{ + "X-ListenerSet-Merged": "true", + "x-response-ext-processed": "true", + }, + }, + Namespace: ns, + } + httputils.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, getListenerAddr(gwAddr, "18198"), expectedResponse) + }) + }, +} diff --git a/test/e2e/tests/utils.go b/test/e2e/tests/utils.go index ac6e43e8e4..bac34d594b 100644 --- a/test/e2e/tests/utils.go +++ b/test/e2e/tests/utils.go @@ -583,6 +583,29 @@ func EnvoyExtensionPolicyMustBeAccepted(t *testing.T, client client.Client, poli require.NoErrorf(t, waitErr, "error waiting for EnvoyExtensionPolicy to be accepted") } +// EnvoyExtensionPolicyMustBeMerged waits for the specified EnvoyExtensionPolicy to have Merged condition. +func EnvoyExtensionPolicyMustBeMerged(t *testing.T, client client.Client, policyName types.NamespacedName, controllerName string, ancestorRef gwapiv1.ParentReference) { + t.Helper() + + waitErr := wait.PollUntilContextTimeout(context.Background(), 1*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) { + policy := &egv1a1.EnvoyExtensionPolicy{} + err := client.Get(ctx, policyName, policy) + if err != nil { + return false, fmt.Errorf("error fetching EnvoyExtensionPolicy: %w", err) + } + + if policyMergedByAncestor(policy.Status.Ancestors, controllerName, ancestorRef) { + tlog.Logf(t, "EnvoyExtensionPolicy has Merged condition: %+v", policy) + return true, nil + } + + tlog.Logf(t, "EnvoyExtensionPolicy does not have Merged condition yet: %+v", policy) + return false, nil + }) + + require.NoErrorf(t, waitErr, "error waiting for EnvoyExtensionPolicy to have Merged condition") +} + // BackendMustBeAccepted waits for the specified Backend to be accepted. func BackendMustBeAccepted(t *testing.T, client client.Client, backendName types.NamespacedName) { t.Helper() diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml index 62f7f1e130..ee57f9edae 100644 --- a/test/helm/gateway-crds-helm/all.out.yaml +++ b/test/helm/gateway-crds-helm/all.out.yaml @@ -32418,6 +32418,19 @@ spec: || (self.type == 'ValueRef' && !has(self.inline) && has(self.valueRef)) maxItems: 16 type: array + mergeType: + description: |- + MergeType determines how this configuration is merged with existing EnvoyExtensionPolicy + configurations targeting a parent resource. When set, this configuration will be merged + into the closest parent EnvoyExtensionPolicy in the route's attachment hierarchy (for + example, one targeting a Gateway, Gateway listener, ListenerSet, or ListenerSet + listener). + Currently, this field can only be set when targeting xRoute resources. + If unset, no merging occurs, and only the most specific configuration takes effect. + type: string + x-kubernetes-validations: + - message: Replace is not a valid MergeType for EnvoyExtensionPolicy + rule: self != 'Replace' targetRef: description: |- TargetRef is the name of the resource this policy is being attached to. @@ -32980,6 +32993,13 @@ spec: rule: 'has(self.targetRefs) ? self.targetRefs.all(ref, ref.kind in [''Gateway'', ''ListenerSet'', ''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute'']) : true ' + - message: mergeType can only be used with xRoute targets + rule: '!has(self.mergeType) || ((!has(self.targetRef) || self.targetRef.kind + in [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute'']) + && (!has(self.targetRefs) || self.targetRefs.all(ref, ref.kind in + [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute''])) + && (!has(self.targetSelectors) || self.targetSelectors.all(sel, sel.kind + in [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute''])))' status: description: Status defines the current status of EnvoyExtensionPolicy. properties: diff --git a/test/helm/gateway-crds-helm/e2e.out.yaml b/test/helm/gateway-crds-helm/e2e.out.yaml index 0120595e5c..4a5ff77e69 100644 --- a/test/helm/gateway-crds-helm/e2e.out.yaml +++ b/test/helm/gateway-crds-helm/e2e.out.yaml @@ -8356,6 +8356,19 @@ spec: || (self.type == 'ValueRef' && !has(self.inline) && has(self.valueRef)) maxItems: 16 type: array + mergeType: + description: |- + MergeType determines how this configuration is merged with existing EnvoyExtensionPolicy + configurations targeting a parent resource. When set, this configuration will be merged + into the closest parent EnvoyExtensionPolicy in the route's attachment hierarchy (for + example, one targeting a Gateway, Gateway listener, ListenerSet, or ListenerSet + listener). + Currently, this field can only be set when targeting xRoute resources. + If unset, no merging occurs, and only the most specific configuration takes effect. + type: string + x-kubernetes-validations: + - message: Replace is not a valid MergeType for EnvoyExtensionPolicy + rule: self != 'Replace' targetRef: description: |- TargetRef is the name of the resource this policy is being attached to. @@ -8918,6 +8931,13 @@ spec: rule: 'has(self.targetRefs) ? self.targetRefs.all(ref, ref.kind in [''Gateway'', ''ListenerSet'', ''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute'']) : true ' + - message: mergeType can only be used with xRoute targets + rule: '!has(self.mergeType) || ((!has(self.targetRef) || self.targetRef.kind + in [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute'']) + && (!has(self.targetRefs) || self.targetRefs.all(ref, ref.kind in + [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute''])) + && (!has(self.targetSelectors) || self.targetSelectors.all(sel, sel.kind + in [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute''])))' status: description: Status defines the current status of EnvoyExtensionPolicy. properties: diff --git a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml index babc7aa51c..22225191e1 100644 --- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml +++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml @@ -8356,6 +8356,19 @@ spec: || (self.type == 'ValueRef' && !has(self.inline) && has(self.valueRef)) maxItems: 16 type: array + mergeType: + description: |- + MergeType determines how this configuration is merged with existing EnvoyExtensionPolicy + configurations targeting a parent resource. When set, this configuration will be merged + into the closest parent EnvoyExtensionPolicy in the route's attachment hierarchy (for + example, one targeting a Gateway, Gateway listener, ListenerSet, or ListenerSet + listener). + Currently, this field can only be set when targeting xRoute resources. + If unset, no merging occurs, and only the most specific configuration takes effect. + type: string + x-kubernetes-validations: + - message: Replace is not a valid MergeType for EnvoyExtensionPolicy + rule: self != 'Replace' targetRef: description: |- TargetRef is the name of the resource this policy is being attached to. @@ -8918,6 +8931,13 @@ spec: rule: 'has(self.targetRefs) ? self.targetRefs.all(ref, ref.kind in [''Gateway'', ''ListenerSet'', ''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute'']) : true ' + - message: mergeType can only be used with xRoute targets + rule: '!has(self.mergeType) || ((!has(self.targetRef) || self.targetRef.kind + in [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute'']) + && (!has(self.targetRefs) || self.targetRefs.all(ref, ref.kind in + [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute''])) + && (!has(self.targetSelectors) || self.targetSelectors.all(sel, sel.kind + in [''HTTPRoute'', ''GRPCRoute'', ''UDPRoute'', ''TCPRoute'', ''TLSRoute''])))' status: description: Status defines the current status of EnvoyExtensionPolicy. properties: