From 0017200655df272408fa26fcc83136addefb8b8e Mon Sep 17 00:00:00 2001 From: rongxin Date: Thu, 14 May 2026 12:48:05 +0800 Subject: [PATCH 01/16] feat: add L4RoutePolicy CRD for attaching stream plugins to Gateway API L4 routes Add a new L4RoutePolicy custom resource that enables attaching APISIX stream plugins to Gateway API L4 routes (TCPRoute, UDPRoute, TLSRoute). This follows the Gateway API Policy Attachment pattern (GEP-713) consistent with the existing BackendTrafficPolicy and HTTPRoutePolicy CRDs. Changes: - Add L4RoutePolicy CRD type (api/v1alpha1/l4routepolicy_types.go) - targetRefs support TCPRoute, UDPRoute, TLSRoute (validated via CEL rule) - plugins list reuses the existing Plugin type (name + config) - LocalPolicyTargetReferenceWithSectionName for future per-rule targeting - Add DeepCopy methods in zz_generated.deepcopy.go - Add L4RoutePolicies to TranslateContext in provider.go - Register L4RoutePolicy indexer (by group+kind+namespace+name) in indexer.go - Add ProcessL4RoutePolicy in policies.go with deterministic conflict resolution (oldest creationTimestamp wins; tie-break by namespace/name; losers get Accepted=False, Reason=Conflicted) - Add updateL4RoutePolicyStatusOnDeleting for route deletion cleanup - Add AttachL4RoutePolicyPlugins translator helper; plugins are attached at the service level (one service per L4 rule) to avoid duplication in TLS multi-SNI - Wire up TCPRoute, UDPRoute, TLSRoute controllers: - Watch L4RoutePolicy and enqueue affected routes - Call ProcessL4RoutePolicy during reconcile - Clear policy ancestor status on route deletion - Add RBAC markers for l4routepolicies resources - Add unit tests for AttachL4RoutePolicyPlugins Fixes: https://github.com/api7/api7-ingress-controller/issues/403 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/v1alpha1/l4routepolicy_types.go | 68 +++++++++ api/v1alpha1/zz_generated.deepcopy.go | 88 +++++++++++ internal/adc/translator/l4routepolicy_test.go | 143 ++++++++++++++++++ internal/adc/translator/policies.go | 47 ++++++ internal/adc/translator/tcproute.go | 6 + internal/adc/translator/tlsroute.go | 6 + internal/adc/translator/udproute.go | 6 + internal/controller/indexer/indexer.go | 28 ++++ internal/controller/policies.go | 104 +++++++++++++ internal/controller/tcproute_controller.go | 30 ++++ internal/controller/tlsroute_controller.go | 30 ++++ internal/controller/udproute_controller.go | 30 ++++ internal/manager/controllers.go | 2 + internal/provider/provider.go | 2 + 14 files changed, 590 insertions(+) create mode 100644 api/v1alpha1/l4routepolicy_types.go create mode 100644 internal/adc/translator/l4routepolicy_test.go 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 473a7b28..3413663c 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -717,6 +717,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/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 f948f238..d26199eb 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,47 @@ 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 + } + 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..251e38ba 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,21 @@ 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..ce2b4919 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 + } + var requests []reconcile.Request + 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..66726a10 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 + } + var requests []reconcile.Request + 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..5b8a722f 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 + } + var requests []reconcile.Request + 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), From 030f36abf61488389e246f67205257aba1788337 Mon Sep 17 00:00:00 2001 From: rongxin Date: Fri, 15 May 2026 08:38:53 +0800 Subject: [PATCH 02/16] chore: fix CI, generate CRD, add L4 route translator tests - Generate config/crd/bases/apisix.apache.org_l4routepolicies.yaml via make manifests - Regenerate config/rbac/role.yaml with l4routepolicies RBAC entries - Regenerate api/v2/zz_generated.deepcopy.go via make generate - Fix pre-existing go vet error in apisixconsumer_test.go: AuthParameter is a pointer field - Fix import ordering in indexer.go (goimports-reviser) - Fix prealloc lint warnings in tcproute/udproute/tlsroute controllers - Add translator-level tests: TestTranslateTCPRouteWithL4RoutePolicy, TestTranslateUDPRouteWithL4RoutePolicy, TestTranslateTLSRouteWithL4RoutePolicy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/v2/zz_generated.deepcopy.go | 29 +- .../apisix.apache.org_l4routepolicies.yaml | 427 ++++++++++++++++++ config/rbac/role.yaml | 2 + internal/adc/translator/l4route_test.go | 264 +++++++++++ internal/controller/indexer/indexer.go | 1 - internal/controller/tcproute_controller.go | 2 +- internal/controller/tlsroute_controller.go | 2 +- internal/controller/udproute_controller.go | 2 +- 8 files changed, 711 insertions(+), 18 deletions(-) create mode 100644 config/crd/bases/apisix.apache.org_l4routepolicies.yaml create mode 100644 internal/adc/translator/l4route_test.go 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/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/controller/indexer/indexer.go b/internal/controller/indexer/indexer.go index 251e38ba..61a64c5d 100644 --- a/internal/controller/indexer/indexer.go +++ b/internal/controller/indexer/indexer.go @@ -911,7 +911,6 @@ func L4RoutePolicyIndexFunc(rawObj client.Object) []string { 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/tcproute_controller.go b/internal/controller/tcproute_controller.go index ce2b4919..70bc16d9 100644 --- a/internal/controller/tcproute_controller.go +++ b/internal/controller/tcproute_controller.go @@ -515,7 +515,7 @@ func (r *TCPRouteReconciler) listTCPRoutesForL4RoutePolicy(ctx context.Context, r.Log.Error(fmt.Errorf("unexpected object type"), "failed to convert object to L4RoutePolicy") return nil } - var requests []reconcile.Request + 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 { diff --git a/internal/controller/tlsroute_controller.go b/internal/controller/tlsroute_controller.go index 66726a10..779f70c2 100644 --- a/internal/controller/tlsroute_controller.go +++ b/internal/controller/tlsroute_controller.go @@ -515,7 +515,7 @@ func (r *TLSRouteReconciler) listTLSRoutesForL4RoutePolicy(ctx context.Context, r.Log.Error(fmt.Errorf("unexpected object type"), "failed to convert object to L4RoutePolicy") return nil } - var requests []reconcile.Request + 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 { diff --git a/internal/controller/udproute_controller.go b/internal/controller/udproute_controller.go index 5b8a722f..a9cf1424 100644 --- a/internal/controller/udproute_controller.go +++ b/internal/controller/udproute_controller.go @@ -515,7 +515,7 @@ func (r *UDPRouteReconciler) listUDPRoutesForL4RoutePolicy(ctx context.Context, r.Log.Error(fmt.Errorf("unexpected object type"), "failed to convert object to L4RoutePolicy") return nil } - var requests []reconcile.Request + 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 { From c36522a0f0a11005bf427b46be5d9f4e53c5c6d8 Mon Sep 17 00:00:00 2001 From: rongxin Date: Fri, 15 May 2026 08:48:40 +0800 Subject: [PATCH 03/16] test: add L4RoutePolicy e2e tests for TCPRoute - Add L4RoutePolicyMustHaveCondition and PollUntilL4RoutePolicyHaveStatus framework helpers (mirroring HTTPRoutePolicy pattern) - Add ApplyL4RoutePolicy scaffold helper - Add e2e test 'L4RoutePolicy blocks traffic via ip-restriction plugin': 1. Create TCPRoute, verify traffic works 2. Apply L4RoutePolicy with ip-restriction blacklist 0.0.0.0/0 3. Verify traffic is blocked 4. Delete L4RoutePolicy, verify traffic recovers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/e2e/framework/assertion.go | 32 +++++++++++ test/e2e/gatewayapi/tcproute.go | 94 +++++++++++++++++++++++++++++++++ test/e2e/scaffold/k8s.go | 16 ++++++ 3 files changed, 142 insertions(+) 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 From 6105ddee3fe61d85fbd14812120b6cf96ef5bcaa Mon Sep 17 00:00:00 2001 From: rongxin Date: Fri, 15 May 2026 08:57:18 +0800 Subject: [PATCH 04/16] docs: update CRD API reference with L4RoutePolicy Run 'make generate-crd-docs' to add L4RoutePolicy and L4RoutePolicySpec entries to the auto-generated CRD reference documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/en/latest/reference/api-reference.md | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/en/latest/reference/api-reference.md b/docs/en/latest/reference/api-reference.md index 21ba3cf9..5059f1d7 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 @@ -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 From d742959dabf15eedae5dfc76301d03f7d5802e8a Mon Sep 17 00:00:00 2001 From: rongxin Date: Thu, 11 Jun 2026 01:46:20 +0800 Subject: [PATCH 05/16] fix: register L4RoutePolicy CRD in config/crd kustomization The L4RoutePolicy CRD base manifest was generated but never added to config/crd/kustomization.yaml, so 'make install' (kustomize build) never applied it. As a result the controllers' L4RoutePolicy watch logged 'if kind is a CRD, it should be installed before calling Start' and the feature was non-functional in any kustomize-based deployment. Add the missing resource entry alongside the other policy CRDs. --- config/crd/kustomization.yaml | 1 + 1 file changed, 1 insertion(+) 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 From 146b11640238e756ddeee9eed5046359e44bd20d Mon Sep 17 00:00:00 2001 From: rongxin Date: Thu, 11 Jun 2026 03:03:58 +0800 Subject: [PATCH 06/16] fix: grant l4routepolicies RBAC in e2e controller manifest The e2e framework deploys the controller with its own hand-maintained ClusterRole in test/e2e/framework/manifests/ingress.yaml, which was not updated for the new CRD. Once the L4RoutePolicy CRD is installed, the controller's informer fails with 'cannot list resource l4routepolicies ... forbidden', preventing the L4RoutePolicy cache from syncing. Add l4routepolicies and l4routepolicies/status to the ClusterRole rules, matching config/rbac/role.yaml. --- test/e2e/framework/manifests/ingress.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/e2e/framework/manifests/ingress.yaml b/test/e2e/framework/manifests/ingress.yaml index b7838494..af36fa81 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 From 20f6527803c7a8aa93f163fe1350f79395cb349c Mon Sep 17 00:00:00 2001 From: rongxin Date: Thu, 11 Jun 2026 03:54:42 +0800 Subject: [PATCH 07/16] fix: write L4RoutePolicy ancestors status correctly Two status-update defects surfaced once the CRD and RBAC were in place and the L4RoutePolicy e2e test could run: 1. ProcessL4RoutePolicy used the same *L4RoutePolicy object for both the Update.Resource and the Mutator closure. The status updater calls client.Get into Resource, overwriting it (and thus the object the Mutator reads) with the server state, so the freshly-set ancestors were lost and the update was rejected with 'ancestors: Required value'. Use a separate DeepCopy for Resource and read the original policy in the Mutator, matching the HTTPRoutePolicy pattern. 2. The route-deletion cleanup set status.ancestors to nil, which serializes to null and is rejected by the required-field schema. Use an empty slice so it serializes to []. --- internal/controller/policies.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/controller/policies.go b/internal/controller/policies.go index 34cb1dac..afad07dc 100644 --- a/internal/controller/policies.go +++ b/internal/controller/policies.go @@ -285,13 +285,15 @@ func ProcessL4RoutePolicy( } if updated := SetAncestors(&policy.Status, tctx.RouteParentRefs, condition); updated { - policyCopy := policy.DeepCopy() + // 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(policyCopy), - Resource: policyCopy, + NamespacedName: utils.NamespacedName(&policy), + Resource: policy.DeepCopy(), Mutator: status.MutatorFunc(func(obj client.Object) client.Object { cp := obj.(*v1alpha1.L4RoutePolicy).DeepCopy() - cp.Status = policyCopy.Status + cp.Status = policy.Status return cp }), }) @@ -317,7 +319,9 @@ func updateL4RoutePolicyDeleteAncestors(updater status.Updater, policy v1alpha1. if len(policy.Status.Ancestors) == 0 { return } - policy.Status.Ancestors = nil + // status.ancestors is a required field; use an empty slice (serializes to []) + // rather than nil (serializes to null), which the CRD schema rejects. + policy.Status.Ancestors = []gatewayv1alpha2.PolicyAncestorStatus{} updater.Update(status.Update{ NamespacedName: utils.NamespacedName(&policy), Resource: policy.DeepCopy(), From 125934ffcb864bb3162f217f135578fb0c7e1034 Mon Sep 17 00:00:00 2001 From: rongxin Date: Thu, 11 Jun 2026 03:56:14 +0800 Subject: [PATCH 08/16] chore: recognize L4RoutePolicy in types.KindOf and GVK lookup Add the L4RoutePolicy cases (and KindL4RoutePolicy constant) so status update logging and GVK resolution report the correct kind instead of 'Unknown', matching the other policy types. --- internal/types/k8s.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/types/k8s.go b/internal/types/k8s.go index e865ed39..3fc34a3e 100644 --- a/internal/types/k8s.go +++ b/internal/types/k8s.go @@ -58,6 +58,7 @@ const ( KindApisixTls = "ApisixTls" KindApisixConsumer = "ApisixConsumer" KindHTTPRoutePolicy = "HTTPRoutePolicy" + KindL4RoutePolicy = "L4RoutePolicy" KindBackendTrafficPolicy = "BackendTrafficPolicy" KindConsumer = "Consumer" KindPluginConfig = "PluginConfig" @@ -111,6 +112,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: @@ -201,6 +204,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", From 4db79aa28851990dc05c437f0ee086bb9b0136e1 Mon Sep 17 00:00:00 2001 From: rongxin Date: Thu, 11 Jun 2026 10:07:08 +0800 Subject: [PATCH 09/16] fix: address review feedback on L4RoutePolicy - status/updater.go: add L4RoutePolicy case to statusEqual so unchanged statuses are not re-written on every reconcile, avoiding an update/ reconcile storm (route controllers watch L4RoutePolicy). - policies.go: use the reconcile-scoped context (tctx) for the policy List instead of context.Background(), matching ProcessBackendTrafficPolicy. - policies.go: scope route-deletion ancestor cleanup to the deleted route only. A policy may target multiple routes; recompute the remaining targets' parentRefs and drop just the ancestor entries no longer referenced (mirrors HTTPRoutePolicy updateDeleteAncestors), keeping a non-nil empty slice so the required status.ancestors serializes to []. - tcp/udp/tlsroute controllers: also match targetRefs by group in the L4RoutePolicy watch mappers to avoid enqueueing for wrong-group refs. - translator/policies.go: normalize a null plugin config to an empty object so it is not emitted as null to APISIX. --- internal/adc/translator/policies.go | 5 ++ internal/controller/policies.go | 71 +++++++++++++++++++--- internal/controller/status/updater.go | 6 ++ internal/controller/tcproute_controller.go | 2 +- internal/controller/tlsroute_controller.go | 2 +- internal/controller/udproute_controller.go | 2 +- 6 files changed, 75 insertions(+), 13 deletions(-) diff --git a/internal/adc/translator/policies.go b/internal/adc/translator/policies.go index d26199eb..662dd6bc 100644 --- a/internal/adc/translator/policies.go +++ b/internal/adc/translator/policies.go @@ -213,6 +213,11 @@ func (t *Translator) mergeL4PolicyPlugins(policy *v1alpha1.L4RoutePolicy, plugin 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/controller/policies.go b/internal/controller/policies.go index afad07dc..e3569d98 100644 --- a/internal/controller/policies.go +++ b/internal/controller/policies.go @@ -20,6 +20,7 @@ package controller import ( "context" "fmt" + "slices" "sort" "github.com/go-logr/logr" @@ -238,7 +239,7 @@ func ProcessL4RoutePolicy( ) { 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 { + 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 } @@ -301,8 +302,10 @@ func ProcessL4RoutePolicy( } } -// updateL4RoutePolicyStatusOnDeleting clears ancestor status entries for L4RoutePolicy -// resources that target the deleted route. +// 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) @@ -310,18 +313,66 @@ func updateL4RoutePolicyStatusOnDeleting(ctx context.Context, c client.Client, u 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) + 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) } } -func updateL4RoutePolicyDeleteAncestors(updater status.Updater, policy v1alpha1.L4RoutePolicy) { - if len(policy.Status.Ancestors) == 0 { +// 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; use an empty slice (serializes to []) - // rather than nil (serializes to null), which the CRD schema rejects. - policy.Status.Ancestors = []gatewayv1alpha2.PolicyAncestorStatus{} + // 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(), diff --git a/internal/controller/status/updater.go b/internal/controller/status/updater.go index d00aec76..b87e68e8 100644 --- a/internal/controller/status/updater.go +++ b/internal/controller/status/updater.go @@ -237,6 +237,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 70bc16d9..b25f0ebf 100644 --- a/internal/controller/tcproute_controller.go +++ b/internal/controller/tcproute_controller.go @@ -518,7 +518,7 @@ func (r *TCPRouteReconciler) listTCPRoutesForL4RoutePolicy(ctx context.Context, 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 { + if string(ref.Group) != gatewayv1.GroupName || string(ref.Kind) != KindTCPRoute { continue } nn := k8stypes.NamespacedName{ diff --git a/internal/controller/tlsroute_controller.go b/internal/controller/tlsroute_controller.go index 779f70c2..11ef5506 100644 --- a/internal/controller/tlsroute_controller.go +++ b/internal/controller/tlsroute_controller.go @@ -518,7 +518,7 @@ func (r *TLSRouteReconciler) listTLSRoutesForL4RoutePolicy(ctx context.Context, 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 { + if string(ref.Group) != gatewayv1.GroupName || string(ref.Kind) != types.KindTLSRoute { continue } nn := k8stypes.NamespacedName{ diff --git a/internal/controller/udproute_controller.go b/internal/controller/udproute_controller.go index a9cf1424..d3d4279b 100644 --- a/internal/controller/udproute_controller.go +++ b/internal/controller/udproute_controller.go @@ -518,7 +518,7 @@ func (r *UDPRouteReconciler) listUDPRoutesForL4RoutePolicy(ctx context.Context, 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 { + if string(ref.Group) != gatewayv1.GroupName || string(ref.Kind) != KindUDPRoute { continue } nn := k8stypes.NamespacedName{ From 805611fb5671b68e0eb7d306f4d0dd66f9f8c5f8 Mon Sep 17 00:00:00 2001 From: rongxin Date: Thu, 11 Jun 2026 11:34:23 +0800 Subject: [PATCH 10/16] fix: attach L4RoutePolicy plugins at stream_route level The plugins were attached to service.Plugins. In APISIX standalone mode the stream proxy applies plugins from the stream_route, not from the service, so an L4RoutePolicy (e.g. ip-restriction) had no effect there and the e2e 'blocks traffic' assertion timed out, while api7ee passed. Attach the plugins to each stream_route's Plugins instead (for TLS, every per-SNI stream_route carries its own copy). Update the translator unit tests to assert on stream_route plugins accordingly. --- internal/adc/translator/l4route_test.go | 19 ++++++++++++------- internal/adc/translator/tcproute.go | 9 ++++----- internal/adc/translator/tlsroute.go | 10 +++++----- internal/adc/translator/udproute.go | 9 ++++----- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/internal/adc/translator/l4route_test.go b/internal/adc/translator/l4route_test.go index 904b918a..75faff3c 100644 --- a/internal/adc/translator/l4route_test.go +++ b/internal/adc/translator/l4route_test.go @@ -93,8 +93,9 @@ func TestTranslateTCPRouteWithL4RoutePolicy(t *testing.T) { 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].Plugins + plugins := result.Services[0].StreamRoutes[0].Plugins if tt.wantNoPlugins { assert.Empty(t, plugins) } else { @@ -159,8 +160,9 @@ func TestTranslateUDPRouteWithL4RoutePolicy(t *testing.T) { 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].Plugins + plugins := result.Services[0].StreamRoutes[0].Plugins if tt.wantNoPlugins { assert.Empty(t, plugins) } else { @@ -249,15 +251,18 @@ func TestTranslateTLSRouteWithL4RoutePolicy(t *testing.T) { assert.Len(t, result.Services[0].StreamRoutes, len(tt.hostnames)) } - plugins := result.Services[0].Plugins + // 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 _, name := range tt.wantPlugins { - assert.Contains(t, plugins, name, "expected plugin %q to be attached", name) + 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) + } } - // 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/tcproute.go b/internal/adc/translator/tcproute.go index dc9ec2e1..c7f00e0e 100644 --- a/internal/adc/translator/tcproute.go +++ b/internal/adc/translator/tcproute.go @@ -156,13 +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) - 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 b43b9835..4b2ef33d 100644 --- a/internal/adc/translator/tlsroute.go +++ b/internal/adc/translator/tlsroute.go @@ -151,14 +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) } - 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 a899a648..00c90f3e 100644 --- a/internal/adc/translator/udproute.go +++ b/internal/adc/translator/udproute.go @@ -145,13 +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) - 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 From dc51e7a86f405dc6069fdc054219334f23eadea6 Mon Sep 17 00:00:00 2001 From: rongxin Date: Thu, 11 Jun 2026 13:22:46 +0800 Subject: [PATCH 11/16] fix: always emit stream_route plugins so api7ee removal is detected Attaching L4RoutePolicy plugins at the stream_route level fixed the apisix/apisix-standalone backends but broke api7ee policy deletion: after removing the policy the plugins map became empty and, with 'plugins,omitempty', the field was omitted entirely. The api7ee ADC backend treats an omitted nested field as 'unchanged' (returns total_resources=0), so the stale plugin was never removed and traffic stayed blocked. Drop omitempty on StreamRoute.Plugins so an empty map serializes as 'plugins: {}', which the ADC diff detects as a real change and clears. NewDefaultStreamRoute now initializes Plugins to a non-nil empty map so the required field never serializes as null. This makes the full L4RoutePolicy lifecycle (attach + remove) work on both the open-source APISIX and api7ee backends with a single stream_route-level approach. --- api/adc/types.go | 7 ++++++- internal/adc/translator/tcproute.go | 1 - internal/adc/translator/tlsroute.go | 1 - internal/adc/translator/udproute.go | 1 - 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/api/adc/types.go b/api/adc/types.go index 1c6d46e4..6c7a749d 100644 --- a/api/adc/types.go +++ b/api/adc/types.go @@ -149,7 +149,10 @@ type Timeout struct { type StreamRoute struct { Metadata `json:",inline" yaml:",inline"` - Plugins Plugins `json:"plugins,omitempty"` + // Plugins is intentionally not omitempty: an empty object ("plugins": {}) must be + // emitted so that removing all plugins is detected as a change by the api7ee ADC + // backend (an omitted field is treated as "no change" and would leave stale plugins). + Plugins Plugins `json:"plugins"` RemoteAddr string `json:"remote_addr,omitempty"` ServerAddr string `json:"server_addr,omitempty"` ServerPort int32 `json:"server_port,omitempty"` @@ -623,6 +626,8 @@ func NewDefaultStreamRoute() *StreamRoute { "managed-by": "apisix-ingress-controller", }, }, + // Non-nil so the now-required plugins field serializes as {} rather than null. + Plugins: Plugins{}, } } diff --git a/internal/adc/translator/tcproute.go b/internal/adc/translator/tcproute.go index c7f00e0e..6982eb90 100644 --- a/internal/adc/translator/tcproute.go +++ b/internal/adc/translator/tcproute.go @@ -158,7 +158,6 @@ func (t *Translator) TranslateTCPRoute(tctx *provider.TranslateContext, tcpRoute // 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) diff --git a/internal/adc/translator/tlsroute.go b/internal/adc/translator/tlsroute.go index 4b2ef33d..aa90588a 100644 --- a/internal/adc/translator/tlsroute.go +++ b/internal/adc/translator/tlsroute.go @@ -154,7 +154,6 @@ func (t *Translator) TranslateTLSRoute(tctx *provider.TranslateContext, tlsRoute // 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) } diff --git a/internal/adc/translator/udproute.go b/internal/adc/translator/udproute.go index 00c90f3e..a790fa9c 100644 --- a/internal/adc/translator/udproute.go +++ b/internal/adc/translator/udproute.go @@ -147,7 +147,6 @@ func (t *Translator) TranslateUDPRoute(tctx *provider.TranslateContext, udpRoute // 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) From 0ebe08da1d7409f540b73e3678f8a77352c2d248 Mon Sep 17 00:00:00 2001 From: rongxin Date: Thu, 11 Jun 2026 13:51:58 +0800 Subject: [PATCH 12/16] Revert "fix: always emit stream_route plugins so api7ee removal is detected" This reverts commit dc51e7a86f405dc6069fdc054219334f23eadea6. --- api/adc/types.go | 7 +------ internal/adc/translator/tcproute.go | 1 + internal/adc/translator/tlsroute.go | 1 + internal/adc/translator/udproute.go | 1 + 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/api/adc/types.go b/api/adc/types.go index 6c7a749d..1c6d46e4 100644 --- a/api/adc/types.go +++ b/api/adc/types.go @@ -149,10 +149,7 @@ type Timeout struct { type StreamRoute struct { Metadata `json:",inline" yaml:",inline"` - // Plugins is intentionally not omitempty: an empty object ("plugins": {}) must be - // emitted so that removing all plugins is detected as a change by the api7ee ADC - // backend (an omitted field is treated as "no change" and would leave stale plugins). - Plugins Plugins `json:"plugins"` + Plugins Plugins `json:"plugins,omitempty"` RemoteAddr string `json:"remote_addr,omitempty"` ServerAddr string `json:"server_addr,omitempty"` ServerPort int32 `json:"server_port,omitempty"` @@ -626,8 +623,6 @@ func NewDefaultStreamRoute() *StreamRoute { "managed-by": "apisix-ingress-controller", }, }, - // Non-nil so the now-required plugins field serializes as {} rather than null. - Plugins: Plugins{}, } } diff --git a/internal/adc/translator/tcproute.go b/internal/adc/translator/tcproute.go index 6982eb90..c7f00e0e 100644 --- a/internal/adc/translator/tcproute.go +++ b/internal/adc/translator/tcproute.go @@ -158,6 +158,7 @@ func (t *Translator) TranslateTCPRoute(tctx *provider.TranslateContext, tcpRoute // 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) diff --git a/internal/adc/translator/tlsroute.go b/internal/adc/translator/tlsroute.go index aa90588a..4b2ef33d 100644 --- a/internal/adc/translator/tlsroute.go +++ b/internal/adc/translator/tlsroute.go @@ -154,6 +154,7 @@ func (t *Translator) TranslateTLSRoute(tctx *provider.TranslateContext, tlsRoute // 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) } diff --git a/internal/adc/translator/udproute.go b/internal/adc/translator/udproute.go index a790fa9c..00c90f3e 100644 --- a/internal/adc/translator/udproute.go +++ b/internal/adc/translator/udproute.go @@ -147,6 +147,7 @@ func (t *Translator) TranslateUDPRoute(tctx *provider.TranslateContext, udpRoute // 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) From c9222de92b26b557ffb55553a520dcfc0316eb74 Mon Sep 17 00:00:00 2001 From: rongxin Date: Fri, 12 Jun 2026 17:09:55 +0800 Subject: [PATCH 13/16] test: tolerate YAML line-wrapping in unknown-plugin status assertion The api7ee dashboard rejects the route with the message "custom plugin (non-existent-plugin) not found". kubectl -o yaml folds this long status message across lines, splitting it between "(non-existent-plugin)" and "not found", so the literal substring match never succeeds. Match it with a whitespace-tolerant regexp. --- test/e2e/crds/v2/status.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/e2e/crds/v2/status.go b/test/e2e/crds/v2/status.go index 6b01a5d0..6988c94d 100644 --- a/test/e2e/crds/v2/status.go +++ b/test/e2e/crds/v2/status.go @@ -145,7 +145,9 @@ spec: And( ContainSubstring(`status: "False"`), ContainSubstring(`reason: SyncFailed`), - ContainSubstring(`(non-existent-plugin) not found`), + // The dashboard error message can be wrapped across lines in the + // YAML output, so match the substring tolerant of whitespace. + MatchRegexp(`(?s)\(non-existent-plugin\)\s+not found`), ), ) } From d9a46f06266310158b0d8b8f2ffac5c8c9013d4f Mon Sep 17 00:00:00 2001 From: rongxin Date: Mon, 15 Jun 2026 04:04:39 +0800 Subject: [PATCH 14/16] feat: validate targetRefs group and skip sectionName for L4RoutePolicy Add an XValidation rule requiring targetRefs.group to be gateway.networking.k8s.io so invalid targets fail fast instead of being silently ignored by the controller. L4 routes expose no addressable sections, so a targetRef that pins a sectionName cannot be honored. Ignore such refs when accepting a policy (ProcessL4RoutePolicy) and when attaching plugins (AttachL4RoutePolicyPlugins) to keep status and dataplane behavior consistent. --- api/v1alpha1/l4routepolicy_types.go | 1 + .../apisix.apache.org_l4routepolicies.yaml | 2 ++ internal/adc/translator/policies.go | 5 +++ internal/controller/policies.go | 34 +++++++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/api/v1alpha1/l4routepolicy_types.go b/api/v1alpha1/l4routepolicy_types.go index 695d1982..5bc81e66 100644 --- a/api/v1alpha1/l4routepolicy_types.go +++ b/api/v1alpha1/l4routepolicy_types.go @@ -30,6 +30,7 @@ type L4RoutePolicySpec struct { // +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. diff --git a/config/crd/bases/apisix.apache.org_l4routepolicies.yaml b/config/crd/bases/apisix.apache.org_l4routepolicies.yaml index 43a966e9..3981fb4f 100644 --- a/config/crd/bases/apisix.apache.org_l4routepolicies.yaml +++ b/config/crd/bases/apisix.apache.org_l4routepolicies.yaml @@ -119,6 +119,8 @@ spec: - 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 diff --git a/internal/adc/translator/policies.go b/internal/adc/translator/policies.go index 662dd6bc..16d6c994 100644 --- a/internal/adc/translator/policies.go +++ b/internal/adc/translator/policies.go @@ -198,6 +198,11 @@ func (t *Translator) AttachL4RoutePolicyPlugins( 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 } diff --git a/internal/controller/policies.go b/internal/controller/policies.go index e3569d98..ef563d2a 100644 --- a/internal/controller/policies.go +++ b/internal/controller/policies.go @@ -228,6 +228,31 @@ func parentRefValueEqual(a, b gatewayv1.ParentReference) bool { 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. @@ -247,6 +272,15 @@ func ProcessL4RoutePolicy( 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 From a9c5209cf70e7cff2fd26590e8d57f94e30b05e0 Mon Sep 17 00:00:00 2001 From: rongxin Date: Mon, 15 Jun 2026 14:58:24 +0800 Subject: [PATCH 15/16] fix: only watch L4RoutePolicy when its CRD is installed The TCP/UDP/TLSRoute controllers watched L4RoutePolicy unconditionally, so upgrading the controller without applying the new CRD made the manager fail to start (the informer cannot be created for a missing kind), taking down all routing. Guard the watch with HasAPIResource so the controller starts without the CRD and enables L4RoutePolicy once it is installed and the pod restarts, matching how optional resources like EndpointSlice are handled. --- internal/controller/tcproute_controller.go | 10 ++++++++-- internal/controller/tlsroute_controller.go | 10 ++++++++-- internal/controller/udproute_controller.go | 10 ++++++++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/internal/controller/tcproute_controller.go b/internal/controller/tcproute_controller.go index b25f0ebf..6f23c1ac 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. @@ -93,10 +94,15 @@ func (r *TCPRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { ). Watches(&v1alpha1.GatewayProxy{}, handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesForGatewayProxy), - ). - Watches(&v1alpha1.L4RoutePolicy{}, + ) + + // 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). + if pkgutils.HasAPIResource(mgr, &v1alpha1.L4RoutePolicy{}) { + bdr.Watches(&v1alpha1.L4RoutePolicy{}, handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesForL4RoutePolicy), ) + } if GetEnableReferenceGrant() { bdr.Watches(&v1beta1.ReferenceGrant{}, diff --git a/internal/controller/tlsroute_controller.go b/internal/controller/tlsroute_controller.go index 11ef5506..170ce826 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. @@ -93,10 +94,15 @@ func (r *TLSRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { ). Watches(&v1alpha1.GatewayProxy{}, handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesForGatewayProxy), - ). - Watches(&v1alpha1.L4RoutePolicy{}, + ) + + // 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). + if pkgutils.HasAPIResource(mgr, &v1alpha1.L4RoutePolicy{}) { + bdr.Watches(&v1alpha1.L4RoutePolicy{}, handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesForL4RoutePolicy), ) + } if GetEnableReferenceGrant() { bdr.Watches(&v1beta1.ReferenceGrant{}, diff --git a/internal/controller/udproute_controller.go b/internal/controller/udproute_controller.go index d3d4279b..bd2bb36d 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. @@ -93,10 +94,15 @@ func (r *UDPRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { ). Watches(&v1alpha1.GatewayProxy{}, handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesForGatewayProxy), - ). - Watches(&v1alpha1.L4RoutePolicy{}, + ) + + // 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). + if pkgutils.HasAPIResource(mgr, &v1alpha1.L4RoutePolicy{}) { + bdr.Watches(&v1alpha1.L4RoutePolicy{}, handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesForL4RoutePolicy), ) + } if GetEnableReferenceGrant() { bdr.Watches(&v1beta1.ReferenceGrant{}, From 72e0b1f9472e64b21afe2665bd44b406111ef529 Mon Sep 17 00:00:00 2001 From: rongxin Date: Mon, 15 Jun 2026 15:11:34 +0800 Subject: [PATCH 16/16] fix: skip L4RoutePolicy list calls when its CRD is absent When the L4RoutePolicy CRD is not installed the watch is already skipped, but ProcessL4RoutePolicy and updateL4RoutePolicyStatusOnDeleting still issued a List on every TCP/UDP/TLSRoute reconcile and deletion, logging an error each time. Store the detection result in supportsL4RoutePolicy and gate the watch and both list calls on it, mirroring supportsEndpointSlice. --- internal/controller/tcproute_controller.go | 14 +++++++++++--- internal/controller/tlsroute_controller.go | 14 +++++++++++--- internal/controller/udproute_controller.go | 14 +++++++++++--- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/internal/controller/tcproute_controller.go b/internal/controller/tcproute_controller.go index 6f23c1ac..f3487547 100644 --- a/internal/controller/tcproute_controller.go +++ b/internal/controller/tcproute_controller.go @@ -59,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. @@ -98,7 +101,8 @@ func (r *TCPRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { // 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). - if pkgutils.HasAPIResource(mgr, &v1alpha1.L4RoutePolicy{}) { + r.supportsL4RoutePolicy = pkgutils.HasAPIResource(mgr, &v1alpha1.L4RoutePolicy{}) + if r.supportsL4RoutePolicy { bdr.Watches(&v1alpha1.L4RoutePolicy{}, handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesForL4RoutePolicy), ) @@ -249,7 +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 } - updateL4RoutePolicyStatusOnDeleting(ctx, r.Client, r.Updater, r.Log, req.NamespacedName, KindTCPRoute) + if r.supportsL4RoutePolicy { + updateL4RoutePolicyStatusOnDeleting(ctx, r.Client, r.Updater, r.Log, req.NamespacedName, KindTCPRoute) + } return ctrl.Result{}, nil } return ctrl.Result{}, err @@ -303,7 +309,9 @@ 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) + 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{} diff --git a/internal/controller/tlsroute_controller.go b/internal/controller/tlsroute_controller.go index 170ce826..ecf46600 100644 --- a/internal/controller/tlsroute_controller.go +++ b/internal/controller/tlsroute_controller.go @@ -59,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. @@ -98,7 +101,8 @@ func (r *TLSRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { // 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). - if pkgutils.HasAPIResource(mgr, &v1alpha1.L4RoutePolicy{}) { + r.supportsL4RoutePolicy = pkgutils.HasAPIResource(mgr, &v1alpha1.L4RoutePolicy{}) + if r.supportsL4RoutePolicy { bdr.Watches(&v1alpha1.L4RoutePolicy{}, handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesForL4RoutePolicy), ) @@ -249,7 +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 } - updateL4RoutePolicyStatusOnDeleting(ctx, r.Client, r.Updater, r.Log, req.NamespacedName, types.KindTLSRoute) + if r.supportsL4RoutePolicy { + updateL4RoutePolicyStatusOnDeleting(ctx, r.Client, r.Updater, r.Log, req.NamespacedName, types.KindTLSRoute) + } return ctrl.Result{}, nil } return ctrl.Result{}, err @@ -303,7 +309,9 @@ 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) + 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{} diff --git a/internal/controller/udproute_controller.go b/internal/controller/udproute_controller.go index bd2bb36d..070cee76 100644 --- a/internal/controller/udproute_controller.go +++ b/internal/controller/udproute_controller.go @@ -59,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. @@ -98,7 +101,8 @@ func (r *UDPRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { // 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). - if pkgutils.HasAPIResource(mgr, &v1alpha1.L4RoutePolicy{}) { + r.supportsL4RoutePolicy = pkgutils.HasAPIResource(mgr, &v1alpha1.L4RoutePolicy{}) + if r.supportsL4RoutePolicy { bdr.Watches(&v1alpha1.L4RoutePolicy{}, handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesForL4RoutePolicy), ) @@ -249,7 +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 } - updateL4RoutePolicyStatusOnDeleting(ctx, r.Client, r.Updater, r.Log, req.NamespacedName, KindUDPRoute) + if r.supportsL4RoutePolicy { + updateL4RoutePolicyStatusOnDeleting(ctx, r.Client, r.Updater, r.Log, req.NamespacedName, KindUDPRoute) + } return ctrl.Result{}, nil } return ctrl.Result{}, err @@ -303,7 +309,9 @@ 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) + 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{}