diff --git a/api/v1alpha1/l4routepolicy_types.go b/api/v1alpha1/l4routepolicy_types.go new file mode 100644 index 00000000..695d1982 --- /dev/null +++ b/api/v1alpha1/l4routepolicy_types.go @@ -0,0 +1,68 @@ +// 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" + 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 f7b5383c..1c8fc80a 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -617,6 +617,94 @@ func (in *HTTPRoutePolicySpec) DeepCopy() *HTTPRoutePolicySpec { 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/api/v2/zz_generated.deepcopy.go b/api/v2/zz_generated.deepcopy.go index 8e659cd9..a4906250 100644 --- a/api/v2/zz_generated.deepcopy.go +++ b/api/v2/zz_generated.deepcopy.go @@ -1,19 +1,20 @@ //go:build !ignore_autogenerated -// 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. +/* +Copyright 2024. + +Licensed 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. +*/ // Code generated by controller-gen. DO NOT EDIT. 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..43a966e9 --- /dev/null +++ b/config/crd/bases/apisix.apache.org_l4routepolicies.yaml @@ -0,0 +1,427 @@ +--- +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') + 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. + + + 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. + + + 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. + + + 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. + + + 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/rbac/role.yaml b/config/rbac/role.yaml index 07d8175b..ecf9372e 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -36,6 +36,7 @@ rules: - consumers - gatewayproxies - httproutepolicies + - l4routepolicies - pluginconfigs verbs: - get @@ -53,6 +54,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 44b29323..6f658437 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. + + + +| 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 @@ -356,6 +375,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 @@ -388,6 +423,7 @@ _Appears in:_ _Appears in:_ - [ConsumerSpec](#consumerspec) +- [L4RoutePolicySpec](#l4routepolicyspec) - [PluginConfigSpec](#pluginconfigspec) #### PluginConfigSpec diff --git a/internal/adc/translator/apisixconsumer_test.go b/internal/adc/translator/apisixconsumer_test.go index e6a1ee4a..ff42eef7 100644 --- a/internal/adc/translator/apisixconsumer_test.go +++ b/internal/adc/translator/apisixconsumer_test.go @@ -49,7 +49,7 @@ func TestTranslateApisixConsumer_UsesMetadataLabelsWithoutOverwritingControllerL }, }, Spec: apiv2.ApisixConsumerSpec{ - AuthParameter: apiv2.ApisixConsumerAuthParameter{ + AuthParameter: &apiv2.ApisixConsumerAuthParameter{ BasicAuth: &apiv2.ApisixConsumerBasicAuth{ Value: &apiv2.ApisixConsumerBasicAuthValue{ Username: "demo", diff --git a/internal/adc/translator/l4route_test.go b/internal/adc/translator/l4route_test.go new file mode 100644 index 00000000..904b918a --- /dev/null +++ b/internal/adc/translator/l4route_test.go @@ -0,0 +1,264 @@ +// 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) + + plugins := result.Services[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) + + plugins := result.Services[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 := result.Services[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) + } + // Plugins are on the service, not duplicated per stream route + assert.Len(t, result.Services, 1, "plugins should be on service level, not duplicated") + } + }) + } +} diff --git a/internal/adc/translator/l4routepolicy_test.go b/internal/adc/translator/l4routepolicy_test.go new file mode 100644 index 00000000..180106e4 --- /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 41706964..05ca8115 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" @@ -80,3 +83,47 @@ func (t *Translator) attachBackendTrafficPolicyToUpstream(policy *v1alpha1.Backe upstream.Key = policy.Spec.LoadBalancer.Key } } + +// 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 + } + 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 + } + } + plugins[plugin.Name] = cfg + } +} diff --git a/internal/adc/translator/tcproute.go b/internal/adc/translator/tcproute.go index fc9c0b13..dc9ec2e1 100644 --- a/internal/adc/translator/tcproute.go +++ b/internal/adc/translator/tcproute.go @@ -157,6 +157,12 @@ func (t *Translator) TranslateTCPRoute(tctx *provider.TranslateContext, tcpRoute streamRoute.Labels = labels // TODO: support remote_addr, server_addr, sni, server_port service.StreamRoutes = append(service.StreamRoutes, streamRoute) + + if service.Plugins == nil { + service.Plugins = make(adctypes.Plugins) + } + t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies, tcpRoute.Namespace, tcpRoute.Name, "TCPRoute", service.Plugins) + 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..b43b9835 100644 --- a/internal/adc/translator/tlsroute.go +++ b/internal/adc/translator/tlsroute.go @@ -153,6 +153,12 @@ func (t *Translator) TranslateTLSRoute(tctx *provider.TranslateContext, tlsRoute streamRoute.Labels = labels service.StreamRoutes = append(service.StreamRoutes, streamRoute) } + + if service.Plugins == nil { + service.Plugins = make(adctypes.Plugins) + } + t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies, tlsRoute.Namespace, tlsRoute.Name, "TLSRoute", service.Plugins) + 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..a899a648 100644 --- a/internal/adc/translator/udproute.go +++ b/internal/adc/translator/udproute.go @@ -146,6 +146,12 @@ func (t *Translator) TranslateUDPRoute(tctx *provider.TranslateContext, udpRoute streamRoute.Labels = labels // TODO: support remote_addr, server_addr, sni, server_port service.StreamRoutes = append(service.StreamRoutes, streamRoute) + + if service.Plugins == nil { + service.Plugins = make(adctypes.Plugins) + } + t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies, udpRoute.Namespace, udpRoute.Name, "UDPRoute", service.Plugins) + result.Services = append(result.Services, service) } return result, nil diff --git a/internal/controller/indexer/indexer.go b/internal/controller/indexer/indexer.go index 5d4b4998..61a64c5d 100644 --- a/internal/controller/indexer/indexer.go +++ b/internal/controller/indexer/indexer.go @@ -89,6 +89,7 @@ func SetupIndexer(mgr ctrl.Manager) error { &networkingv1beta1.IngressClass{}: setupIngressClassV1beta1Indexer, &v1alpha1.BackendTrafficPolicy{}: setupBackendTrafficPolicyIndexer, &v1alpha1.HTTPRoutePolicy{}: setHTTPRoutePolicyIndexer, + &v1alpha1.L4RoutePolicy{}: setupL4RoutePolicyIndexer, } { if utils.HasAPIResource(mgr, resource) { if err := setup(mgr); err != nil { @@ -518,6 +519,18 @@ func IngressClassV1beta1IndexFunc(rawObj client.Object) []string { return []string{controllerName} } +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 == "" { @@ -884,6 +897,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..34cb1dac 100644 --- a/internal/controller/policies.go +++ b/internal/controller/policies.go @@ -18,7 +18,9 @@ package controller import ( + "context" "fmt" + "sort" "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/api/meta" @@ -224,3 +226,105 @@ func parentRefValueEqual(a, b gatewayv1.ParentReference) bool { ptr.Equal(a.Namespace, b.Namespace) && a.Name == b.Name } + +// 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(context.Background(), &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 + } + + // 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 { + policyCopy := policy.DeepCopy() + tctx.StatusUpdaters = append(tctx.StatusUpdaters, status.Update{ + NamespacedName: utils.NamespacedName(policyCopy), + Resource: policyCopy, + Mutator: status.MutatorFunc(func(obj client.Object) client.Object { + cp := obj.(*v1alpha1.L4RoutePolicy).DeepCopy() + cp.Status = policyCopy.Status + return cp + }), + }) + } + } +} + +// updateL4RoutePolicyStatusOnDeleting clears ancestor status entries for L4RoutePolicy +// resources that target the deleted route. +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 _, policy := range list.Items { + updateL4RoutePolicyDeleteAncestors(updater, policy) + } +} + +func updateL4RoutePolicyDeleteAncestors(updater status.Updater, policy v1alpha1.L4RoutePolicy) { + if len(policy.Status.Ancestors) == 0 { + return + } + policy.Status.Ancestors = nil + 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/tcproute_controller.go b/internal/controller/tcproute_controller.go index 125a14a9..70bc16d9 100644 --- a/internal/controller/tcproute_controller.go +++ b/internal/controller/tcproute_controller.go @@ -93,6 +93,9 @@ func (r *TCPRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { ). Watches(&v1alpha1.GatewayProxy{}, handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesForGatewayProxy), + ). + Watches(&v1alpha1.L4RoutePolicy{}, + handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesForL4RoutePolicy), ) if GetEnableReferenceGrant() { @@ -240,6 +243,7 @@ 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 } + updateL4RoutePolicyStatusOnDeleting(ctx, r.Client, r.Updater, r.Log, req.NamespacedName, KindTCPRoute) return ctrl.Result{}, nil } return ctrl.Result{}, err @@ -293,6 +297,7 @@ func (r *TCPRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c } ProcessBackendTrafficPolicy(r.Client, r.Log, tctx) + 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 +508,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.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..779f70c2 100644 --- a/internal/controller/tlsroute_controller.go +++ b/internal/controller/tlsroute_controller.go @@ -93,6 +93,9 @@ func (r *TLSRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { ). Watches(&v1alpha1.GatewayProxy{}, handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesForGatewayProxy), + ). + Watches(&v1alpha1.L4RoutePolicy{}, + handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesForL4RoutePolicy), ) if GetEnableReferenceGrant() { @@ -240,6 +243,7 @@ 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 } + updateL4RoutePolicyStatusOnDeleting(ctx, r.Client, r.Updater, r.Log, req.NamespacedName, types.KindTLSRoute) return ctrl.Result{}, nil } return ctrl.Result{}, err @@ -293,6 +297,7 @@ func (r *TLSRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c } ProcessBackendTrafficPolicy(r.Client, r.Log, tctx) + 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 +508,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.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..a9cf1424 100644 --- a/internal/controller/udproute_controller.go +++ b/internal/controller/udproute_controller.go @@ -93,6 +93,9 @@ func (r *UDPRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { ). Watches(&v1alpha1.GatewayProxy{}, handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesForGatewayProxy), + ). + Watches(&v1alpha1.L4RoutePolicy{}, + handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesForL4RoutePolicy), ) if GetEnableReferenceGrant() { @@ -240,6 +243,7 @@ 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 } + updateL4RoutePolicyStatusOnDeleting(ctx, r.Client, r.Updater, r.Log, req.NamespacedName, KindUDPRoute) return ctrl.Result{}, nil } return ctrl.Result{}, err @@ -293,6 +297,7 @@ func (r *UDPRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c } ProcessBackendTrafficPolicy(r.Client, r.Log, tctx) + 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 +508,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.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 c4db6f77..f8e70890 100644 --- a/internal/manager/controllers.go +++ b/internal/manager/controllers.go @@ -79,6 +79,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/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/gatewayapi/tcproute.go b/test/e2e/gatewayapi/tcproute.go index 7ed01449..c8ac7ba7 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" ) @@ -105,4 +108,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 2694832a..bb5be9fd 100644 --- a/test/e2e/scaffold/k8s.go +++ b/test/e2e/scaffold/k8s.go @@ -292,6 +292,22 @@ func (s *Scaffold) ApplyHTTPRoutePolicy(refNN, hrpNN types.NamespacedName, spec } } +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