Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ type GatewayContext struct {

type HTTPRouteContext struct {
gatewayv1.HTTPRoute
// TCPTimeoutsByRuleIdx holds provider TCP-level timeouts by HTTPRoute rule index.
TCPTimeoutsByRuleIdx map[int]*TCPTimeouts

// PathRewriteByRuleIdx maps HTTPRoute rule indices to path rewrite intent.
// This is provider-neutral and applied by the common emitter.
Expand All @@ -64,6 +66,13 @@ type HTTPRouteContext struct {
BodySizeByRuleIdx map[int]*BodySize
}

// TCPTimeouts holds TCP-level timeout configuration for a single HTTPRoute rule.
type TCPTimeouts struct {
Connect *gatewayv1.Duration
Read *gatewayv1.Duration
Write *gatewayv1.Duration
}

// PathRewrite represents provider-neutral path rewrite intent.
// For now it only supports full-path replacement; more fields may be added later.
type PathRewrite struct {
Expand Down
63 changes: 62 additions & 1 deletion pkg/i2gw/emitters/common_emitter/emitter.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ limitations under the License.
package common_emitter

import (
"time"

emitterir "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/emitter_intermediate"
"k8s.io/apimachinery/pkg/util/validation/field"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
)

type Emitter struct{}

const tcpTimeoutMultiplier = 10

func NewEmitter() *Emitter {
return &Emitter{}
}
Expand Down Expand Up @@ -65,6 +69,63 @@ func applyPathRewrites(ir *emitterir.EmitterIR) {
// This ALWAYS runs after providers and before provider-specific emitters.
// TODO: Implement common logic such as filtering by maturity status and/or individual features.
func (e *Emitter) Emit(ir emitterir.EmitterIR) (emitterir.EmitterIR, field.ErrorList) {
errs := applyTCPTimeouts(&ir)
applyPathRewrites(&ir)
return ir, nil
return ir, errs
}

func applyTCPTimeouts(ir *emitterir.EmitterIR) field.ErrorList {
var errs field.ErrorList
for i, httpRouteContext := range ir.HTTPRoutes {
if httpRouteContext.TCPTimeoutsByRuleIdx == nil {
return nil
}

for ruleIdx, timeouts := range httpRouteContext.TCPTimeoutsByRuleIdx {
if timeouts == nil {
continue
}
if ruleIdx < 0 || ruleIdx >= len(httpRouteContext.Spec.Rules) {
errs = append(errs, field.Invalid(
field.NewPath("httpRoute", "spec", "rules").Index(ruleIdx),
ruleIdx,
"rule index out of range",
))
continue
}

rule := &httpRouteContext.Spec.Rules[ruleIdx]
if rule.Timeouts == nil {
rule.Timeouts = &gatewayv1.HTTPRouteTimeouts{}
}
maxTimeout, ok := maxParsedDuration(timeouts.Connect, timeouts.Read, timeouts.Write)
if ok {
requestTimeout := gatewayv1.Duration((maxTimeout * time.Duration(tcpTimeoutMultiplier)).String())
rule.Timeouts.Request = &requestTimeout
}
}

httpRouteContext.TCPTimeoutsByRuleIdx = nil
ir.HTTPRoutes[i] = httpRouteContext
}
return errs
}

func maxParsedDuration(durations ...*gatewayv1.Duration) (time.Duration, bool) {
var maxDuration time.Duration
var found bool
for _, d := range durations {
if d == nil {
continue
}
parsed, err := time.ParseDuration(string(*d))
if err != nil {
continue
}
if !found || parsed > maxDuration {
maxDuration = parsed
found = true
}
}
return maxDuration, found
}
75 changes: 75 additions & 0 deletions pkg/i2gw/emitters/common_emitter/emitter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,81 @@ import (
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
)

func TestApplyTCPTimeouts(t *testing.T) {
d := gatewayv1.Duration("10s")
tenSeconds := emitterir.TCPTimeouts{Connect: &d}

testCases := []struct {
name string
ctx emitterir.HTTPRouteContext
wantSet bool
wantErr bool
}{
{
name: "sets request timeout",
ctx: emitterir.HTTPRouteContext{
HTTPRoute: gatewayv1.HTTPRoute{Spec: gatewayv1.HTTPRouteSpec{Rules: []gatewayv1.HTTPRouteRule{{}}}},
TCPTimeoutsByRuleIdx: map[int]*emitterir.TCPTimeouts{0: &tenSeconds},
},
wantSet: true,
},
{
name: "nil duration ignored",
ctx: emitterir.HTTPRouteContext{
HTTPRoute: gatewayv1.HTTPRoute{Spec: gatewayv1.HTTPRouteSpec{Rules: []gatewayv1.HTTPRouteRule{{}}}},
TCPTimeoutsByRuleIdx: map[int]*emitterir.TCPTimeouts{0: nil},
},
wantSet: false,
},
{
name: "out of range rule index",
ctx: emitterir.HTTPRouteContext{
HTTPRoute: gatewayv1.HTTPRoute{Spec: gatewayv1.HTTPRouteSpec{Rules: []gatewayv1.HTTPRouteRule{{}}}},
TCPTimeoutsByRuleIdx: map[int]*emitterir.TCPTimeouts{1: &tenSeconds},
},
wantErr: true,
},
}

key := types.NamespacedName{Namespace: "ns", Name: "route"}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ir := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{key: tc.ctx}}
errList := applyTCPTimeouts(&ir)

gotCtx := ir.HTTPRoutes[key]
if gotCtx.TCPTimeoutsByRuleIdx != nil {
t.Fatalf("expected TCPTimeoutsByRuleIdx to be nil after apply")
}
if tc.wantErr {
if len(errList) == 0 {
t.Fatalf("expected error")
}
return
}
if len(errList) > 0 {
t.Fatalf("expected no errors, got %v", errList)
}

got := gotCtx.Spec.Rules[0].Timeouts
if tc.wantSet {
if got == nil || got.Request == nil {
t.Fatalf("expected request timeout to be set")
}
if *got.Request != gatewayv1.Duration("1m40s") {
t.Fatalf("expected %v, got %v", gatewayv1.Duration("1m40s"), *got.Request)
}
return
}

if got != nil {
t.Fatalf("expected timeouts to be nil, got %v", got)
}
})
}
}

func TestEmitter_Emit_appliesPathRewriteReplaceFullPath(t *testing.T) {
key := types.NamespacedName{Namespace: "ns", Name: "route"}

Expand Down
7 changes: 6 additions & 1 deletion pkg/i2gw/providers/ingressnginx/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,16 @@ const (
ConnectionProxyHeaderAnnotation = "nginx.ingress.kubernetes.io/connection-proxy-header"
CustomHeadersAnnotation = "nginx.ingress.kubernetes.io/custom-headers"

// Timeout annotations
ProxyConnectTimeoutAnnotation = "nginx.ingress.kubernetes.io/proxy-connect-timeout"
ProxySendTimeoutAnnotation = "nginx.ingress.kubernetes.io/proxy-send-timeout"
ProxyReadTimeoutAnnotation = "nginx.ingress.kubernetes.io/proxy-read-timeout"

// Body Size annotations
ProxyBodySizeAnnotation = "nginx.ingress.kubernetes.io/proxy-body-size"
ClientBodyBufferSizeAnnotation = "nginx.ingress.kubernetes.io/client-body-buffer-size"

// R2gex
// Regex
UseRegexAnnotation = "nginx.ingress.kubernetes.io/use-regex"

// SSL Redirect annotation
Expand Down
1 change: 1 addition & 0 deletions pkg/i2gw/providers/ingressnginx/ingressnginx.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ func (p *Provider) ToIR() (emitterir.EmitterIR, field.ErrorList) {
pIR, errs := p.resourcesToIRConverter.convert(p.storage)
eIR := providerir.ToEmitterIR(pIR)
applyRewriteTargetToEmitterIR(pIR, &eIR)
errs = append(errs, applyTimeoutsToEmitterIR(pIR, &eIR)...)
errs = append(errs, addDefaultSSLRedirect(&pIR, &eIR)...)
errs = append(errs, applyBodySizeToEmitterIR(pIR, &eIR)...)
return eIR, errs
Expand Down
140 changes: 140 additions & 0 deletions pkg/i2gw/providers/ingressnginx/timeouts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
Copyright 2026 The Kubernetes Authors.

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.
*/

package ingressnginx

import (
"fmt"
"strconv"
"strings"
"time"

emitterir "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/emitter_intermediate"
"github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/notifications"
providerir "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/provider_intermediate"
networkingv1 "k8s.io/api/networking/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
)

const timeoutMultiplier = 10

func parseIngressNginxTimeout(val string) (time.Duration, error) {
if val == "" {
return 0, fmt.Errorf("empty timeout")
}

// These annotations are specified as unitless seconds.
// https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#custom-timeouts
secs, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return 0, fmt.Errorf("must be an integer number of seconds")
}
if secs <= 0 {
return 0, fmt.Errorf("must be > 0")
}
return time.Duration(secs) * time.Second, nil
}

// applyTimeoutsToEmitterIR is a temporary bridge until timeout parsing is integrated
// into the generic feature parsing flow.
func applyTimeoutsToEmitterIR(pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) field.ErrorList {
var errList field.ErrorList

for key, httpRouteContext := range pIR.HTTPRoutes {
eHTTPContext, ok := eIR.HTTPRoutes[key]
if !ok {
continue
}
if eHTTPContext.TCPTimeoutsByRuleIdx == nil {
eHTTPContext.TCPTimeoutsByRuleIdx = make(map[int]*emitterir.TCPTimeouts, len(eHTTPContext.Spec.Rules))
}

for ruleIdx := range httpRouteContext.HTTPRoute.Spec.Rules {
if ruleIdx >= len(httpRouteContext.RuleBackendSources) {
continue
}
sources := httpRouteContext.RuleBackendSources[ruleIdx]
ingress := getNonCanaryIngress(sources)
if ingress == nil {
continue
}

connect, _ := parseIngressNginxTimeoutAnnotation(ingress, ProxyConnectTimeoutAnnotation, &errList)
read, _ := parseIngressNginxTimeoutAnnotation(ingress, ProxyReadTimeoutAnnotation, &errList)
write, _ := parseIngressNginxTimeoutAnnotation(ingress, ProxySendTimeoutAnnotation, &errList)
if connect == nil && read == nil && write == nil {
continue
}

eHTTPContext.TCPTimeoutsByRuleIdx[ruleIdx] = &emitterir.TCPTimeouts{
Connect: connect,
Read: read,
Write: write,
}

notify(notifications.InfoNotification, fmt.Sprintf("parsed ingress-nginx proxy timeouts (x%d) from %s/%s for HTTPRoute %s/%s rule %d (timeouts.request): %s",
timeoutMultiplier, ingress.Namespace, ingress.Name, key.Namespace, key.Name, ruleIdx, formatTCPTimeouts(connect, read, write)), &httpRouteContext.HTTPRoute)
notify(
notifications.WarningNotification,
"ingress-nginx only supports TCP-level timeouts; i2gw has made a best-effort translation to Gateway API timeouts.request."+
" Please verify that this meets your needs. See documentation: https://gateway-api.sigs.k8s.io/guides/http-timeouts/",
)
}

eIR.HTTPRoutes[key] = eHTTPContext
}

if len(errList) > 0 {
return errList
}
return nil
}

func parseIngressNginxTimeoutAnnotation(ingress *networkingv1.Ingress, annotation string, errList *field.ErrorList) (*gatewayv1.Duration, error) {
val, ok := ingress.Annotations[annotation]
if !ok || val == "" {
return nil, nil
}
d, err := parseIngressNginxTimeout(val)
if err != nil {
*errList = append(*errList, field.Invalid(
field.NewPath("ingress", ingress.Namespace, ingress.Name, "metadata", "annotations").Key(annotation),
val,
fmt.Sprintf("invalid timeout: %v", err),
))
return nil, err
}
gwDur := gatewayv1.Duration(d.String())
return &gwDur, nil
}

func formatTCPTimeouts(connect, read, write *gatewayv1.Duration) string {
parts := []string{}
if connect != nil {
parts = append(parts, fmt.Sprintf("connect=%s", *connect))
}
if read != nil {
parts = append(parts, fmt.Sprintf("read=%s", *read))
}
if write != nil {
parts = append(parts, fmt.Sprintf("write=%s", *write))
}
if len(parts) == 0 {
return "none"
}
return strings.Join(parts, ", ")
}
Loading