This is an automated email from the ASF dual-hosted git repository.
AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git
The following commit(s) were added to refs/heads/master by this push:
new 11635a1e feat: add L4RoutePolicy for attaching stream plugins to L4
routes (#2791)
11635a1e is described below
commit 11635a1e1fc76366984713177a92980d605e53f8
Author: AlinsRan <[email protected]>
AuthorDate: Tue Jun 16 10:44:02 2026 +0800
feat: add L4RoutePolicy for attaching stream plugins to L4 routes (#2791)
---
api/v1alpha1/l4routepolicy_types.go | 69 ++++
api/v1alpha1/zz_generated.deepcopy.go | 88 +++++
.../bases/apisix.apache.org_l4routepolicies.yaml | 429 +++++++++++++++++++++
config/crd/kustomization.yaml | 1 +
config/rbac/role.yaml | 2 +
docs/en/latest/reference/api-reference.md | 36 ++
internal/adc/translator/l4route_test.go | 269 +++++++++++++
internal/adc/translator/l4routepolicy_test.go | 143 +++++++
internal/adc/translator/policies.go | 57 +++
internal/adc/translator/tcproute.go | 5 +
internal/adc/translator/tlsroute.go | 6 +
internal/adc/translator/udproute.go | 5 +
internal/controller/indexer/indexer.go | 27 ++
internal/controller/policies.go | 193 +++++++++
internal/controller/status/updater.go | 6 +
internal/controller/tcproute_controller.go | 44 +++
internal/controller/tlsroute_controller.go | 44 +++
internal/controller/udproute_controller.go | 44 +++
internal/manager/controllers.go | 2 +
internal/provider/provider.go | 2 +
internal/types/k8s.go | 9 +
test/e2e/framework/assertion.go | 32 ++
test/e2e/framework/manifests/ingress.yaml | 2 +
test/e2e/gatewayapi/tcproute.go | 94 +++++
test/e2e/scaffold/k8s.go | 16 +
25 files changed, 1625 insertions(+)
diff --git a/api/v1alpha1/l4routepolicy_types.go
b/api/v1alpha1/l4routepolicy_types.go
new file mode 100644
index 00000000..5bc81e66
--- /dev/null
+++ b/api/v1alpha1/l4routepolicy_types.go
@@ -0,0 +1,69 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package v1alpha1
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
+)
+
+// L4RoutePolicySpec defines the desired state of L4RoutePolicy.
+type L4RoutePolicySpec struct {
+ // TargetRefs identifies the L4 route resources (TCPRoute, UDPRoute, or
TLSRoute)
+ // to which this policy applies. Only same-namespace targets are
supported.
+ //
+ // +kubebuilder:validation:MinItems=1
+ // +kubebuilder:validation:MaxItems=16
+ // +kubebuilder:validation:XValidation:rule="self.all(r, r.kind ==
'TCPRoute' || r.kind == 'UDPRoute' || r.kind ==
'TLSRoute')",message="targetRefs kind must be TCPRoute, UDPRoute, or TLSRoute"
+ // +kubebuilder:validation:XValidation:rule="self.all(r, r.group ==
'gateway.networking.k8s.io')",message="targetRefs group must be
gateway.networking.k8s.io"
+ TargetRefs []gatewayv1alpha2.LocalPolicyTargetReferenceWithSectionName
`json:"targetRefs"`
+
+ // Plugins is the list of APISIX stream plugins to attach to the
targeted L4 routes.
+ // Plugin names should be valid APISIX stream plugin names (e.g.,
limit-conn, ip-restriction).
+ //
+ // +optional
+ Plugins []Plugin `json:"plugins,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+
+// L4RoutePolicy defines plugin configuration for Gateway API L4 routes
(TCPRoute, UDPRoute, TLSRoute).
+// It follows the Gateway API Policy Attachment pattern and attaches APISIX
stream plugins
+// to the targeted L4 route resources.
+type L4RoutePolicy struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ // Spec defines the desired state of L4RoutePolicy.
+ Spec L4RoutePolicySpec `json:"spec,omitempty"`
+ Status PolicyStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// L4RoutePolicyList contains a list of L4RoutePolicy.
+type L4RoutePolicyList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []L4RoutePolicy `json:"items"`
+}
+
+func init() {
+ SchemeBuilder.Register(&L4RoutePolicy{}, &L4RoutePolicyList{})
+}
diff --git a/api/v1alpha1/zz_generated.deepcopy.go
b/api/v1alpha1/zz_generated.deepcopy.go
index 9e1c02ff..06a43ca9 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -716,6 +716,94 @@ func (in *HealthCheck) DeepCopy() *HealthCheck {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver,
writing into out. in must be non-nil.
+func (in *L4RoutePolicy) DeepCopyInto(out *L4RoutePolicy) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver,
creating a new L4RoutePolicy.
+func (in *L4RoutePolicy) DeepCopy() *L4RoutePolicy {
+ if in == nil {
+ return nil
+ }
+ out := new(L4RoutePolicy)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver,
creating a new runtime.Object.
+func (in *L4RoutePolicy) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver,
writing into out. in must be non-nil.
+func (in *L4RoutePolicyList) DeepCopyInto(out *L4RoutePolicyList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]L4RoutePolicy, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver,
creating a new L4RoutePolicyList.
+func (in *L4RoutePolicyList) DeepCopy() *L4RoutePolicyList {
+ if in == nil {
+ return nil
+ }
+ out := new(L4RoutePolicyList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver,
creating a new runtime.Object.
+func (in *L4RoutePolicyList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver,
writing into out. in must be non-nil.
+func (in *L4RoutePolicySpec) DeepCopyInto(out *L4RoutePolicySpec) {
+ *out = *in
+ if in.TargetRefs != nil {
+ in, out := &in.TargetRefs, &out.TargetRefs
+ *out =
make([]v1alpha2.LocalPolicyTargetReferenceWithSectionName, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ if in.Plugins != nil {
+ in, out := &in.Plugins, &out.Plugins
+ *out = make([]Plugin, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver,
creating a new L4RoutePolicySpec.
+func (in *L4RoutePolicySpec) DeepCopy() *L4RoutePolicySpec {
+ if in == nil {
+ return nil
+ }
+ out := new(L4RoutePolicySpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver,
writing into out. in must be non-nil.
func (in *LoadBalancer) DeepCopyInto(out *LoadBalancer) {
*out = *in
diff --git a/config/crd/bases/apisix.apache.org_l4routepolicies.yaml
b/config/crd/bases/apisix.apache.org_l4routepolicies.yaml
new file mode 100644
index 00000000..3981fb4f
--- /dev/null
+++ b/config/crd/bases/apisix.apache.org_l4routepolicies.yaml
@@ -0,0 +1,429 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.17.2
+ name: l4routepolicies.apisix.apache.org
+spec:
+ group: apisix.apache.org
+ names:
+ kind: L4RoutePolicy
+ listKind: L4RoutePolicyList
+ plural: l4routepolicies
+ singular: l4routepolicy
+ scope: Namespaced
+ versions:
+ - name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: |-
+ L4RoutePolicy defines plugin configuration for Gateway API L4 routes
(TCPRoute, UDPRoute, TLSRoute).
+ It follows the Gateway API Policy Attachment pattern and attaches
APISIX stream plugins
+ to the targeted L4 route resources.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation
of an object.
+ Servers should convert recognized schemas to the latest internal
value, and
+ may reject unrecognized values.
+ More info:
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this
object represents.
+ Servers may infer this from the endpoint the client submits
requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info:
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: Spec defines the desired state of L4RoutePolicy.
+ properties:
+ plugins:
+ description: |-
+ Plugins is the list of APISIX stream plugins to attach to
the targeted L4 routes.
+ Plugin names should be valid APISIX stream plugin names
(e.g., limit-conn, ip-restriction).
+ items:
+ properties:
+ config:
+ description: Config is plugin configuration details.
+ x-kubernetes-preserve-unknown-fields: true
+ name:
+ description: Name is the name of the plugin.
+ type: string
+ required:
+ - name
+ type: object
+ type: array
+ targetRefs:
+ description: |-
+ TargetRefs identifies the L4 route resources (TCPRoute,
UDPRoute, or TLSRoute)
+ to which this policy applies. Only same-namespace targets
are supported.
+ items:
+ description: |-
+ LocalPolicyTargetReferenceWithSectionName identifies an
API object to apply a
+ direct policy to. This should be used as part of Policy
resources that can
+ target single resources. For more information on how this
policy attachment
+ mode works, and a sample Policy resource, refer to the
policy attachment
+ documentation for Gateway API.
+
+ Note: This should only be used for direct policy
attachment when references
+ to SectionName are actually needed. In all other cases,
+ LocalPolicyTargetReference should be used.
+ properties:
+ group:
+ description: Group is the group of the target resource.
+ maxLength: 253
+ pattern:
^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+ type: string
+ kind:
+ description: Kind is kind of the target resource.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$
+ type: string
+ name:
+ description: Name is the name of the target resource.
+ maxLength: 253
+ minLength: 1
+ type: string
+ sectionName:
+ description: |-
+ SectionName is the name of a section within the target
resource. When
+ unspecified, this targetRef targets the entire
resource. In the following
+ resources, SectionName is interpreted as the following:
+
+ * Gateway: Listener name
+ * HTTPRoute: HTTPRouteRule name
+ * Service: Port name
+
+ If a SectionName is specified, but does not exist on
the targeted object,
+ the Policy must fail to attach, and the policy
implementation should record
+ a `ResolvedRefs` or similar Condition in the Policy's
status.
+ maxLength: 253
+ minLength: 1
+ pattern:
^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+ type: string
+ required:
+ - group
+ - kind
+ - name
+ type: object
+ maxItems: 16
+ minItems: 1
+ type: array
+ x-kubernetes-validations:
+ - message: targetRefs kind must be TCPRoute, UDPRoute, or
TLSRoute
+ rule: self.all(r, r.kind == 'TCPRoute' || r.kind ==
'UDPRoute' ||
+ r.kind == 'TLSRoute')
+ - message: targetRefs group must be gateway.networking.k8s.io
+ rule: self.all(r, r.group == 'gateway.networking.k8s.io')
+ required:
+ - targetRefs
+ type: object
+ status:
+ description: |-
+ PolicyStatus defines the common attributes that all Policies
should include within
+ their status.
+ properties:
+ ancestors:
+ description: |-
+ Ancestors is a list of ancestor resources (usually Gateways)
that are
+ associated with the policy, and the status of the policy
with respect to
+ each ancestor. When this policy attaches to a parent, the
controller that
+ manages the parent and the ancestors MUST add an entry to
this list when
+ the controller first sees the policy and SHOULD update the
entry as
+ appropriate when the relevant ancestor is modified.
+
+ Note that choosing the relevant ancestor is left to the
Policy designers;
+ an important part of Policy design is designing the right
object level at
+ which to namespace this status.
+
+ Note also that implementations MUST ONLY populate ancestor
status for
+ the Ancestor resources they are responsible for.
Implementations MUST
+ use the ControllerName field to uniquely identify the
entries in this list
+ that they are responsible for.
+
+ Note that to achieve this, the list of PolicyAncestorStatus
structs
+ MUST be treated as a map with a composite key, made up of
the AncestorRef
+ and ControllerName fields combined.
+
+ A maximum of 16 ancestors will be represented in this list.
An empty list
+ means the Policy is not relevant for any ancestors.
+
+ If this slice is full, implementations MUST NOT add further
entries.
+ Instead they MUST consider the policy unimplementable and
signal that
+ on any related resources such as the ancestor that would be
referenced
+ here. For example, if this list was full on
BackendTLSPolicy, no
+ additional Gateways would be able to reference the Service
targeted by
+ the BackendTLSPolicy.
+ items:
+ description: |-
+ PolicyAncestorStatus describes the status of a route with
respect to an
+ associated Ancestor.
+
+ Ancestors refer to objects that are either the Target of a
policy or above it
+ in terms of object hierarchy. For example, if a policy
targets a Service, the
+ Policy's Ancestors are, in order, the Service, the
HTTPRoute, the Gateway, and
+ the GatewayClass. Almost always, in this hierarchy, the
Gateway will be the most
+ useful object to place Policy status on, so we recommend
that implementations
+ SHOULD use Gateway as the PolicyAncestorStatus object
unless the designers
+ have a _very_ good reason otherwise.
+
+ In the context of policy attachment, the Ancestor is used
to distinguish which
+ resource results in a distinct application of this policy.
For example, if a policy
+ targets a Service, it may have a distinct result per
attached Gateway.
+
+ Policies targeting the same resource may have different
effects depending on the
+ ancestors of those resources. For example, different
Gateways targeting the same
+ Service may have different capabilities, especially if
they have different underlying
+ implementations.
+
+ For example, in BackendTLSPolicy, the Policy attaches to a
Service that is
+ used as a backend in a HTTPRoute that is itself attached
to a Gateway.
+ In this case, the relevant object for status is the
Gateway, and that is the
+ ancestor object referred to in this status.
+
+ Note that a parent is also an ancestor, so for objects
where the parent is the
+ relevant object for status, this struct SHOULD still be
used.
+
+ This struct is intended to be used in a slice that's
effectively a map,
+ with a composite key made up of the AncestorRef and the
ControllerName.
+ properties:
+ ancestorRef:
+ description: |-
+ AncestorRef corresponds with a ParentRef in the spec
that this
+ PolicyAncestorStatus struct describes the status of.
+ properties:
+ group:
+ default: gateway.networking.k8s.io
+ description: |-
+ Group is the group of the referent.
+ When unspecified, "gateway.networking.k8s.io" is
inferred.
+ To set the core API group (such as for a "Service"
kind referent),
+ Group must be explicitly set to "" (empty string).
+
+ Support: Core
+ maxLength: 253
+ pattern:
^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+ type: string
+ kind:
+ default: Gateway
+ description: |-
+ Kind is kind of the referent.
+
+ There are two kinds of parent resources with
"Core" support:
+
+ * Gateway (Gateway conformance profile)
+ * Service (Mesh conformance profile, ClusterIP
Services only)
+
+ Support for other resources is
Implementation-Specific.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$
+ type: string
+ name:
+ description: |-
+ Name is the name of the referent.
+
+ Support: Core
+ maxLength: 253
+ minLength: 1
+ type: string
+ namespace:
+ description: |-
+ Namespace is the namespace of the referent. When
unspecified, this refers
+ to the local namespace of the Route.
+
+ Note that there are specific rules for ParentRefs
which cross namespace
+ boundaries. Cross-namespace references are only
valid if they are explicitly
+ allowed by something in the namespace they are
referring to. For example:
+ Gateway has the AllowedRoutes field, and
ReferenceGrant provides a
+ generic way to enable any other kind of
cross-namespace reference.
+
+ <gateway:experimental:description>
+ ParentRefs from a Route to a Service in the same
namespace are "producer"
+ routes, which apply default routing rules to
inbound connections from
+ any namespace to the Service.
+
+ ParentRefs from a Route to a Service in a
different namespace are
+ "consumer" routes, and these routing rules are
only applied to outbound
+ connections originating from the same namespace as
the Route, for which
+ the intended destination of the connections are a
Service targeted as a
+ ParentRef of the Route.
+ </gateway:experimental:description>
+
+ Support: Core
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+ type: string
+ port:
+ description: |-
+ Port is the network port this Route targets. It
can be interpreted
+ differently based on the type of parent resource.
+
+ When the parent resource is a Gateway, this
targets all listeners
+ listening on the specified port that also support
this kind of Route(and
+ select this Route). It's not recommended to set
`Port` unless the
+ networking behaviors specified in a Route must
apply to a specific port
+ as opposed to a listener(s) whose port(s) may be
changed. When both Port
+ and SectionName are specified, the name and port
of the selected listener
+ must match both specified values.
+
+ <gateway:experimental:description>
+ When the parent resource is a Service, this
targets a specific port in the
+ Service spec. When both Port (experimental) and
SectionName are specified,
+ the name and port of the selected port must match
both specified values.
+ </gateway:experimental:description>
+
+ Implementations MAY choose to support other parent
resources.
+ Implementations supporting other types of parent
resources MUST clearly
+ document how/if Port is interpreted.
+
+ For the purpose of status, an attachment is
considered successful as
+ long as the parent resource accepts it partially.
For example, Gateway
+ listeners can restrict which Routes can attach to
them by Route kind,
+ namespace, or hostname. If 1 of 2 Gateway
listeners accept attachment
+ from the referencing Route, the Route MUST be
considered successfully
+ attached. If no Gateway listeners accept
attachment from this Route,
+ the Route MUST be considered detached from the
Gateway.
+
+ Support: Extended
+ format: int32
+ maximum: 65535
+ minimum: 1
+ type: integer
+ sectionName:
+ description: |-
+ SectionName is the name of a section within the
target resource. In the
+ following resources, SectionName is interpreted as
the following:
+
+ * Gateway: Listener name. When both Port
(experimental) and SectionName
+ are specified, the name and port of the selected
listener must match
+ both specified values.
+ * Service: Port name. When both Port
(experimental) and SectionName
+ are specified, the name and port of the selected
listener must match
+ both specified values.
+
+ Implementations MAY choose to support attaching
Routes to other resources.
+ If that is the case, they MUST clearly document
how SectionName is
+ interpreted.
+
+ When unspecified (empty string), this will
reference the entire resource.
+ For the purpose of status, an attachment is
considered successful if at
+ least one section in the parent resource accepts
it. For example, Gateway
+ listeners can restrict which Routes can attach to
them by Route kind,
+ namespace, or hostname. If 1 of 2 Gateway
listeners accept attachment from
+ the referencing Route, the Route MUST be
considered successfully
+ attached. If no Gateway listeners accept
attachment from this Route, the
+ Route MUST be considered detached from the Gateway.
+
+ Support: Core
+ maxLength: 253
+ minLength: 1
+ pattern:
^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+ type: string
+ required:
+ - name
+ type: object
+ conditions:
+ description: Conditions describes the status of the
Policy with
+ respect to the given Ancestor.
+ items:
+ description: Condition contains details for one aspect
of
+ the current state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the
condition transitioned from one status to another.
+ This should be when the underlying condition
changed. If that is not known, then using the time when the API field changed
is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating
details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the
.metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is
currently 12, but the .status.conditions[x].observedGeneration is 9, the
condition is out of date
+ with respect to the current state of the
instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier
indicating the reason for the condition's last transition.
+ Producers of specific condition types may define
expected values and meanings for this field,
+ and whether the values are considered a
guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True,
False,
+ Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in
foo.example.com/CamelCase.
+ maxLength: 316
+ pattern:
^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ maxItems: 8
+ minItems: 1
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ controllerName:
+ description: |-
+ ControllerName is a domain/path string that indicates
the name of the
+ controller that wrote this status. This corresponds
with the
+ controllerName field on GatewayClass.
+
+ Example: "example.net/gateway-controller".
+
+ The format of this field is DOMAIN "/" PATH, where
DOMAIN and PATH are
+ valid Kubernetes names
+
(https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names).
+
+ Controllers MUST populate this field when writing
status. Controllers should ensure that
+ entries to status populated with their ControllerName
are cleaned up when they are no
+ longer necessary.
+ maxLength: 253
+ minLength: 1
+ pattern:
^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$
+ type: string
+ required:
+ - ancestorRef
+ - controllerName
+ type: object
+ maxItems: 16
+ type: array
+ required:
+ - ancestors
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index 4148a24d..c2a7b3c0 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -7,6 +7,7 @@ resources:
- bases/apisix.apache.org_consumers.yaml
- bases/apisix.apache.org_backendtrafficpolicies.yaml
- bases/apisix.apache.org_httproutepolicies.yaml
+- bases/apisix.apache.org_l4routepolicies.yaml
- bases/apisix.apache.org_apisixroutes.yaml
- bases/apisix.apache.org_apisixconsumers.yaml
- bases/apisix.apache.org_apisixglobalrules.yaml
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index bfda220b..a883ef90 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -35,6 +35,7 @@ rules:
- consumers
- gatewayproxies
- httproutepolicies
+ - l4routepolicies
- pluginconfigs
verbs:
- get
@@ -52,6 +53,7 @@ rules:
- backendtrafficpolicies/status
- consumers/status
- httproutepolicies/status
+ - l4routepolicies/status
verbs:
- get
- update
diff --git a/docs/en/latest/reference/api-reference.md
b/docs/en/latest/reference/api-reference.md
index 3d8987ac..8a02cfdf 100644
--- a/docs/en/latest/reference/api-reference.md
+++ b/docs/en/latest/reference/api-reference.md
@@ -19,6 +19,7 @@ Package v1alpha1 contains API Schema definitions for the
apisix.apache.org v1alp
- [Consumer](#consumer)
- [GatewayProxy](#gatewayproxy)
- [HTTPRoutePolicy](#httproutepolicy)
+- [L4RoutePolicy](#l4routepolicy)
- [PluginConfig](#pluginconfig)
### BackendTrafficPolicy
@@ -84,6 +85,24 @@ HTTPRoutePolicy defines configuration of traffic policies.
+### L4RoutePolicy
+
+
+L4RoutePolicy defines plugin configuration for Gateway API L4 routes
(TCPRoute, UDPRoute, TLSRoute).
+It follows the Gateway API Policy Attachment pattern and attaches APISIX
stream plugins
+to the targeted L4 route resources.
+
+<!-- L4RoutePolicy resource -->
+
+| Field | Description |
+| --- | --- |
+| `apiVersion` _string_ | `apisix.apache.org/v1alpha1`
+| `kind` _string_ | `L4RoutePolicy`
+| `metadata`
_[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_
| Please refer to the Kubernetes API documentation for details on the
`metadata` field. |
+| `spec` _[L4RoutePolicySpec](#l4routepolicyspec)_ | Spec defines the desired
state of L4RoutePolicy. |
+
+
+
### PluginConfig
@@ -433,6 +452,22 @@ _Base type:_ `string`
_Appears in:_
- [BackendTrafficPolicySpec](#backendtrafficpolicyspec)
+#### L4RoutePolicySpec
+
+
+L4RoutePolicySpec defines the desired state of L4RoutePolicy.
+
+
+
+| Field | Description |
+| --- | --- |
+| `targetRefs` _LocalPolicyTargetReferenceWithSectionName array_ | TargetRefs
identifies the L4 route resources (TCPRoute, UDPRoute, or TLSRoute) to which
this policy applies. Only same-namespace targets are supported. |
+| `plugins` _[Plugin](#plugin) array_ | Plugins is the list of APISIX stream
plugins to attach to the targeted L4 routes. Plugin names should be valid
APISIX stream plugin names (e.g., limit-conn, ip-restriction). |
+
+
+_Appears in:_
+- [L4RoutePolicy](#l4routepolicy)
+
#### LoadBalancer
@@ -518,6 +553,7 @@ _Appears in:_
_Appears in:_
- [ConsumerSpec](#consumerspec)
+- [L4RoutePolicySpec](#l4routepolicyspec)
- [PluginConfigSpec](#pluginconfigspec)
#### PluginConfigSpec
diff --git a/internal/adc/translator/l4route_test.go
b/internal/adc/translator/l4route_test.go
new file mode 100644
index 00000000..fdf7a87d
--- /dev/null
+++ b/internal/adc/translator/l4route_test.go
@@ -0,0 +1,269 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package translator
+
+import (
+ "context"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ k8stypes "k8s.io/apimachinery/pkg/types"
+ gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
+
+ "github.com/apache/apisix-ingress-controller/api/v1alpha1"
+ "github.com/apache/apisix-ingress-controller/internal/provider"
+)
+
+func TestTranslateTCPRouteWithL4RoutePolicy(t *testing.T) {
+ tests := []struct {
+ name string
+ policy *v1alpha1.L4RoutePolicy
+ wantPlugins []string
+ wantNoPlugins bool
+ }{
+ {
+ name: "attaches plugins from matching L4RoutePolicy",
+ policy: makeL4RoutePolicy("default", "tcp-policy",
"TCPRoute", "my-tcp", []v1alpha1.Plugin{
+ {Name: "limit-conn", Config:
mustJSON(map[string]any{"conn": 100})},
+ {Name: "ip-restriction", Config:
mustJSON(map[string]any{"whitelist": []string{"10.0.0.0/8"}})},
+ }),
+ wantPlugins: []string{"limit-conn", "ip-restriction"},
+ },
+ {
+ name: "does not attach plugins from policy targeting
different route kind",
+ policy: makeL4RoutePolicy("default", "udp-policy",
"UDPRoute", "my-tcp", []v1alpha1.Plugin{
+ {Name: "limit-conn", Config:
mustJSON(map[string]any{"conn": 100})},
+ }),
+ wantNoPlugins: true,
+ },
+ {
+ name: "does not attach plugins from policy targeting
different route name",
+ policy: makeL4RoutePolicy("default", "tcp-policy",
"TCPRoute", "other-tcp", []v1alpha1.Plugin{
+ {Name: "limit-conn", Config:
mustJSON(map[string]any{"conn": 100})},
+ }),
+ wantNoPlugins: true,
+ },
+ {
+ name: "succeeds with no policy in context",
+ policy: nil,
+ wantNoPlugins: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ translator := NewTranslator(logr.Discard(), "")
+ tctx :=
provider.NewDefaultTranslateContext(context.Background())
+
+ if tt.policy != nil {
+ key := k8stypes.NamespacedName{Namespace:
tt.policy.Namespace, Name: tt.policy.Name}
+ tctx.L4RoutePolicies[key] = tt.policy
+ }
+
+ route := &gatewayv1alpha2.TCPRoute{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "my-tcp",
+ Namespace: "default",
+ },
+ Spec: gatewayv1alpha2.TCPRouteSpec{
+ Rules: []gatewayv1alpha2.TCPRouteRule{
+ {BackendRefs:
[]gatewayv1alpha2.BackendRef{}},
+ },
+ },
+ }
+
+ result, err := translator.TranslateTCPRoute(tctx, route)
+ require.NoError(t, err)
+ require.Len(t, result.Services, 1)
+ require.NotEmpty(t, result.Services[0].StreamRoutes)
+
+ plugins := result.Services[0].StreamRoutes[0].Plugins
+ if tt.wantNoPlugins {
+ assert.Empty(t, plugins)
+ } else {
+ for _, name := range tt.wantPlugins {
+ assert.Contains(t, plugins, name,
"expected plugin %q to be attached", name)
+ }
+ }
+ })
+ }
+}
+
+func TestTranslateUDPRouteWithL4RoutePolicy(t *testing.T) {
+ tests := []struct {
+ name string
+ policy *v1alpha1.L4RoutePolicy
+ wantPlugins []string
+ wantNoPlugins bool
+ }{
+ {
+ name: "attaches plugins from matching L4RoutePolicy",
+ policy: makeL4RoutePolicy("default", "udp-policy",
"UDPRoute", "my-udp", []v1alpha1.Plugin{
+ {Name: "limit-conn", Config:
mustJSON(map[string]any{"conn": 50})},
+ }),
+ wantPlugins: []string{"limit-conn"},
+ },
+ {
+ name: "does not attach plugins from policy targeting
TCPRoute",
+ policy: makeL4RoutePolicy("default", "tcp-policy",
"TCPRoute", "my-udp", []v1alpha1.Plugin{
+ {Name: "limit-conn", Config:
mustJSON(map[string]any{"conn": 50})},
+ }),
+ wantNoPlugins: true,
+ },
+ {
+ name: "succeeds with no policy in context",
+ policy: nil,
+ wantNoPlugins: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ translator := NewTranslator(logr.Discard(), "")
+ tctx :=
provider.NewDefaultTranslateContext(context.Background())
+
+ if tt.policy != nil {
+ key := k8stypes.NamespacedName{Namespace:
tt.policy.Namespace, Name: tt.policy.Name}
+ tctx.L4RoutePolicies[key] = tt.policy
+ }
+
+ route := &gatewayv1alpha2.UDPRoute{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "my-udp",
+ Namespace: "default",
+ },
+ Spec: gatewayv1alpha2.UDPRouteSpec{
+ Rules: []gatewayv1alpha2.UDPRouteRule{
+ {BackendRefs:
[]gatewayv1alpha2.BackendRef{}},
+ },
+ },
+ }
+
+ result, err := translator.TranslateUDPRoute(tctx, route)
+ require.NoError(t, err)
+ require.Len(t, result.Services, 1)
+ require.NotEmpty(t, result.Services[0].StreamRoutes)
+
+ plugins := result.Services[0].StreamRoutes[0].Plugins
+ if tt.wantNoPlugins {
+ assert.Empty(t, plugins)
+ } else {
+ for _, name := range tt.wantPlugins {
+ assert.Contains(t, plugins, name,
"expected plugin %q to be attached", name)
+ }
+ }
+ })
+ }
+}
+
+func TestTranslateTLSRouteWithL4RoutePolicy(t *testing.T) {
+ tests := []struct {
+ name string
+ policy *v1alpha1.L4RoutePolicy
+ hostnames []string
+ wantPlugins []string
+ wantNoPlugins bool
+ }{
+ {
+ name: "attaches plugins from matching L4RoutePolicy",
+ policy: makeL4RoutePolicy("default", "tls-policy",
"TLSRoute", "my-tls", []v1alpha1.Plugin{
+ {Name: "ip-restriction", Config:
mustJSON(map[string]any{"whitelist": []string{"192.168.0.0/16"}})},
+ }),
+ hostnames: []string{"example.com"},
+ wantPlugins: []string{"ip-restriction"},
+ },
+ {
+ name: "plugins attached once per rule even with
multiple SNI hostnames",
+ policy: makeL4RoutePolicy("default", "tls-policy",
"TLSRoute", "my-tls", []v1alpha1.Plugin{
+ {Name: "limit-conn", Config:
mustJSON(map[string]any{"conn": 20})},
+ }),
+ hostnames: []string{"foo.example.com",
"bar.example.com"},
+ wantPlugins: []string{"limit-conn"},
+ },
+ {
+ name: "does not attach plugins from policy targeting
TCPRoute",
+ policy: makeL4RoutePolicy("default", "tcp-policy",
"TCPRoute", "my-tls", []v1alpha1.Plugin{
+ {Name: "limit-conn", Config:
mustJSON(map[string]any{"conn": 20})},
+ }),
+ hostnames: []string{"example.com"},
+ wantNoPlugins: true,
+ },
+ {
+ name: "succeeds with no policy in context",
+ policy: nil,
+ hostnames: []string{"example.com"},
+ wantNoPlugins: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ translator := NewTranslator(logr.Discard(), "")
+ tctx :=
provider.NewDefaultTranslateContext(context.Background())
+
+ if tt.policy != nil {
+ key := k8stypes.NamespacedName{Namespace:
tt.policy.Namespace, Name: tt.policy.Name}
+ tctx.L4RoutePolicies[key] = tt.policy
+ }
+
+ hostnames := make([]gatewayv1alpha2.Hostname, 0,
len(tt.hostnames))
+ for _, h := range tt.hostnames {
+ hostnames = append(hostnames,
gatewayv1alpha2.Hostname(h))
+ }
+
+ route := &gatewayv1alpha2.TLSRoute{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "my-tls",
+ Namespace: "default",
+ },
+ Spec: gatewayv1alpha2.TLSRouteSpec{
+ Hostnames: hostnames,
+ Rules: []gatewayv1alpha2.TLSRouteRule{
+ {BackendRefs:
[]gatewayv1alpha2.BackendRef{}},
+ },
+ },
+ }
+
+ result, err := translator.TranslateTLSRoute(tctx, route)
+ require.NoError(t, err)
+ require.Len(t, result.Services, 1)
+
+ // Verify stream routes are created per SNI hostname
+ if len(tt.hostnames) > 0 {
+ assert.Len(t, result.Services[0].StreamRoutes,
len(tt.hostnames))
+ }
+
+ // Plugins are attached at the stream_route level so
the APISIX stream proxy
+ // applies them; with multiple SNIs each stream_route
carries its own copy.
+ require.NotEmpty(t, result.Services[0].StreamRoutes)
+ plugins := result.Services[0].StreamRoutes[0].Plugins
+ if tt.wantNoPlugins {
+ assert.Empty(t, plugins)
+ } else {
+ for _, streamRoute := range
result.Services[0].StreamRoutes {
+ for _, name := range tt.wantPlugins {
+ assert.Contains(t,
streamRoute.Plugins, name, "expected plugin %q to be attached", name)
+ }
+ }
+ }
+ })
+ }
+}
diff --git a/internal/adc/translator/l4routepolicy_test.go
b/internal/adc/translator/l4routepolicy_test.go
new file mode 100644
index 00000000..9bae179c
--- /dev/null
+++ b/internal/adc/translator/l4routepolicy_test.go
@@ -0,0 +1,143 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package translator
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+ apiextensionsv1
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ k8stypes "k8s.io/apimachinery/pkg/types"
+ gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
+
+ adctypes "github.com/apache/apisix-ingress-controller/api/adc"
+ "github.com/apache/apisix-ingress-controller/api/v1alpha1"
+)
+
+func makeL4RoutePolicy(namespace, name, targetKind, targetName string, plugins
[]v1alpha1.Plugin) *v1alpha1.L4RoutePolicy {
+ return &v1alpha1.L4RoutePolicy{
+ ObjectMeta: metav1.ObjectMeta{
+ Namespace: namespace,
+ Name: name,
+ },
+ Spec: v1alpha1.L4RoutePolicySpec{
+ TargetRefs:
[]gatewayv1alpha2.LocalPolicyTargetReferenceWithSectionName{
+ {
+ LocalPolicyTargetReference:
gatewayv1alpha2.LocalPolicyTargetReference{
+ Group:
gatewayv1alpha2.GroupName,
+ Kind:
gatewayv1alpha2.Kind(targetKind),
+ Name:
gatewayv1alpha2.ObjectName(targetName),
+ },
+ },
+ },
+ Plugins: plugins,
+ },
+ }
+}
+
+func mustJSON(v any) apiextensionsv1.JSON {
+ b, err := json.Marshal(v)
+ if err != nil {
+ panic(err)
+ }
+ return apiextensionsv1.JSON{Raw: b}
+}
+
+func TestAttachL4RoutePolicyPlugins_AttachesMatchingPolicy(t *testing.T) {
+ tr := NewTranslator(logr.Discard(), "")
+
+ policy := makeL4RoutePolicy("default", "my-policy", "TCPRoute",
"my-tcp-route", []v1alpha1.Plugin{
+ {Name: "limit-conn", Config: mustJSON(map[string]any{"conn":
100, "burst": 50})},
+ {Name: "ip-restriction", Config:
mustJSON(map[string]any{"whitelist": []string{"10.0.0.0/8"}})},
+ })
+
+ policies := map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy{
+ {Namespace: "default", Name: "my-policy"}: policy,
+ }
+
+ plugins := adctypes.Plugins{}
+ tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route",
"TCPRoute", plugins)
+
+ assert.Len(t, plugins, 2)
+ assert.Contains(t, plugins, "limit-conn")
+ assert.Contains(t, plugins, "ip-restriction")
+
+ cfg := plugins["limit-conn"].(map[string]any)
+ assert.EqualValues(t, 100, cfg["conn"])
+}
+
+func TestAttachL4RoutePolicyPlugins_NoMatchOnKind(t *testing.T) {
+ tr := NewTranslator(logr.Discard(), "")
+
+ policy := makeL4RoutePolicy("default", "udp-policy", "UDPRoute",
"my-udp-route", []v1alpha1.Plugin{
+ {Name: "limit-conn", Config: mustJSON(map[string]any{"conn":
10})},
+ })
+
+ policies := map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy{
+ {Namespace: "default", Name: "udp-policy"}: policy,
+ }
+
+ plugins := adctypes.Plugins{}
+ // Looking for TCPRoute, but policy targets UDPRoute — should not match.
+ tr.AttachL4RoutePolicyPlugins(policies, "default", "my-udp-route",
"TCPRoute", plugins)
+
+ assert.Empty(t, plugins)
+}
+
+func TestAttachL4RoutePolicyPlugins_NoMatchOnNamespace(t *testing.T) {
+ tr := NewTranslator(logr.Discard(), "")
+
+ policy := makeL4RoutePolicy("other-ns", "my-policy", "TCPRoute",
"my-tcp-route", []v1alpha1.Plugin{
+ {Name: "limit-conn", Config: mustJSON(map[string]any{"conn":
10})},
+ })
+
+ policies := map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy{
+ {Namespace: "other-ns", Name: "my-policy"}: policy,
+ }
+
+ plugins := adctypes.Plugins{}
+ // Route is in "default" namespace, policy is in "other-ns" — should
not match.
+ tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route",
"TCPRoute", plugins)
+
+ assert.Empty(t, plugins)
+}
+
+func TestAttachL4RoutePolicyPlugins_EmptyPlugins(t *testing.T) {
+ tr := NewTranslator(logr.Discard(), "")
+
+ policy := makeL4RoutePolicy("default", "empty-policy", "TCPRoute",
"my-tcp-route", nil)
+
+ policies := map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy{
+ {Namespace: "default", Name: "empty-policy"}: policy,
+ }
+
+ plugins := adctypes.Plugins{}
+ tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route",
"TCPRoute", plugins)
+
+ assert.Empty(t, plugins)
+}
+
+func TestAttachL4RoutePolicyPlugins_EmptyPolicies(t *testing.T) {
+ tr := NewTranslator(logr.Discard(), "")
+ plugins := adctypes.Plugins{}
+ tr.AttachL4RoutePolicyPlugins(nil, "default", "my-tcp-route",
"TCPRoute", plugins)
+ assert.Empty(t, plugins)
+}
diff --git a/internal/adc/translator/policies.go
b/internal/adc/translator/policies.go
index 726fbfac..9bf65999 100644
--- a/internal/adc/translator/policies.go
+++ b/internal/adc/translator/policies.go
@@ -18,9 +18,12 @@
package translator
import (
+ "encoding/json"
+
"k8s.io/apimachinery/pkg/types"
"k8s.io/utils/ptr"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+ gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
adctypes "github.com/apache/apisix-ingress-controller/api/adc"
"github.com/apache/apisix-ingress-controller/api/v1alpha1"
@@ -169,3 +172,57 @@ func translateBTPPassiveHealthCheck(config
*v1alpha1.PassiveHealthCheck) *adctyp
}
return passive
}
+
+// AttachL4RoutePolicyPlugins merges plugins from the matching L4RoutePolicy
(if any) into the
+// provided plugins map. It looks up policies targeting the route identified
by routeNamespace,
+// routeName, and routeKind.
+func (t *Translator) AttachL4RoutePolicyPlugins(
+ policies map[types.NamespacedName]*v1alpha1.L4RoutePolicy,
+ routeNamespace, routeName, routeKind string,
+ plugins adctypes.Plugins,
+) {
+ if len(policies) == 0 {
+ return
+ }
+ for _, policy := range policies {
+ if policy.Namespace != routeNamespace {
+ continue
+ }
+ for _, ref := range policy.Spec.TargetRefs {
+ if string(ref.Group) != gatewayv1alpha2.GroupName {
+ continue
+ }
+ if string(ref.Kind) != routeKind {
+ continue
+ }
+ if string(ref.Name) != routeName {
+ continue
+ }
+ // sectionName targeting is not supported for L4
routes; skip such refs
+ // so plugins are not attached for an attachment that
cannot be honored.
+ if ref.SectionName != nil && *ref.SectionName != "" {
+ continue
+ }
+ t.mergeL4PolicyPlugins(policy, plugins)
+ return
+ }
+ }
+}
+
+func (t *Translator) mergeL4PolicyPlugins(policy *v1alpha1.L4RoutePolicy,
plugins adctypes.Plugins) {
+ for _, plugin := range policy.Spec.Plugins {
+ cfg := make(map[string]any)
+ if len(plugin.Config.Raw) > 0 {
+ if err := json.Unmarshal(plugin.Config.Raw, &cfg); err
!= nil {
+ t.Log.Error(err, "failed to unmarshal
L4RoutePolicy plugin config", "plugin", plugin.Name, "policy", policy.Name)
+ continue
+ }
+ }
+ // A literal `config: null` unmarshals to a nil map, which
serializes back to
+ // null and is rejected by most APISIX plugins; normalize it to
an empty object.
+ if cfg == nil {
+ cfg = map[string]any{}
+ }
+ plugins[plugin.Name] = cfg
+ }
+}
diff --git a/internal/adc/translator/tcproute.go
b/internal/adc/translator/tcproute.go
index fc9c0b13..c7f00e0e 100644
--- a/internal/adc/translator/tcproute.go
+++ b/internal/adc/translator/tcproute.go
@@ -156,7 +156,12 @@ func (t *Translator) TranslateTCPRoute(tctx
*provider.TranslateContext, tcpRoute
streamRoute.ID = id.GenID(streamRouteName)
streamRoute.Labels = labels
// TODO: support remote_addr, server_addr, sni, server_port
+ // Attach L4RoutePolicy plugins at the stream_route level: the
APISIX stream proxy
+ // applies plugins from the stream_route, not from the service.
+ streamRoute.Plugins = make(adctypes.Plugins)
+ t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies,
tcpRoute.Namespace, tcpRoute.Name, "TCPRoute", streamRoute.Plugins)
service.StreamRoutes = append(service.StreamRoutes, streamRoute)
+
result.Services = append(result.Services, service)
}
return result, nil
diff --git a/internal/adc/translator/tlsroute.go
b/internal/adc/translator/tlsroute.go
index b1eb5fa0..4b2ef33d 100644
--- a/internal/adc/translator/tlsroute.go
+++ b/internal/adc/translator/tlsroute.go
@@ -151,8 +151,14 @@ func (t *Translator) TranslateTLSRoute(tctx
*provider.TranslateContext, tlsRoute
streamRoute.ID = id.GenID(streamRouteName)
streamRoute.SNI = host
streamRoute.Labels = labels
+ // Attach L4RoutePolicy plugins at the stream_route
level: the APISIX stream proxy
+ // applies plugins from the stream_route, not from the
service. With multiple SNIs
+ // each stream_route carries its own copy of the
plugins.
+ streamRoute.Plugins = make(adctypes.Plugins)
+ t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies,
tlsRoute.Namespace, tlsRoute.Name, "TLSRoute", streamRoute.Plugins)
service.StreamRoutes = append(service.StreamRoutes,
streamRoute)
}
+
result.Services = append(result.Services, service)
}
return result, nil
diff --git a/internal/adc/translator/udproute.go
b/internal/adc/translator/udproute.go
index 5cc09a10..00c90f3e 100644
--- a/internal/adc/translator/udproute.go
+++ b/internal/adc/translator/udproute.go
@@ -145,7 +145,12 @@ func (t *Translator) TranslateUDPRoute(tctx
*provider.TranslateContext, udpRoute
streamRoute.ID = id.GenID(streamRouteName)
streamRoute.Labels = labels
// TODO: support remote_addr, server_addr, sni, server_port
+ // Attach L4RoutePolicy plugins at the stream_route level: the
APISIX stream proxy
+ // applies plugins from the stream_route, not from the service.
+ streamRoute.Plugins = make(adctypes.Plugins)
+ t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies,
udpRoute.Namespace, udpRoute.Name, "UDPRoute", streamRoute.Plugins)
service.StreamRoutes = append(service.StreamRoutes, streamRoute)
+
result.Services = append(result.Services, service)
}
return result, nil
diff --git a/internal/controller/indexer/indexer.go
b/internal/controller/indexer/indexer.go
index 5172e487..58403f71 100644
--- a/internal/controller/indexer/indexer.go
+++ b/internal/controller/indexer/indexer.go
@@ -62,6 +62,7 @@ func SetupAPIv1alpha1Indexer(mgr ctrl.Manager) error {
&v1alpha1.BackendTrafficPolicy{}:
setupBackendTrafficPolicyIndexer,
&v1alpha1.Consumer{}: setupConsumerIndexer,
&v1alpha1.GatewayProxy{}: setupGatewayProxyIndexer,
+ &v1alpha1.L4RoutePolicy{}: setupL4RoutePolicyIndexer,
} {
if utils.HasAPIResource(mgr, resource) {
if err := setup(mgr); err != nil {
@@ -487,6 +488,18 @@ func setupBackendTrafficPolicyIndexer(mgr ctrl.Manager)
error {
return nil
}
+func setupL4RoutePolicyIndexer(mgr ctrl.Manager) error {
+ if err := mgr.GetFieldIndexer().IndexField(
+ context.Background(),
+ &v1alpha1.L4RoutePolicy{},
+ PolicyTargetRefs,
+ L4RoutePolicyIndexFunc,
+ ); err != nil {
+ return err
+ }
+ return nil
+}
+
func IngressClassIndexFunc(rawObj client.Object) []string {
ingressClass := rawObj.(*networkingv1.IngressClass)
if ingressClass.Spec.Controller == "" {
@@ -853,6 +866,20 @@ func BackendTrafficPolicyIndexFunc(rawObj client.Object)
[]string {
return keys
}
+func L4RoutePolicyIndexFunc(rawObj client.Object) []string {
+ lrp := rawObj.(*v1alpha1.L4RoutePolicy)
+ keys := make([]string, 0, len(lrp.Spec.TargetRefs))
+ m := make(map[string]struct{})
+ for _, ref := range lrp.Spec.TargetRefs {
+ key := GenIndexKeyWithGK(string(ref.Group), string(ref.Kind),
lrp.GetNamespace(), string(ref.Name))
+ if _, ok := m[key]; !ok {
+ m[key] = struct{}{}
+ keys = append(keys, key)
+ }
+ }
+ return keys
+}
+
func IngressClassParametersRefIndexFunc(rawObj client.Object) []string {
ingressClass := rawObj.(*networkingv1.IngressClass)
// check if the IngressClass references this gateway proxy
diff --git a/internal/controller/policies.go b/internal/controller/policies.go
index f117cdea..ef563d2a 100644
--- a/internal/controller/policies.go
+++ b/internal/controller/policies.go
@@ -18,7 +18,10 @@
package controller
import (
+ "context"
"fmt"
+ "slices"
+ "sort"
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/api/meta"
@@ -224,3 +227,193 @@ func parentRefValueEqual(a, b gatewayv1.ParentReference)
bool {
ptr.Equal(a.Namespace, b.Namespace) &&
a.Name == b.Name
}
+
+// l4RoutePolicyMatchesRoute reports whether the policy has a targetRef that
matches the
+// given L4 route. A ref matches only when its group/kind/name equal the route
and it does
+// not pin a sectionName, since L4 routes expose no addressable sections to
attach to.
+func l4RoutePolicyMatchesRoute(policy v1alpha1.L4RoutePolicy, routeKind,
routeNamespace, routeName string) bool {
+ if policy.Namespace != routeNamespace {
+ return false
+ }
+ for _, ref := range policy.Spec.TargetRefs {
+ if string(ref.Group) != gatewayv1alpha2.GroupName {
+ continue
+ }
+ if string(ref.Kind) != routeKind {
+ continue
+ }
+ if string(ref.Name) != routeName {
+ continue
+ }
+ if ref.SectionName != nil && *ref.SectionName != "" {
+ continue
+ }
+ return true
+ }
+ return false
+}
+
+// ProcessL4RoutePolicy finds L4RoutePolicy resources that target the given L4
route
+// (identified by namespace, name, and kind), resolves conflicts
deterministically,
+// populates tctx.L4RoutePolicies with the winning policy, and queues status
updates.
+func ProcessL4RoutePolicy(
+ c client.Client,
+ log logr.Logger,
+ tctx *provider.TranslateContext,
+ routeNamespace, routeName, routeKind string,
+) {
+ var list v1alpha1.L4RoutePolicyList
+ key := indexer.GenIndexKeyWithGK(gatewayv1alpha2.GroupName, routeKind,
routeNamespace, routeName)
+ if err := c.List(tctx, &list,
client.MatchingFields{indexer.PolicyTargetRefs: key}); err != nil {
+ log.Error(err, "failed to list L4RoutePolicy", "namespace",
routeNamespace, "name", routeName, "kind", routeKind)
+ return
+ }
+ if len(list.Items) == 0 {
+ return
+ }
+
+ // L4 routes have no addressable sections; a targetRef that specifies a
sectionName
+ // cannot be honored, so ignore policies that only match this route via
such a ref.
+ list.Items = slices.DeleteFunc(list.Items, func(p
v1alpha1.L4RoutePolicy) bool {
+ return !l4RoutePolicyMatchesRoute(p, routeKind, routeNamespace,
routeName)
+ })
+ if len(list.Items) == 0 {
+ return
+ }
+
+ // Deterministic conflict resolution: oldest creationTimestamp wins;
tie-break by namespace/name.
+ sort.Slice(list.Items, func(i, j int) bool {
+ ti := list.Items[i].CreationTimestamp.Time
+ tj := list.Items[j].CreationTimestamp.Time
+ if ti.Equal(tj) {
+ ki := list.Items[i].Namespace + "/" + list.Items[i].Name
+ kj := list.Items[j].Namespace + "/" + list.Items[j].Name
+ return ki < kj
+ }
+ return ti.Before(tj)
+ })
+
+ winner := list.Items[0].DeepCopy()
+ tctx.L4RoutePolicies[types.NamespacedName{Namespace: winner.Namespace,
Name: winner.Name}] = winner
+
+ for i := range list.Items {
+ policy := list.Items[i]
+ var condition metav1.Condition
+ if i == 0 {
+ condition = metav1.Condition{
+ Type:
string(gatewayv1alpha2.PolicyConditionAccepted),
+ Status: metav1.ConditionTrue,
+ ObservedGeneration: policy.GetGeneration(),
+ LastTransitionTime: metav1.Now(),
+ Reason:
string(gatewayv1alpha2.PolicyReasonAccepted),
+ Message: "Policy has been accepted",
+ }
+ } else {
+ condition = metav1.Condition{
+ Type:
string(gatewayv1alpha2.PolicyConditionAccepted),
+ Status: metav1.ConditionFalse,
+ ObservedGeneration: policy.GetGeneration(),
+ LastTransitionTime: metav1.Now(),
+ Reason:
string(gatewayv1alpha2.PolicyReasonConflicted),
+ Message: fmt.Sprintf("Conflicts with
L4RoutePolicy %s/%s which was created earlier", winner.Namespace, winner.Name),
+ }
+ }
+
+ if updated := SetAncestors(&policy.Status,
tctx.RouteParentRefs, condition); updated {
+ // Resource must be a separate copy from the object
captured by the Mutator:
+ // the status updater calls client.Get into Resource,
overwriting it with the
+ // server state. The Mutator reads policy.Status, which
keeps the ancestors set above.
+ tctx.StatusUpdaters = append(tctx.StatusUpdaters,
status.Update{
+ NamespacedName: utils.NamespacedName(&policy),
+ Resource: policy.DeepCopy(),
+ Mutator: status.MutatorFunc(func(obj
client.Object) client.Object {
+ cp :=
obj.(*v1alpha1.L4RoutePolicy).DeepCopy()
+ cp.Status = policy.Status
+ return cp
+ }),
+ })
+ }
+ }
+}
+
+// updateL4RoutePolicyStatusOnDeleting removes the deleted route's ancestor
status entries
+// from L4RoutePolicy resources that target it. A single policy may target
multiple routes,
+// so the still-existing target routes' parentRefs are recomputed and only
ancestor entries
+// no longer referenced by any of them are removed.
+func updateL4RoutePolicyStatusOnDeleting(ctx context.Context, c client.Client,
updater status.Updater, log logr.Logger, nn types.NamespacedName, routeKind
string) {
+ var list v1alpha1.L4RoutePolicyList
+ key := indexer.GenIndexKeyWithGK(gatewayv1alpha2.GroupName, routeKind,
nn.Namespace, nn.Name)
+ if err := c.List(ctx, &list,
client.MatchingFields{indexer.PolicyTargetRefs: key}); err != nil {
+ log.Error(err, "failed to list L4RoutePolicy on route
deletion", "namespace", nn.Namespace, "name", nn.Name)
+ return
+ }
+ for i := range list.Items {
+ policy := list.Items[i]
+ var parentRefs []gatewayv1.ParentReference
+ for _, ref := range policy.Spec.TargetRefs {
+ if string(ref.Group) != gatewayv1alpha2.GroupName {
+ continue
+ }
+ // The deleted route returns NotFound here and is
naturally skipped.
+ refs, ok := l4RouteParentRefs(ctx, c, string(ref.Kind),
types.NamespacedName{Namespace: policy.Namespace, Name: string(ref.Name)})
+ if !ok {
+ continue
+ }
+ parentRefs = append(parentRefs, refs...)
+ }
+ updateL4RoutePolicyDeleteAncestors(updater, policy, parentRefs)
+ }
+}
+
+// l4RouteParentRefs returns the parentRefs of the L4 route identified by
kind/nn,
+// or ok=false if the route kind is unsupported or the route no longer exists.
+func l4RouteParentRefs(ctx context.Context, c client.Client, kind string, nn
types.NamespacedName) ([]gatewayv1.ParentReference, bool) {
+ switch kind {
+ case internaltypes.KindTCPRoute:
+ var route gatewayv1alpha2.TCPRoute
+ if err := c.Get(ctx, nn, &route); err != nil {
+ return nil, false
+ }
+ return route.Spec.ParentRefs, true
+ case internaltypes.KindUDPRoute:
+ var route gatewayv1alpha2.UDPRoute
+ if err := c.Get(ctx, nn, &route); err != nil {
+ return nil, false
+ }
+ return route.Spec.ParentRefs, true
+ case internaltypes.KindTLSRoute:
+ var route gatewayv1alpha2.TLSRoute
+ if err := c.Get(ctx, nn, &route); err != nil {
+ return nil, false
+ }
+ return route.Spec.ParentRefs, true
+ default:
+ return nil, false
+ }
+}
+
+func updateL4RoutePolicyDeleteAncestors(updater status.Updater, policy
v1alpha1.L4RoutePolicy, parentRefs []gatewayv1.ParentReference) {
+ length := len(policy.Status.Ancestors)
+ policy.Status.Ancestors = slices.DeleteFunc(policy.Status.Ancestors,
func(ancestor gatewayv1alpha2.PolicyAncestorStatus) bool {
+ return !slices.ContainsFunc(parentRefs, func(ref
gatewayv1.ParentReference) bool {
+ return parentRefValueEqual(ancestor.AncestorRef, ref)
+ })
+ })
+ if length == len(policy.Status.Ancestors) {
+ return
+ }
+ // status.ancestors is a required field; ensure a fully-cleared list
serializes to []
+ // rather than null, which the CRD schema rejects.
+ if policy.Status.Ancestors == nil {
+ policy.Status.Ancestors =
[]gatewayv1alpha2.PolicyAncestorStatus{}
+ }
+ updater.Update(status.Update{
+ NamespacedName: utils.NamespacedName(&policy),
+ Resource: policy.DeepCopy(),
+ Mutator: status.MutatorFunc(func(obj client.Object)
client.Object {
+ cp := obj.(*v1alpha1.L4RoutePolicy).DeepCopy()
+ cp.Status = policy.Status
+ return cp
+ }),
+ })
+}
diff --git a/internal/controller/status/updater.go
b/internal/controller/status/updater.go
index e2ef06ed..ae1e1c28 100644
--- a/internal/controller/status/updater.go
+++ b/internal/controller/status/updater.go
@@ -236,6 +236,12 @@ func statusEqual(a, b any, opts ...cmp.Option) bool {
return false
}
statusA, statusB = a.Status, b.Status
+ case *v1alpha1.L4RoutePolicy:
+ b, ok := b.(*v1alpha1.L4RoutePolicy)
+ if !ok {
+ return false
+ }
+ statusA, statusB = a.Status, b.Status
case *v1alpha1.BackendTrafficPolicy:
b, ok := b.(*v1alpha1.BackendTrafficPolicy)
if !ok {
diff --git a/internal/controller/tcproute_controller.go
b/internal/controller/tcproute_controller.go
index 125a14a9..f3487547 100644
--- a/internal/controller/tcproute_controller.go
+++ b/internal/controller/tcproute_controller.go
@@ -45,6 +45,7 @@ import (
"github.com/apache/apisix-ingress-controller/internal/provider"
"github.com/apache/apisix-ingress-controller/internal/types"
"github.com/apache/apisix-ingress-controller/internal/utils"
+ pkgutils "github.com/apache/apisix-ingress-controller/pkg/utils"
)
// TCPRouteReconciler reconciles a TCPRoute object.
@@ -58,6 +59,9 @@ type TCPRouteReconciler struct { //nolint:revive
Updater status.Updater
Readier readiness.ReadinessManager
+
+ // supportsL4RoutePolicy indicates whether the L4RoutePolicy CRD is
installed.
+ supportsL4RoutePolicy bool
}
// SetupWithManager sets up the controller with the Manager.
@@ -95,6 +99,15 @@ func (r *TCPRouteReconciler) SetupWithManager(mgr
ctrl.Manager) error {
handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesForGatewayProxy),
)
+ // L4RoutePolicy is an optional CRD. Only watch it when installed so the
+ // controller still starts if the CRD has not been applied yet (e.g.
upgrades).
+ r.supportsL4RoutePolicy = pkgutils.HasAPIResource(mgr,
&v1alpha1.L4RoutePolicy{})
+ if r.supportsL4RoutePolicy {
+ bdr.Watches(&v1alpha1.L4RoutePolicy{},
+
handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesForL4RoutePolicy),
+ )
+ }
+
if GetEnableReferenceGrant() {
bdr.Watches(&v1beta1.ReferenceGrant{},
handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesForReferenceGrant),
@@ -240,6 +253,9 @@ func (r *TCPRouteReconciler) Reconcile(ctx context.Context,
req ctrl.Request) (c
r.Log.Error(err, "failed to delete tcproute",
"tcproute", tr)
return ctrl.Result{}, err
}
+ if r.supportsL4RoutePolicy {
+ updateL4RoutePolicyStatusOnDeleting(ctx,
r.Client, r.Updater, r.Log, req.NamespacedName, KindTCPRoute)
+ }
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
@@ -293,6 +309,9 @@ func (r *TCPRouteReconciler) Reconcile(ctx context.Context,
req ctrl.Request) (c
}
ProcessBackendTrafficPolicy(r.Client, r.Log, tctx)
+ if r.supportsL4RoutePolicy {
+ ProcessL4RoutePolicy(r.Client, r.Log, tctx, tr.Namespace,
tr.Name, KindTCPRoute)
+ }
tr.Status.Parents = make([]gatewayv1.RouteParentStatus, 0,
len(gateways))
for _, gateway := range gateways {
parentStatus := gatewayv1.RouteParentStatus{}
@@ -503,3 +522,28 @@ func (r *TCPRouteReconciler) listTCPRoutesByServiceRef(ctx
context.Context, obj
}
return requests
}
+
+func (r *TCPRouteReconciler) listTCPRoutesForL4RoutePolicy(ctx
context.Context, obj client.Object) []reconcile.Request {
+ policy, ok := obj.(*v1alpha1.L4RoutePolicy)
+ if !ok {
+ r.Log.Error(fmt.Errorf("unexpected object type"), "failed to
convert object to L4RoutePolicy")
+ return nil
+ }
+ requests := make([]reconcile.Request, 0, len(policy.Spec.TargetRefs))
+ seen := make(map[k8stypes.NamespacedName]struct{})
+ for _, ref := range policy.Spec.TargetRefs {
+ if string(ref.Group) != gatewayv1.GroupName || string(ref.Kind)
!= KindTCPRoute {
+ continue
+ }
+ nn := k8stypes.NamespacedName{
+ Namespace: policy.Namespace,
+ Name: string(ref.Name),
+ }
+ if _, ok := seen[nn]; ok {
+ continue
+ }
+ seen[nn] = struct{}{}
+ requests = append(requests, reconcile.Request{NamespacedName:
nn})
+ }
+ return requests
+}
diff --git a/internal/controller/tlsroute_controller.go
b/internal/controller/tlsroute_controller.go
index f5f97721..ecf46600 100644
--- a/internal/controller/tlsroute_controller.go
+++ b/internal/controller/tlsroute_controller.go
@@ -45,6 +45,7 @@ import (
"github.com/apache/apisix-ingress-controller/internal/provider"
"github.com/apache/apisix-ingress-controller/internal/types"
"github.com/apache/apisix-ingress-controller/internal/utils"
+ pkgutils "github.com/apache/apisix-ingress-controller/pkg/utils"
)
// TLSRouteReconciler reconciles a TLSRoute object.
@@ -58,6 +59,9 @@ type TLSRouteReconciler struct { //nolint:revive
Updater status.Updater
Readier readiness.ReadinessManager
+
+ // supportsL4RoutePolicy indicates whether the L4RoutePolicy CRD is
installed.
+ supportsL4RoutePolicy bool
}
// SetupWithManager sets up the controller with the Manager.
@@ -95,6 +99,15 @@ func (r *TLSRouteReconciler) SetupWithManager(mgr
ctrl.Manager) error {
handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesForGatewayProxy),
)
+ // L4RoutePolicy is an optional CRD. Only watch it when installed so the
+ // controller still starts if the CRD has not been applied yet (e.g.
upgrades).
+ r.supportsL4RoutePolicy = pkgutils.HasAPIResource(mgr,
&v1alpha1.L4RoutePolicy{})
+ if r.supportsL4RoutePolicy {
+ bdr.Watches(&v1alpha1.L4RoutePolicy{},
+
handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesForL4RoutePolicy),
+ )
+ }
+
if GetEnableReferenceGrant() {
bdr.Watches(&v1beta1.ReferenceGrant{},
handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesForReferenceGrant),
@@ -240,6 +253,9 @@ func (r *TLSRouteReconciler) Reconcile(ctx context.Context,
req ctrl.Request) (c
r.Log.Error(err, "failed to delete tlsroute",
"tlsroute", tr)
return ctrl.Result{}, err
}
+ if r.supportsL4RoutePolicy {
+ updateL4RoutePolicyStatusOnDeleting(ctx,
r.Client, r.Updater, r.Log, req.NamespacedName, types.KindTLSRoute)
+ }
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
@@ -293,6 +309,9 @@ func (r *TLSRouteReconciler) Reconcile(ctx context.Context,
req ctrl.Request) (c
}
ProcessBackendTrafficPolicy(r.Client, r.Log, tctx)
+ if r.supportsL4RoutePolicy {
+ ProcessL4RoutePolicy(r.Client, r.Log, tctx, tr.Namespace,
tr.Name, types.KindTLSRoute)
+ }
tr.Status.Parents = make([]gatewayv1.RouteParentStatus, 0,
len(gateways))
for _, gateway := range gateways {
parentStatus := gatewayv1.RouteParentStatus{}
@@ -503,3 +522,28 @@ func (r *TLSRouteReconciler) listTLSRoutesByServiceRef(ctx
context.Context, obj
}
return requests
}
+
+func (r *TLSRouteReconciler) listTLSRoutesForL4RoutePolicy(ctx
context.Context, obj client.Object) []reconcile.Request {
+ policy, ok := obj.(*v1alpha1.L4RoutePolicy)
+ if !ok {
+ r.Log.Error(fmt.Errorf("unexpected object type"), "failed to
convert object to L4RoutePolicy")
+ return nil
+ }
+ requests := make([]reconcile.Request, 0, len(policy.Spec.TargetRefs))
+ seen := make(map[k8stypes.NamespacedName]struct{})
+ for _, ref := range policy.Spec.TargetRefs {
+ if string(ref.Group) != gatewayv1.GroupName || string(ref.Kind)
!= types.KindTLSRoute {
+ continue
+ }
+ nn := k8stypes.NamespacedName{
+ Namespace: policy.Namespace,
+ Name: string(ref.Name),
+ }
+ if _, ok := seen[nn]; ok {
+ continue
+ }
+ seen[nn] = struct{}{}
+ requests = append(requests, reconcile.Request{NamespacedName:
nn})
+ }
+ return requests
+}
diff --git a/internal/controller/udproute_controller.go
b/internal/controller/udproute_controller.go
index 2a4a7a4a..070cee76 100644
--- a/internal/controller/udproute_controller.go
+++ b/internal/controller/udproute_controller.go
@@ -45,6 +45,7 @@ import (
"github.com/apache/apisix-ingress-controller/internal/provider"
"github.com/apache/apisix-ingress-controller/internal/types"
"github.com/apache/apisix-ingress-controller/internal/utils"
+ pkgutils "github.com/apache/apisix-ingress-controller/pkg/utils"
)
// UDPRouteReconciler reconciles a UDPRoute object.
@@ -58,6 +59,9 @@ type UDPRouteReconciler struct { //nolint:revive
Updater status.Updater
Readier readiness.ReadinessManager
+
+ // supportsL4RoutePolicy indicates whether the L4RoutePolicy CRD is
installed.
+ supportsL4RoutePolicy bool
}
// SetupWithManager sets up the controller with the Manager.
@@ -95,6 +99,15 @@ func (r *UDPRouteReconciler) SetupWithManager(mgr
ctrl.Manager) error {
handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesForGatewayProxy),
)
+ // L4RoutePolicy is an optional CRD. Only watch it when installed so the
+ // controller still starts if the CRD has not been applied yet (e.g.
upgrades).
+ r.supportsL4RoutePolicy = pkgutils.HasAPIResource(mgr,
&v1alpha1.L4RoutePolicy{})
+ if r.supportsL4RoutePolicy {
+ bdr.Watches(&v1alpha1.L4RoutePolicy{},
+
handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesForL4RoutePolicy),
+ )
+ }
+
if GetEnableReferenceGrant() {
bdr.Watches(&v1beta1.ReferenceGrant{},
handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesForReferenceGrant),
@@ -240,6 +253,9 @@ func (r *UDPRouteReconciler) Reconcile(ctx context.Context,
req ctrl.Request) (c
r.Log.Error(err, "failed to delete udproute",
"udproute", tr)
return ctrl.Result{}, err
}
+ if r.supportsL4RoutePolicy {
+ updateL4RoutePolicyStatusOnDeleting(ctx,
r.Client, r.Updater, r.Log, req.NamespacedName, KindUDPRoute)
+ }
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
@@ -293,6 +309,9 @@ func (r *UDPRouteReconciler) Reconcile(ctx context.Context,
req ctrl.Request) (c
}
ProcessBackendTrafficPolicy(r.Client, r.Log, tctx)
+ if r.supportsL4RoutePolicy {
+ ProcessL4RoutePolicy(r.Client, r.Log, tctx, tr.Namespace,
tr.Name, KindUDPRoute)
+ }
tr.Status.Parents = make([]gatewayv1.RouteParentStatus, 0,
len(gateways))
for _, gateway := range gateways {
parentStatus := gatewayv1.RouteParentStatus{}
@@ -503,3 +522,28 @@ func (r *UDPRouteReconciler) listUDPRoutesByServiceRef(ctx
context.Context, obj
}
return requests
}
+
+func (r *UDPRouteReconciler) listUDPRoutesForL4RoutePolicy(ctx
context.Context, obj client.Object) []reconcile.Request {
+ policy, ok := obj.(*v1alpha1.L4RoutePolicy)
+ if !ok {
+ r.Log.Error(fmt.Errorf("unexpected object type"), "failed to
convert object to L4RoutePolicy")
+ return nil
+ }
+ requests := make([]reconcile.Request, 0, len(policy.Spec.TargetRefs))
+ seen := make(map[k8stypes.NamespacedName]struct{})
+ for _, ref := range policy.Spec.TargetRefs {
+ if string(ref.Group) != gatewayv1.GroupName || string(ref.Kind)
!= KindUDPRoute {
+ continue
+ }
+ nn := k8stypes.NamespacedName{
+ Namespace: policy.Namespace,
+ Name: string(ref.Name),
+ }
+ if _, ok := seen[nn]; ok {
+ continue
+ }
+ seen[nn] = struct{}{}
+ requests = append(requests, reconcile.Request{NamespacedName:
nn})
+ }
+ return requests
+}
diff --git a/internal/manager/controllers.go b/internal/manager/controllers.go
index fd6e5782..a8b9e055 100644
--- a/internal/manager/controllers.go
+++ b/internal/manager/controllers.go
@@ -77,6 +77,8 @@ import (
//
+kubebuilder:rbac:groups=apisix.apache.org,resources=backendtrafficpolicies/status,verbs=get;update
//
+kubebuilder:rbac:groups=apisix.apache.org,resources=httproutepolicies,verbs=get;list;watch
//
+kubebuilder:rbac:groups=apisix.apache.org,resources=httproutepolicies/status,verbs=get;update
+//
+kubebuilder:rbac:groups=apisix.apache.org,resources=l4routepolicies,verbs=get;list;watch
+//
+kubebuilder:rbac:groups=apisix.apache.org,resources=l4routepolicies/status,verbs=get;update
// GatewayAPI
//
+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses,verbs=get;list;watch;update
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 92ada510..92f36a87 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -54,6 +54,7 @@ type TranslateContext struct {
ApisixPluginConfigs
map[k8stypes.NamespacedName]*apiv2.ApisixPluginConfig
Services map[k8stypes.NamespacedName]*corev1.Service
BackendTrafficPolicies
map[k8stypes.NamespacedName]*v1alpha1.BackendTrafficPolicy
+ L4RoutePolicies
map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy
Upstreams map[k8stypes.NamespacedName]*apiv2.ApisixUpstream
GatewayProxies
map[types.NamespacedNameKind]v1alpha1.GatewayProxy
ResourceParentRefs
map[types.NamespacedNameKind][]types.NamespacedNameKind
@@ -73,6 +74,7 @@ func NewDefaultTranslateContext(ctx context.Context)
*TranslateContext {
ApisixPluginConfigs:
make(map[k8stypes.NamespacedName]*apiv2.ApisixPluginConfig),
Services:
make(map[k8stypes.NamespacedName]*corev1.Service),
BackendTrafficPolicies:
make(map[k8stypes.NamespacedName]*v1alpha1.BackendTrafficPolicy),
+ L4RoutePolicies:
make(map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy),
Upstreams:
make(map[k8stypes.NamespacedName]*apiv2.ApisixUpstream),
GatewayProxies:
make(map[types.NamespacedNameKind]v1alpha1.GatewayProxy),
ResourceParentRefs:
make(map[types.NamespacedNameKind][]types.NamespacedNameKind),
diff --git a/internal/types/k8s.go b/internal/types/k8s.go
index 2aa317e4..fa2e05f6 100644
--- a/internal/types/k8s.go
+++ b/internal/types/k8s.go
@@ -55,6 +55,7 @@ const (
KindApisixTls = "ApisixTls"
KindApisixConsumer = "ApisixConsumer"
KindHTTPRoutePolicy = "HTTPRoutePolicy"
+ KindL4RoutePolicy = "L4RoutePolicy"
KindBackendTrafficPolicy = "BackendTrafficPolicy"
KindConsumer = "Consumer"
KindPluginConfig = "PluginConfig"
@@ -108,6 +109,8 @@ func KindOf(obj any) string {
return KindApisixConsumer
case *v1alpha1.HTTPRoutePolicy:
return KindHTTPRoutePolicy
+ case *v1alpha1.L4RoutePolicy:
+ return KindL4RoutePolicy
case *v1alpha1.BackendTrafficPolicy:
return KindBackendTrafficPolicy
case *v1alpha1.GatewayProxy:
@@ -176,6 +179,12 @@ func GvkOf(obj any) schema.GroupVersionKind {
Version: "v1alpha1",
Kind: KindHTTPRoutePolicy,
}
+ case *v1alpha1.L4RoutePolicy:
+ return schema.GroupVersionKind{
+ Group: "apisix.apache.org",
+ Version: "v1alpha1",
+ Kind: KindL4RoutePolicy,
+ }
case *v1alpha1.BackendTrafficPolicy:
return schema.GroupVersionKind{
Group: "apisix.apache.org",
diff --git a/test/e2e/framework/assertion.go b/test/e2e/framework/assertion.go
index fe069b4e..7fcb5f50 100644
--- a/test/e2e/framework/assertion.go
+++ b/test/e2e/framework/assertion.go
@@ -102,6 +102,38 @@ func PollUntilHTTPRoutePolicyHaveStatus(cli client.Client,
timeout time.Duration
return genericPollResource(new(v1alpha1.HTTPRoutePolicy), cli, timeout,
hrpNN, f)
}
+func L4RoutePolicyMustHaveCondition(t testing.TestingT, client client.Client,
timeout time.Duration, refNN, policyNN types.NamespacedName,
+ condition metav1.Condition) {
+ err := PollUntilL4RoutePolicyHaveStatus(client, timeout, policyNN,
func(policy *v1alpha1.L4RoutePolicy) bool {
+ for _, ancestor := range policy.Status.Ancestors {
+ if err :=
kubernetes.ConditionsHaveLatestObservedGeneration(policy, ancestor.Conditions);
err != nil {
+ log.Printf("L4RoutePolicy %s (ancestorRef=%v)
%v", policyNN, parentRefToString(ancestor.AncestorRef), err)
+ return false
+ }
+
+ if ancestor.AncestorRef.Name ==
gatewayv1.ObjectName(refNN.Name) &&
+ (refNN.Namespace == "" ||
(ancestor.AncestorRef.Namespace != nil &&
string(*ancestor.AncestorRef.Namespace) == refNN.Namespace)) {
+ if findConditionInList(ancestor.Conditions,
condition) {
+ log.Printf("found condition %v in list
%v for %s reference %s", condition, ancestor.Conditions, policyNN, refNN)
+ return true
+ }
+ log.Printf("NOT FOUND condition %v in %v for %s
reference %s", condition, ancestor.Conditions, policyNN, refNN)
+ }
+ }
+ return false
+ })
+
+ require.NoError(t, err, "error waiting for L4RoutePolicy %s status to
have a Condition matching %+v", policyNN, condition)
+}
+
+func PollUntilL4RoutePolicyHaveStatus(cli client.Client, timeout
time.Duration, policyNN types.NamespacedName,
+ f func(policy *v1alpha1.L4RoutePolicy) bool) error {
+ if err := v1alpha1.AddToScheme(cli.Scheme()); err != nil {
+ return err
+ }
+ return genericPollResource(new(v1alpha1.L4RoutePolicy), cli, timeout,
policyNN, f)
+}
+
func APIv2MustHaveCondition(t testing.TestingT, cli client.Client, timeout
time.Duration, nn types.NamespacedName, obj client.Object, cond
metav1.Condition) {
f := func(object client.Object) bool {
value := reflect.Indirect(reflect.ValueOf(object))
diff --git a/test/e2e/framework/manifests/ingress.yaml
b/test/e2e/framework/manifests/ingress.yaml
index 15fdf630..0c690392 100644
--- a/test/e2e/framework/manifests/ingress.yaml
+++ b/test/e2e/framework/manifests/ingress.yaml
@@ -101,6 +101,7 @@ rules:
- consumers
- gatewayproxies
- httproutepolicies
+ - l4routepolicies
- pluginconfigs
verbs:
- get
@@ -118,6 +119,7 @@ rules:
- backendtrafficpolicies/status
- consumers/status
- httproutepolicies/status
+ - l4routepolicies/status
verbs:
- get
- update
diff --git a/test/e2e/gatewayapi/tcproute.go b/test/e2e/gatewayapi/tcproute.go
index b6de0a42..9c292395 100644
--- a/test/e2e/gatewayapi/tcproute.go
+++ b/test/e2e/gatewayapi/tcproute.go
@@ -23,6 +23,9 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
"github.com/apache/apisix-ingress-controller/test/e2e/scaffold"
)
@@ -108,4 +111,95 @@ spec:
s.HTTPOverTCPConnectAssert(false, time.Minute*3)
})
})
+
+ Context("TCPRoute With L4RoutePolicy", func() {
+ var tcpGateway = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+ name: %s
+spec:
+ gatewayClassName: %s
+ listeners:
+ - name: tcp
+ protocol: TCP
+ port: 80
+ allowedRoutes:
+ kinds:
+ - kind: TCPRoute
+ infrastructure:
+ parametersRef:
+ group: apisix.apache.org
+ kind: GatewayProxy
+ name: apisix-proxy-config
+`
+
+ var tcpRoute = `
+apiVersion: gateway.networking.k8s.io/v1alpha2
+kind: TCPRoute
+metadata:
+ name: tcp-l4policy
+spec:
+ parentRefs:
+ - name: %s
+ sectionName: tcp
+ rules:
+ - backendRefs:
+ - name: httpbin-service-e2e-test
+ port: 80
+`
+
+ // ip-restriction with blacklist covering all IPv4 addresses
blocks all TCP connections.
+ var l4RoutePolicyBlockAll = `
+apiVersion: apisix.apache.org/v1alpha1
+kind: L4RoutePolicy
+metadata:
+ name: tcp-block-all
+spec:
+ targetRefs:
+ - group: gateway.networking.k8s.io
+ kind: TCPRoute
+ name: tcp-l4policy
+ plugins:
+ - name: ip-restriction
+ config:
+ blacklist:
+ - "0.0.0.0/0"
+`
+
+ BeforeEach(func() {
+
Expect(s.CreateResourceFromString(s.GetGatewayProxySpec())).NotTo(HaveOccurred(),
"creating GatewayProxy")
+
Expect(s.CreateResourceFromString(s.GetGatewayClassYaml())).NotTo(HaveOccurred(),
"creating GatewayClass")
+
Expect(s.CreateResourceFromString(fmt.Sprintf(tcpGateway, s.Namespace(),
s.Namespace()))).
+ NotTo(HaveOccurred(), "creating Gateway")
+ })
+
+ It("L4RoutePolicy blocks traffic via ip-restriction plugin",
func() {
+ By("creating TCPRoute")
+ s.ResourceApplied("TCPRoute", "tcp-l4policy",
fmt.Sprintf(tcpRoute, s.Namespace()), 1)
+
+ By("verifying TCP traffic works before applying
L4RoutePolicy")
+ s.HTTPOverTCPConnectAssert(true, time.Minute*3)
+
+ By("applying L4RoutePolicy with ip-restriction
blacklist")
+ s.ApplyL4RoutePolicy(
+ types.NamespacedName{Name: s.Namespace()},
+ types.NamespacedName{Namespace: s.Namespace(),
Name: "tcp-block-all"},
+ l4RoutePolicyBlockAll,
+ metav1.Condition{
+ Type:
string(gatewayv1alpha2.PolicyConditionAccepted),
+ Status: metav1.ConditionTrue,
+ },
+ )
+
+ By("verifying TCP traffic is blocked by the
L4RoutePolicy")
+ s.HTTPOverTCPConnectAssert(false, time.Minute*3)
+
+ By("deleting L4RoutePolicy")
+ Expect(s.DeleteResource("L4RoutePolicy",
"tcp-block-all")).NotTo(HaveOccurred(), "deleting L4RoutePolicy")
+
+ By("verifying TCP traffic recovers after L4RoutePolicy
deletion")
+ s.HTTPOverTCPConnectAssert(true, time.Minute*3)
+ })
+ })
})
diff --git a/test/e2e/scaffold/k8s.go b/test/e2e/scaffold/k8s.go
index 30fab2fd..db461150 100644
--- a/test/e2e/scaffold/k8s.go
+++ b/test/e2e/scaffold/k8s.go
@@ -294,6 +294,22 @@ func (s *Scaffold) RunCurlFromK8s(args ...string) (string,
error) {
return s.RunKubectlAndGetOutput(kubectlArgs...)
}
+func (s *Scaffold) ApplyL4RoutePolicy(refNN, policyNN types.NamespacedName,
spec string, conditions ...metav1.Condition) {
+ err := s.CreateResourceFromString(spec)
+ Expect(err).NotTo(HaveOccurred(), "creating L4RoutePolicy %s", policyNN)
+ if len(conditions) == 0 {
+ conditions = []metav1.Condition{
+ {
+ Type:
string(v1alpha2.PolicyConditionAccepted),
+ Status: metav1.ConditionTrue,
+ },
+ }
+ }
+ for _, condition := range conditions {
+ framework.L4RoutePolicyMustHaveCondition(s.GinkgoT,
s.K8sClient, 8*time.Second, refNN, policyNN, condition)
+ }
+}
+
func (s *Scaffold) GetGatewayProxySpec() string {
var gatewayProxyYaml = `
apiVersion: apisix.apache.org/v1alpha1