Skip to content

Commit a735228

Browse files
authored
Merge pull request #96 from kgateway-dev/issue_59_11
agentgateway: map frontend TLS annotations to frontend policy
2 parents 8ad1aa2 + 65bad00 commit a735228

18 files changed

Lines changed: 777 additions & 0 deletions

File tree

pkg/i2gw/emitter_intermediate/intermediate_representation.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,12 @@ type BackendTLSPolicy struct {
161161
Hostname string
162162
}
163163

164+
// FrontendTLSPolicy defines frontend TLS listener policy extracted from annotations.
165+
type FrontendTLSPolicy struct {
166+
HandshakeTimeout *metav1.Duration
167+
ALPNProtocols []string
168+
}
169+
164170
// Policy describes per-Ingress policy knobs projected by providers.
165171
type Policy struct {
166172
ClientBodyBufferSize *resource.Quantity
@@ -176,6 +182,7 @@ type Policy struct {
176182
SessionAffinity *SessionAffinityPolicy
177183
LoadBalancing *BackendLoadBalancingPolicy
178184
BackendTLS *BackendTLSPolicy
185+
FrontendTLS *FrontendTLSPolicy
179186
BackendProtocol *BackendProtocol
180187
SSLRedirect *bool
181188
RewriteTarget *string

pkg/i2gw/emitters/agentgateway/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,28 @@ These are mapped into an `AgentgatewayPolicy` using agentgateway’s `Traffic.Ti
285285
`spec.traffic.timeouts.request` to avoid unexpectedly truncating requests.
286286
- Invalid/unsupported duration values are ignored by the provider and will not be projected.
287287

288+
#### Frontend TLS Settings
289+
290+
The agentgateway emitter supports projecting frontend TLS listener settings via:
291+
292+
- `nginx.ingress.kubernetes.io/ssl-handshake-timeout`
293+
- `nginx.ingress.kubernetes.io/ssl-alpn`
294+
295+
These are mapped into an `AgentgatewayPolicy` using agentgateway's `Frontend.TLS` model:
296+
297+
- `ssl-handshake-timeout` -> `AgentgatewayPolicy.spec.frontend.tls.handshakeTimeout`
298+
- `ssl-alpn` -> `AgentgatewayPolicy.spec.frontend.tls.alpnProtocols`
299+
300+
**Notes:**
301+
302+
- Agentgateway validates `spec.frontend` only on `Gateway` targets, so ingress2gateway emits a single
303+
Gateway-targeted policy named `<gateway>-frontend-tls`.
304+
- If multiple source Ingresses on the same Gateway request different frontend TLS settings, the emitter returns an
305+
error because agentgateway cannot scope these settings to an individual HTTPRoute or listener.
306+
- `ssl-handshake-timeout` accepts either Go-style durations (`20s`, `1m`) or bare seconds (`20`) and must be at least `100ms`.
307+
- `ssl-alpn` is parsed as a comma-separated list and de-duplicated while preserving order.
308+
- If only `ssl-alpn` is set, the provider projects a default `15s` handshake timeout so `spec.frontend.tls` remains valid.
309+
288310
#### Local Rate Limiting
289311

290312
The agentgateway emitter currently supports projecting local rate limiting via:

pkg/i2gw/emitters/agentgateway/emitter.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ func (e *Emitter) Emit(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.Err
6565
// Track AgentgatewayPolicies per ingress name
6666
agentgatewayPolicies := map[string]*agentgatewayv1alpha1.AgentgatewayPolicy{}
6767

68+
// Track Gateway-scoped frontend TLS policies. Agentgateway validates
69+
// spec.frontend only when the policy targets the Gateway directly.
70+
gatewayFrontendTLSPolicies := map[types.NamespacedName]*agentgatewayv1alpha1.AgentgatewayPolicy{}
71+
gatewayFrontendTLSPolicySources := map[types.NamespacedName]string{}
72+
6873
// Track backend-scoped AgentgatewayPolicies per Service (ns/name) (e.g. TLS, connect timeout)
6974
backendPolicies := map[types.NamespacedName]*agentgatewayv1alpha1.AgentgatewayPolicy{}
7075

@@ -112,6 +117,18 @@ func (e *Emitter) Emit(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.Err
112117
touched = true
113118
}
114119

120+
// Frontend TLS settings are Gateway-scoped in agentgateway.
121+
if _, frontendTLSErr := applyFrontendTLSPolicy(
122+
pol,
123+
polSourceIngressName,
124+
httpRouteContext.HTTPRoute,
125+
httpRouteKey.Namespace,
126+
gatewayFrontendTLSPolicies,
127+
gatewayFrontendTLSPolicySources,
128+
); frontendTLSErr != nil {
129+
errs = append(errs, frontendTLSErr)
130+
}
131+
115132
// Check if SSL redirect is enabled but don't apply it yet (will split route later).
116133
if applySSLRedirectPolicy(pol) {
117134
routesToSplitForSSLRedirect[httpRouteKey] = true
@@ -338,6 +355,11 @@ func (e *Emitter) Emit(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.Err
338355
}
339356
}
340357

358+
// Collect Gateway-scoped frontend TLS policies.
359+
for _, ap := range gatewayFrontendTLSPolicies {
360+
agentgatewayObjs = append(agentgatewayObjs, ap)
361+
}
362+
341363
// Collect AgentgatewayPolicies
342364
for _, ap := range agentgatewayPolicies {
343365
agentgatewayObjs = append(agentgatewayObjs, ap)

pkg/i2gw/emitters/agentgateway/emitter_integration_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,32 @@ func TestAgentgatewayIngressNginxIntegration_Timeouts(t *testing.T) {
311311
}
312312
}
313313

314+
func TestAgentgatewayIngressNginxIntegration_FrontendTLS(t *testing.T) {
315+
t.Helper()
316+
317+
tests := []struct {
318+
name string
319+
inputRel string
320+
goldenRel string
321+
}{
322+
{
323+
name: "frontend_tls",
324+
inputRel: filepath.Join(
325+
"pkg", "i2gw", "emitters", "agentgateway", "testing", "testdata", "input", "frontend_tls.yaml",
326+
),
327+
goldenRel: filepath.Join(
328+
"pkg", "i2gw", "emitters", "agentgateway", "testing", "testdata", "output", "frontend_tls.yaml",
329+
),
330+
},
331+
}
332+
333+
for _, tt := range tests {
334+
t.Run(tt.name, func(t *testing.T) {
335+
runGoldenTest(t, tt.inputRel, tt.goldenRel)
336+
})
337+
}
338+
}
339+
314340
func TestAgentgatewayIngressNginxIntegration_CORS(t *testing.T) {
315341
t.Helper()
316342

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
Copyright 2026 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package agentgateway
18+
19+
import (
20+
"fmt"
21+
22+
emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate"
23+
agentgatewayv1alpha1 "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/agentgateway"
24+
"github.com/kgateway-dev/kgateway/v2/api/v1alpha1/shared"
25+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"k8s.io/apimachinery/pkg/types"
27+
"k8s.io/apimachinery/pkg/util/validation/field"
28+
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
29+
)
30+
31+
// applyFrontendTLSPolicy projects frontend TLS listener settings into
32+
// AgentgatewayPolicy.spec.frontend.tls.
33+
//
34+
// Agentgateway validates frontend policies only when they target the Gateway
35+
// resource directly, so these settings are tracked separately from the
36+
// HTTPRoute-scoped policy map used by traffic features.
37+
func applyFrontendTLSPolicy(
38+
pol emitterir.Policy,
39+
ingressName string,
40+
httpRoute gatewayv1.HTTPRoute,
41+
defaultNamespace string,
42+
ap map[types.NamespacedName]*agentgatewayv1alpha1.AgentgatewayPolicy,
43+
sources map[types.NamespacedName]string,
44+
) (bool, *field.Error) {
45+
if pol.FrontendTLS == nil {
46+
return false, nil
47+
}
48+
if pol.FrontendTLS.HandshakeTimeout == nil && len(pol.FrontendTLS.ALPNProtocols) == 0 {
49+
return false, nil
50+
}
51+
52+
gatewayKey, ok := gatewayParentKeyForHTTPRoute(httpRoute, defaultNamespace)
53+
if !ok {
54+
return false, field.Invalid(
55+
field.NewPath("emitter", "agentgateway", "AgentgatewayPolicy", "frontend", "tls"),
56+
ingressName,
57+
"frontend TLS policies require a parent Gateway target",
58+
)
59+
}
60+
61+
if existing, ok := ap[gatewayKey]; ok {
62+
if !frontendTLSPoliciesEqual(existing.Spec.Frontend.TLS, pol.FrontendTLS) {
63+
return false, field.Invalid(
64+
field.NewPath("emitter", "agentgateway", "AgentgatewayPolicy", "frontend", "tls"),
65+
ingressName,
66+
fmt.Sprintf(
67+
"frontend TLS settings conflict on Gateway %s/%s with source Ingress %q; agentgateway only supports Gateway-scoped frontend TLS policies",
68+
gatewayKey.Namespace,
69+
gatewayKey.Name,
70+
sources[gatewayKey],
71+
),
72+
)
73+
}
74+
return true, nil
75+
}
76+
77+
alpn := make([]agentgatewayv1alpha1.TinyString, 0, len(pol.FrontendTLS.ALPNProtocols))
78+
for _, value := range pol.FrontendTLS.ALPNProtocols {
79+
alpn = append(alpn, agentgatewayv1alpha1.TinyString(value))
80+
}
81+
82+
ap[gatewayKey] = &agentgatewayv1alpha1.AgentgatewayPolicy{
83+
ObjectMeta: metav1.ObjectMeta{
84+
Name: fmt.Sprintf("%s-frontend-tls", gatewayKey.Name),
85+
Namespace: gatewayKey.Namespace,
86+
},
87+
Spec: agentgatewayv1alpha1.AgentgatewayPolicySpec{
88+
Frontend: &agentgatewayv1alpha1.Frontend{
89+
TLS: &agentgatewayv1alpha1.FrontendTLS{
90+
HandshakeTimeout: pol.FrontendTLS.HandshakeTimeout,
91+
AlpnProtocols: &alpn,
92+
},
93+
},
94+
TargetRefs: []shared.LocalPolicyTargetReferenceWithSectionName{{
95+
LocalPolicyTargetReference: shared.LocalPolicyTargetReference{
96+
Group: gatewayv1.Group("gateway.networking.k8s.io"),
97+
Kind: gatewayv1.Kind("Gateway"),
98+
Name: gatewayv1.ObjectName(gatewayKey.Name),
99+
},
100+
}},
101+
},
102+
}
103+
ap[gatewayKey].SetGroupVersionKind(AgentgatewayPolicyGVK)
104+
sources[gatewayKey] = ingressName
105+
106+
return true, nil
107+
}
108+
109+
func frontendTLSPoliciesEqual(
110+
existing *agentgatewayv1alpha1.FrontendTLS,
111+
desired *emitterir.FrontendTLSPolicy,
112+
) bool {
113+
if existing == nil || desired == nil {
114+
return existing == nil && desired == nil
115+
}
116+
117+
switch {
118+
case existing.HandshakeTimeout == nil && desired.HandshakeTimeout != nil:
119+
return false
120+
case existing.HandshakeTimeout != nil && desired.HandshakeTimeout == nil:
121+
return false
122+
case existing.HandshakeTimeout != nil && desired.HandshakeTimeout != nil &&
123+
existing.HandshakeTimeout.Duration != desired.HandshakeTimeout.Duration:
124+
return false
125+
}
126+
127+
existingALPN := []agentgatewayv1alpha1.TinyString{}
128+
if existing.AlpnProtocols != nil {
129+
existingALPN = *existing.AlpnProtocols
130+
}
131+
if len(existingALPN) != len(desired.ALPNProtocols) {
132+
return false
133+
}
134+
for i, protocol := range desired.ALPNProtocols {
135+
if string(existingALPN[i]) != protocol {
136+
return false
137+
}
138+
}
139+
140+
return true
141+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
Copyright 2026 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package agentgateway
18+
19+
import (
20+
"testing"
21+
"time"
22+
23+
emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate"
24+
agentgatewayv1alpha1 "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/agentgateway"
25+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"k8s.io/apimachinery/pkg/types"
27+
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
28+
)
29+
30+
func TestApplyFrontendTLSPolicyTargetsGateway(t *testing.T) {
31+
policies := map[types.NamespacedName]*agentgatewayv1alpha1.AgentgatewayPolicy{}
32+
sources := map[types.NamespacedName]string{}
33+
httpRoute := gatewayv1.HTTPRoute{
34+
Spec: gatewayv1.HTTPRouteSpec{
35+
CommonRouteSpec: gatewayv1.CommonRouteSpec{
36+
ParentRefs: []gatewayv1.ParentReference{{
37+
Name: gatewayv1.ObjectName("nginx"),
38+
}},
39+
},
40+
},
41+
}
42+
policy := emitterir.Policy{
43+
FrontendTLS: &emitterir.FrontendTLSPolicy{
44+
HandshakeTimeout: &metav1.Duration{Duration: 20 * time.Second},
45+
ALPNProtocols: []string{"h2", "http/1.1"},
46+
},
47+
}
48+
49+
touched, err := applyFrontendTLSPolicy(policy, "ingress-frontend-tls", httpRoute, "default", policies, sources)
50+
if err != nil {
51+
t.Fatalf("applyFrontendTLSPolicy returned error: %v", err)
52+
}
53+
if !touched {
54+
t.Fatal("expected frontend TLS policy to be emitted")
55+
}
56+
57+
key := types.NamespacedName{Namespace: "default", Name: "nginx"}
58+
got := policies[key]
59+
if got == nil {
60+
t.Fatalf("expected frontend TLS policy for %v", key)
61+
}
62+
if got.Name != "nginx-frontend-tls" {
63+
t.Fatalf("expected Gateway-scoped frontend TLS policy name %q, got %q", "nginx-frontend-tls", got.Name)
64+
}
65+
if len(got.Spec.TargetRefs) != 1 {
66+
t.Fatalf("expected a single targetRef, got %d", len(got.Spec.TargetRefs))
67+
}
68+
targetRef := got.Spec.TargetRefs[0]
69+
if targetRef.Kind != gatewayv1.Kind("Gateway") {
70+
t.Fatalf("expected Gateway target, got %q", targetRef.Kind)
71+
}
72+
if targetRef.Name != gatewayv1.ObjectName("nginx") {
73+
t.Fatalf("expected Gateway target name %q, got %q", "nginx", targetRef.Name)
74+
}
75+
if targetRef.SectionName != nil {
76+
t.Fatalf("expected Gateway frontend TLS policy to omit sectionName, got %q", *targetRef.SectionName)
77+
}
78+
}
79+
80+
func TestApplyFrontendTLSPolicyRejectsConflictingGatewaySettings(t *testing.T) {
81+
policies := map[types.NamespacedName]*agentgatewayv1alpha1.AgentgatewayPolicy{}
82+
sources := map[types.NamespacedName]string{}
83+
httpRoute := gatewayv1.HTTPRoute{
84+
Spec: gatewayv1.HTTPRouteSpec{
85+
CommonRouteSpec: gatewayv1.CommonRouteSpec{
86+
ParentRefs: []gatewayv1.ParentReference{{
87+
Name: gatewayv1.ObjectName("nginx"),
88+
}},
89+
},
90+
},
91+
}
92+
93+
first := emitterir.Policy{
94+
FrontendTLS: &emitterir.FrontendTLSPolicy{
95+
HandshakeTimeout: &metav1.Duration{Duration: 20 * time.Second},
96+
ALPNProtocols: []string{"h2", "http/1.1"},
97+
},
98+
}
99+
if _, err := applyFrontendTLSPolicy(first, "ingress-a", httpRoute, "default", policies, sources); err != nil {
100+
t.Fatalf("unexpected error creating first frontend TLS policy: %v", err)
101+
}
102+
103+
conflicting := emitterir.Policy{
104+
FrontendTLS: &emitterir.FrontendTLSPolicy{
105+
HandshakeTimeout: &metav1.Duration{Duration: 30 * time.Second},
106+
ALPNProtocols: []string{"h2", "http/1.1"},
107+
},
108+
}
109+
if _, err := applyFrontendTLSPolicy(conflicting, "ingress-b", httpRoute, "default", policies, sources); err == nil {
110+
t.Fatal("expected conflicting frontend TLS settings to return an error")
111+
}
112+
}

0 commit comments

Comments
 (0)