Skip to content

Commit 242ce9c

Browse files
AlinsRanCopilot
andcommitted
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: #403 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 382cc0f commit 242ce9c

14 files changed

Lines changed: 590 additions & 0 deletions
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package v1alpha1
19+
20+
import (
21+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
22+
gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
23+
)
24+
25+
// L4RoutePolicySpec defines the desired state of L4RoutePolicy.
26+
type L4RoutePolicySpec struct {
27+
// TargetRefs identifies the L4 route resources (TCPRoute, UDPRoute, or TLSRoute)
28+
// to which this policy applies. Only same-namespace targets are supported.
29+
//
30+
// +kubebuilder:validation:MinItems=1
31+
// +kubebuilder:validation:MaxItems=16
32+
// +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"
33+
TargetRefs []gatewayv1alpha2.LocalPolicyTargetReferenceWithSectionName `json:"targetRefs"`
34+
35+
// Plugins is the list of APISIX stream plugins to attach to the targeted L4 routes.
36+
// Plugin names should be valid APISIX stream plugin names (e.g., limit-conn, ip-restriction).
37+
//
38+
// +optional
39+
Plugins []Plugin `json:"plugins,omitempty"`
40+
}
41+
42+
// +kubebuilder:object:root=true
43+
// +kubebuilder:subresource:status
44+
45+
// L4RoutePolicy defines plugin configuration for Gateway API L4 routes (TCPRoute, UDPRoute, TLSRoute).
46+
// It follows the Gateway API Policy Attachment pattern and attaches APISIX stream plugins
47+
// to the targeted L4 route resources.
48+
type L4RoutePolicy struct {
49+
metav1.TypeMeta `json:",inline"`
50+
metav1.ObjectMeta `json:"metadata,omitempty"`
51+
52+
// Spec defines the desired state of L4RoutePolicy.
53+
Spec L4RoutePolicySpec `json:"spec,omitempty"`
54+
Status PolicyStatus `json:"status,omitempty"`
55+
}
56+
57+
// +kubebuilder:object:root=true
58+
59+
// L4RoutePolicyList contains a list of L4RoutePolicy.
60+
type L4RoutePolicyList struct {
61+
metav1.TypeMeta `json:",inline"`
62+
metav1.ListMeta `json:"metadata,omitempty"`
63+
Items []L4RoutePolicy `json:"items"`
64+
}
65+
66+
func init() {
67+
SchemeBuilder.Register(&L4RoutePolicy{}, &L4RoutePolicyList{})
68+
}

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 88 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package translator
19+
20+
import (
21+
"encoding/json"
22+
"testing"
23+
24+
"github.com/go-logr/logr"
25+
"github.com/stretchr/testify/assert"
26+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
27+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
28+
k8stypes "k8s.io/apimachinery/pkg/types"
29+
gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
30+
31+
adctypes "github.com/apache/apisix-ingress-controller/api/adc"
32+
"github.com/apache/apisix-ingress-controller/api/v1alpha1"
33+
)
34+
35+
func makeL4RoutePolicy(namespace, name, targetKind, targetName string, plugins []v1alpha1.Plugin) *v1alpha1.L4RoutePolicy {
36+
return &v1alpha1.L4RoutePolicy{
37+
ObjectMeta: metav1.ObjectMeta{
38+
Namespace: namespace,
39+
Name: name,
40+
},
41+
Spec: v1alpha1.L4RoutePolicySpec{
42+
TargetRefs: []gatewayv1alpha2.LocalPolicyTargetReferenceWithSectionName{
43+
{
44+
LocalPolicyTargetReference: gatewayv1alpha2.LocalPolicyTargetReference{
45+
Group: gatewayv1alpha2.GroupName,
46+
Kind: gatewayv1alpha2.Kind(targetKind),
47+
Name: gatewayv1alpha2.ObjectName(targetName),
48+
},
49+
},
50+
},
51+
Plugins: plugins,
52+
},
53+
}
54+
}
55+
56+
func mustJSON(v any) apiextensionsv1.JSON {
57+
b, err := json.Marshal(v)
58+
if err != nil {
59+
panic(err)
60+
}
61+
return apiextensionsv1.JSON{Raw: b}
62+
}
63+
64+
func TestAttachL4RoutePolicyPlugins_AttachesMatchingPolicy(t *testing.T) {
65+
tr := NewTranslator(logr.Discard())
66+
67+
policy := makeL4RoutePolicy("default", "my-policy", "TCPRoute", "my-tcp-route", []v1alpha1.Plugin{
68+
{Name: "limit-conn", Config: mustJSON(map[string]any{"conn": 100, "burst": 50})},
69+
{Name: "ip-restriction", Config: mustJSON(map[string]any{"whitelist": []string{"10.0.0.0/8"}})},
70+
})
71+
72+
policies := map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy{
73+
{Namespace: "default", Name: "my-policy"}: policy,
74+
}
75+
76+
plugins := adctypes.Plugins{}
77+
tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route", "TCPRoute", plugins)
78+
79+
assert.Len(t, plugins, 2)
80+
assert.Contains(t, plugins, "limit-conn")
81+
assert.Contains(t, plugins, "ip-restriction")
82+
83+
cfg := plugins["limit-conn"].(map[string]any)
84+
assert.EqualValues(t, 100, cfg["conn"])
85+
}
86+
87+
func TestAttachL4RoutePolicyPlugins_NoMatchOnKind(t *testing.T) {
88+
tr := NewTranslator(logr.Discard())
89+
90+
policy := makeL4RoutePolicy("default", "udp-policy", "UDPRoute", "my-udp-route", []v1alpha1.Plugin{
91+
{Name: "limit-conn", Config: mustJSON(map[string]any{"conn": 10})},
92+
})
93+
94+
policies := map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy{
95+
{Namespace: "default", Name: "udp-policy"}: policy,
96+
}
97+
98+
plugins := adctypes.Plugins{}
99+
// Looking for TCPRoute, but policy targets UDPRoute — should not match.
100+
tr.AttachL4RoutePolicyPlugins(policies, "default", "my-udp-route", "TCPRoute", plugins)
101+
102+
assert.Empty(t, plugins)
103+
}
104+
105+
func TestAttachL4RoutePolicyPlugins_NoMatchOnNamespace(t *testing.T) {
106+
tr := NewTranslator(logr.Discard())
107+
108+
policy := makeL4RoutePolicy("other-ns", "my-policy", "TCPRoute", "my-tcp-route", []v1alpha1.Plugin{
109+
{Name: "limit-conn", Config: mustJSON(map[string]any{"conn": 10})},
110+
})
111+
112+
policies := map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy{
113+
{Namespace: "other-ns", Name: "my-policy"}: policy,
114+
}
115+
116+
plugins := adctypes.Plugins{}
117+
// Route is in "default" namespace, policy is in "other-ns" — should not match.
118+
tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route", "TCPRoute", plugins)
119+
120+
assert.Empty(t, plugins)
121+
}
122+
123+
func TestAttachL4RoutePolicyPlugins_EmptyPlugins(t *testing.T) {
124+
tr := NewTranslator(logr.Discard())
125+
126+
policy := makeL4RoutePolicy("default", "empty-policy", "TCPRoute", "my-tcp-route", nil)
127+
128+
policies := map[k8stypes.NamespacedName]*v1alpha1.L4RoutePolicy{
129+
{Namespace: "default", Name: "empty-policy"}: policy,
130+
}
131+
132+
plugins := adctypes.Plugins{}
133+
tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route", "TCPRoute", plugins)
134+
135+
assert.Empty(t, plugins)
136+
}
137+
138+
func TestAttachL4RoutePolicyPlugins_EmptyPolicies(t *testing.T) {
139+
tr := NewTranslator(logr.Discard())
140+
plugins := adctypes.Plugins{}
141+
tr.AttachL4RoutePolicyPlugins(nil, "default", "my-tcp-route", "TCPRoute", plugins)
142+
assert.Empty(t, plugins)
143+
}

internal/adc/translator/policies.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@
1818
package translator
1919

2020
import (
21+
"encoding/json"
22+
2123
"k8s.io/apimachinery/pkg/types"
2224
"k8s.io/utils/ptr"
2325
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
26+
gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
2427

2528
adctypes "github.com/apache/apisix-ingress-controller/api/adc"
2629
"github.com/apache/apisix-ingress-controller/api/v1alpha1"
@@ -80,3 +83,47 @@ func (t *Translator) attachBackendTrafficPolicyToUpstream(policy *v1alpha1.Backe
8083
upstream.Key = policy.Spec.LoadBalancer.Key
8184
}
8285
}
86+
87+
// AttachL4RoutePolicyPlugins merges plugins from the matching L4RoutePolicy (if any) into the
88+
// provided plugins map. It looks up policies targeting the route identified by routeNamespace,
89+
// routeName, and routeKind.
90+
func (t *Translator) AttachL4RoutePolicyPlugins(
91+
policies map[types.NamespacedName]*v1alpha1.L4RoutePolicy,
92+
routeNamespace, routeName, routeKind string,
93+
plugins adctypes.Plugins,
94+
) {
95+
if len(policies) == 0 {
96+
return
97+
}
98+
for _, policy := range policies {
99+
if policy.Namespace != routeNamespace {
100+
continue
101+
}
102+
for _, ref := range policy.Spec.TargetRefs {
103+
if string(ref.Group) != gatewayv1alpha2.GroupName {
104+
continue
105+
}
106+
if string(ref.Kind) != routeKind {
107+
continue
108+
}
109+
if string(ref.Name) != routeName {
110+
continue
111+
}
112+
t.mergeL4PolicyPlugins(policy, plugins)
113+
return
114+
}
115+
}
116+
}
117+
118+
func (t *Translator) mergeL4PolicyPlugins(policy *v1alpha1.L4RoutePolicy, plugins adctypes.Plugins) {
119+
for _, plugin := range policy.Spec.Plugins {
120+
cfg := make(map[string]any)
121+
if len(plugin.Config.Raw) > 0 {
122+
if err := json.Unmarshal(plugin.Config.Raw, &cfg); err != nil {
123+
t.Log.Error(err, "failed to unmarshal L4RoutePolicy plugin config", "plugin", plugin.Name, "policy", policy.Name)
124+
continue
125+
}
126+
}
127+
plugins[plugin.Name] = cfg
128+
}
129+
}

internal/adc/translator/tcproute.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ func (t *Translator) TranslateTCPRoute(tctx *provider.TranslateContext, tcpRoute
157157
streamRoute.Labels = labels
158158
// TODO: support remote_addr, server_addr, sni, server_port
159159
service.StreamRoutes = append(service.StreamRoutes, streamRoute)
160+
161+
if service.Plugins == nil {
162+
service.Plugins = make(adctypes.Plugins)
163+
}
164+
t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies, tcpRoute.Namespace, tcpRoute.Name, "TCPRoute", service.Plugins)
165+
160166
result.Services = append(result.Services, service)
161167
}
162168
return result, nil

internal/adc/translator/tlsroute.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ func (t *Translator) TranslateTLSRoute(tctx *provider.TranslateContext, tlsRoute
153153
streamRoute.Labels = labels
154154
service.StreamRoutes = append(service.StreamRoutes, streamRoute)
155155
}
156+
157+
if service.Plugins == nil {
158+
service.Plugins = make(adctypes.Plugins)
159+
}
160+
t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies, tlsRoute.Namespace, tlsRoute.Name, "TLSRoute", service.Plugins)
161+
156162
result.Services = append(result.Services, service)
157163
}
158164
return result, nil

0 commit comments

Comments
 (0)