From 127e6c1eea5d45fb8773996b1141c0b1627f61d9 Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Tue, 23 Jun 2026 14:29:45 +0530 Subject: [PATCH 01/14] Adding changes for HCM level timeout configurations --- gateway/configs/config-template.toml | 12 ++ .../gateway-controller/pkg/config/config.go | 39 +++++- .../pkg/config/config_test.go | 99 +++++++++++++++ .../pkg/constants/constants.go | 4 + .../gateway-controller/pkg/xds/translator.go | 7 ++ .../pkg/xds/translator_test.go | 50 ++++++++ ...ckend_timeout.feature => timeouts.feature} | 49 +++++++- gateway/it/steps_backend_timeout.go | 60 --------- gateway/it/steps_timeouts.go | 115 ++++++++++++++++++ gateway/it/suite_test.go | 2 +- gateway/it/test-config.toml | 4 + 11 files changed, 374 insertions(+), 67 deletions(-) rename gateway/it/features/{backend_timeout.feature => timeouts.feature} (65%) delete mode 100644 gateway/it/steps_backend_timeout.go create mode 100644 gateway/it/steps_timeouts.go diff --git a/gateway/configs/config-template.toml b/gateway/configs/config-template.toml index c123dcd7a6..31ef8bae2c 100644 --- a/gateway/configs/config-template.toml +++ b/gateway/configs/config-template.toml @@ -212,6 +212,18 @@ connect_timeout_ms = 5000 server_header_transformation = "OVERWRITE" server_header_value = "WSO2 API Platform" +# HTTP Connection Manager (downstream) timeouts +# Except for idle_timeout, a value of 0 disables all other timeout settings. +[router.http_listener.timeouts] +# Max duration for the entire downstream request +request_timeout = "0s" +# Max duration to receive the complete request headers +request_headers_timeout = "0s" +# Idle timeout for a single HTTP stream/request +stream_idle_timeout = "5m" +# Idle timeout for the downstream connection +idle_timeout = "1h" + [router.policy_engine] host = "policy-engine" port = 9001 diff --git a/gateway/gateway-controller/pkg/config/config.go b/gateway/gateway-controller/pkg/config/config.go index f9e4e14319..7710ee622c 100644 --- a/gateway/gateway-controller/pkg/config/config.go +++ b/gateway/gateway-controller/pkg/config/config.go @@ -585,8 +585,17 @@ type VHostEntry struct { // HTTPListenerConfig holds HTTP listener related configuration of an API type HTTPListenerConfig struct { - ServerHeaderTransformation string `koanf:"server_header_transformation"` // Options: "APPEND_IF_ABSENT", "OVERWRITE", "PASS_THROUGH" - ServerHeaderValue string `koanf:"server_header_value"` // Custom value for the Server header + ServerHeaderTransformation string `koanf:"server_header_transformation"` // Options: "APPEND_IF_ABSENT", "OVERWRITE", "PASS_THROUGH" + ServerHeaderValue string `koanf:"server_header_value"` // Custom value for the Server header + Timeouts HCMTimeouts `koanf:"timeouts"` // HTTP Connection Manager (downstream) timeouts +} + +// HCMTimeouts holds HTTP Connection Manager (downstream/connection) timeouts. +type HCMTimeouts struct { + RequestTimeout time.Duration `koanf:"request_timeout"` // HCM request_timeout (default 0s = disabled) + RequestHeadersTimeout time.Duration `koanf:"request_headers_timeout"` // HCM request_headers_timeout (default 0s = disabled) + StreamIdleTimeout time.Duration `koanf:"stream_idle_timeout"` // HCM stream_idle_timeout (default 5m) + IdleTimeout time.Duration `koanf:"idle_timeout"` // common_http_protocol_options.idle_timeout (default 1h) } // PolicyEngineConfig holds policy engine ext_proc filter configuration @@ -976,6 +985,12 @@ func defaultConfig() *Config { HTTPListener: HTTPListenerConfig{ ServerHeaderTransformation: commonconstants.OVERWRITE, ServerHeaderValue: commonconstants.ServerName, + Timeouts: HCMTimeouts{ + RequestTimeout: 0, // 0s = disabled (Envoy default) + RequestHeadersTimeout: 0, // 0s = disabled (Envoy default) + StreamIdleTimeout: 5 * time.Minute, // Envoy default + IdleTimeout: 1 * time.Hour, // Envoy default (connection-level) + }, }, }, Analytics: AnalyticsConfig{ @@ -1676,6 +1691,26 @@ func (c *Config) validateTimeoutConfig() error { timeouts.ConnectTimeoutMs, constants.MaxReasonableTimeoutMs) } + // Validate HCM (downstream/connection) timeouts. Unlike the upstream timeouts above, + // 0 is a valid value here: which denotes "disabled scenario" + // only reject negative values and unreasonably large ones are rejected. + maxConnTimeout := time.Duration(constants.MaxReasonableConnectionTimeoutMs) * time.Millisecond + hcmTimeouts := map[string]time.Duration{ + "request_timeout": c.Router.HTTPListener.Timeouts.RequestTimeout, + "request_headers_timeout": c.Router.HTTPListener.Timeouts.RequestHeadersTimeout, + "stream_idle_timeout": c.Router.HTTPListener.Timeouts.StreamIdleTimeout, + "idle_timeout": c.Router.HTTPListener.Timeouts.IdleTimeout, + } + for name, v := range hcmTimeouts { + if v < 0 { + return fmt.Errorf("router.http_listener.timeouts.%s must not be negative, got: %s", name, v) + } + if v > maxConnTimeout { + return fmt.Errorf("router.http_listener.timeouts.%s (%s) exceeds maximum reasonable timeout of %s", + name, v, maxConnTimeout) + } + } + return nil } diff --git a/gateway/gateway-controller/pkg/config/config_test.go b/gateway/gateway-controller/pkg/config/config_test.go index c154d47649..6a23cfe474 100644 --- a/gateway/gateway-controller/pkg/config/config_test.go +++ b/gateway/gateway-controller/pkg/config/config_test.go @@ -1012,6 +1012,61 @@ func TestConfig_ValidateTimeoutConfig(t *testing.T) { } } +func TestConfig_ValidateHCMTimeouts(t *testing.T) { + maxConn := time.Duration(constants.MaxReasonableConnectionTimeoutMs) * time.Millisecond + tests := []struct { + name string + timeouts HCMTimeouts + wantErr bool + errContains string + }{ + { + name: "Envoy defaults are valid", + timeouts: HCMTimeouts{RequestTimeout: 0, RequestHeadersTimeout: 0, StreamIdleTimeout: 5 * time.Minute, IdleTimeout: time.Hour}, + }, + { + name: "All zero (disabled) is valid", + timeouts: HCMTimeouts{}, + }, + { + name: "Custom positive values are valid", + timeouts: HCMTimeouts{RequestTimeout: 30 * time.Second, RequestHeadersTimeout: 10 * time.Second, StreamIdleTimeout: time.Minute, IdleTimeout: 2 * time.Hour}, + }, + { + name: "Negative request_timeout rejected", + timeouts: HCMTimeouts{RequestTimeout: -1 * time.Second}, + wantErr: true, + errContains: "router.http_listener.timeouts.request_timeout must not be negative", + }, + { + name: "Negative stream_idle_timeout rejected", + timeouts: HCMTimeouts{StreamIdleTimeout: -5 * time.Second}, + wantErr: true, + errContains: "router.http_listener.timeouts.stream_idle_timeout must not be negative", + }, + { + name: "idle_timeout exceeding max reasonable rejected", + timeouts: HCMTimeouts{IdleTimeout: maxConn + time.Second}, + wantErr: true, + errContains: "router.http_listener.timeouts.idle_timeout", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validConfig() + cfg.Router.HTTPListener.Timeouts = tt.timeouts + err := cfg.Validate() + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + assert.NoError(t, err) + } + }) + } +} + func TestConfig_ValidatePolicyEngineConfig(t *testing.T) { tests := []struct { name string @@ -1669,6 +1724,50 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, "info", cfg.Controller.Logging.Level) assert.False(t, cfg.Controller.Server.SkipInvalidDeploymentsOnStartup) assert.Equal(t, uint32(5000), cfg.Router.Upstream.Timeouts.ConnectTimeoutMs, "default router.upstream.timeouts.connect_timeout_ms should be 5s (5000 ms)") + + // HCM timeout defaults must match Envoy's documented defaults. + hcm := cfg.Router.HTTPListener.Timeouts + assert.Equal(t, time.Duration(0), hcm.RequestTimeout, "default request_timeout should be 0s (disabled)") + assert.Equal(t, time.Duration(0), hcm.RequestHeadersTimeout, "default request_headers_timeout should be 0s (disabled)") + assert.Equal(t, 5*time.Minute, hcm.StreamIdleTimeout, "default stream_idle_timeout should be 5m") + assert.Equal(t, time.Hour, hcm.IdleTimeout, "default idle_timeout should be 1h") +} + +func TestLoadConfig_HCMTimeouts(t *testing.T) { + t.Run("explicit values parse from toml", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.toml") + toml := ` +[router.http_listener.timeouts] +request_timeout = "30s" +request_headers_timeout = "10s" +stream_idle_timeout = "2m" +idle_timeout = "30m" +` + require.NoError(t, os.WriteFile(configPath, []byte(toml), 0o644)) + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + hcm := cfg.Router.HTTPListener.Timeouts + assert.Equal(t, 30*time.Second, hcm.RequestTimeout) + assert.Equal(t, 10*time.Second, hcm.RequestHeadersTimeout) + assert.Equal(t, 2*time.Minute, hcm.StreamIdleTimeout) + assert.Equal(t, 30*time.Minute, hcm.IdleTimeout) + }) + + t.Run("omitted section falls back to Envoy defaults", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(""), 0o644)) + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + hcm := cfg.Router.HTTPListener.Timeouts + assert.Equal(t, time.Duration(0), hcm.RequestTimeout) + assert.Equal(t, time.Duration(0), hcm.RequestHeadersTimeout) + assert.Equal(t, 5*time.Minute, hcm.StreamIdleTimeout) + assert.Equal(t, time.Hour, hcm.IdleTimeout) + }) } func TestConfig_CaseInsensitiveAlgorithm(t *testing.T) { diff --git a/gateway/gateway-controller/pkg/constants/constants.go b/gateway/gateway-controller/pkg/constants/constants.go index b0741044af..cced5e512a 100644 --- a/gateway/gateway-controller/pkg/constants/constants.go +++ b/gateway/gateway-controller/pkg/constants/constants.go @@ -71,6 +71,10 @@ const ( // Configuration Validation Constants MaxReasonableTimeoutMs = uint32(3600000) // 1 hour in milliseconds MaxReasonablePolicyTimeoutMs = uint32(60000) // 60 seconds in milliseconds + + // MaxReasonableConnectionTimeoutMs caps connection-level timeouts (request, request-headers,etc.), + // allowing higher values than MaxReasonableTimeoutMs to support long-lived idle connections. + MaxReasonableConnectionTimeoutMs = uint32(86400000) // 24 hours in milliseconds // Cipher Suite Validation CipherInvalidChars1 = ";" diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index c0123c5f9c..e174c5c89b 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -1086,6 +1086,13 @@ func (t *Translator) createListener(virtualHosts []*route.VirtualHost, isHTTPS b HttpFilters: httpFilters, ServerHeaderTransformation: convertServerHeaderTransformation(t.routerConfig.HTTPListener.ServerHeaderTransformation), ServerName: t.routerConfig.HTTPListener.ServerHeaderValue, + // HCM-level (downstream) timeouts. Defaults match Envoy's documented defaults. + RequestTimeout: durationpb.New(t.routerConfig.HTTPListener.Timeouts.RequestTimeout), + RequestHeadersTimeout: durationpb.New(t.routerConfig.HTTPListener.Timeouts.RequestHeadersTimeout), + StreamIdleTimeout: durationpb.New(t.routerConfig.HTTPListener.Timeouts.StreamIdleTimeout), + CommonHttpProtocolOptions: &core.HttpProtocolOptions{ + IdleTimeout: durationpb.New(t.routerConfig.HTTPListener.Timeouts.IdleTimeout), + }, } // Add access logs if enabled diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index 80e6f87bb2..678844ab8a 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -32,6 +32,7 @@ import ( core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" route "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + hcm "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" tlsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3" resource "github.com/envoyproxy/go-control-plane/pkg/resource/v3" "github.com/stretchr/testify/assert" @@ -1107,6 +1108,55 @@ func TestTranslator_ExtractProviderName_NilSourceConfig(t *testing.T) { assert.Equal(t, "", result) } +// extractHCM pulls the HttpConnectionManager out of the listener's first filter chain. +func extractHCM(t *testing.T, lis *listener.Listener) *hcm.HttpConnectionManager { + t.Helper() + require.NotEmpty(t, lis.GetFilterChains()) + require.NotEmpty(t, lis.GetFilterChains()[0].GetFilters()) + typedConfig := lis.GetFilterChains()[0].GetFilters()[0].GetTypedConfig() + require.NotNil(t, typedConfig) + manager := &hcm.HttpConnectionManager{} + require.NoError(t, typedConfig.UnmarshalTo(manager)) + return manager +} + +func TestTranslator_CreateListener_HCMTimeouts(t *testing.T) { + tests := []struct { + name string + timeouts config.HCMTimeouts + }{ + { + name: "configured values", + timeouts: config.HCMTimeouts{RequestTimeout: 30 * time.Second, RequestHeadersTimeout: 10 * time.Second, StreamIdleTimeout: 2 * time.Minute, IdleTimeout: 30 * time.Minute}, + }, + { + name: "envoy defaults flow through unchanged", + timeouts: config.HCMTimeouts{RequestTimeout: 0, RequestHeadersTimeout: 0, StreamIdleTimeout: 5 * time.Minute, IdleTimeout: time.Hour}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := createTestLogger() + routerCfg := testRouterConfig() + routerCfg.HTTPListener.Timeouts = tt.timeouts + cfg := testConfig() + cfg.Router = *routerCfg + translator := NewTranslator(logger, routerCfg, nil, cfg) + + lis, _, err := translator.createListener(nil, false) + require.NoError(t, err) + + manager := extractHCM(t, lis) + assert.Equal(t, tt.timeouts.RequestTimeout, manager.GetRequestTimeout().AsDuration(), "request_timeout") + assert.Equal(t, tt.timeouts.RequestHeadersTimeout, manager.GetRequestHeadersTimeout().AsDuration(), "request_headers_timeout") + assert.Equal(t, tt.timeouts.StreamIdleTimeout, manager.GetStreamIdleTimeout().AsDuration(), "stream_idle_timeout") + require.NotNil(t, manager.GetCommonHttpProtocolOptions(), "common_http_protocol_options must be set") + assert.Equal(t, tt.timeouts.IdleTimeout, manager.GetCommonHttpProtocolOptions().GetIdleTimeout().AsDuration(), "idle_timeout") + }) + } +} + func TestTranslator_CreateAccessLogConfig_Disabled(t *testing.T) { // Note: createAccessLogConfig should only be called when access logs are enabled. // The check for enabled is done at the caller level. When called directly with disabled diff --git a/gateway/it/features/backend_timeout.feature b/gateway/it/features/timeouts.feature similarity index 65% rename from gateway/it/features/backend_timeout.feature rename to gateway/it/features/timeouts.feature index 655afa375d..f2ae26afeb 100644 --- a/gateway/it/features/backend_timeout.feature +++ b/gateway/it/features/timeouts.feature @@ -15,11 +15,15 @@ # under the License. # -------------------------------------------------------------------- -@backend-timeout -Feature: Backend timeout +@backend-timeout @timeouts +Feature: Timeouts As an API developer - I want backend timeout (upstreamDefinitions) to be enforced by the gateway - So that requests to slow or unreachable backends fail within the configured timeout + I want upstream (connect) and HTTP Connection Manager timeouts to be enforced by the gateway + So that requests to slow or unreachable backends, and slow downstream clients, + fail within the configured timeout + + # request_timeout, stream_idle_timeout and idle_timeout are not exercised here: + # small values would affect the whole shared suite Background: Given the gateway services are running @@ -97,3 +101,40 @@ Feature: Backend timeout When I delete the API "timeout-api-global-v1.0" Then the response should be successful + # Tests HCM request_headers_timeout (set to "5s" in it/test-config.toml). + # A raw client sends a partial request and never terminates the headers; the gateway + # must close the stream with 408 once request_headers_timeout elapses. + Scenario: HCM request_headers_timeout terminates a slow-header request + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: headers-timeout-api-v1.0 + spec: + displayName: Headers-Timeout-API + version: v1.0 + context: /headers-timeout/$version + upstreamDefinitions: + - name: headers-timeout-upstream + upstreams: + - url: http://sample-backend:9080 + upstream: + main: + ref: headers-timeout-upstream + operations: + - method: GET + path: / + """ + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + And I record the current time as "request_start" + When I open a raw connection to "localhost:8080" and send incomplete request headers for path "/headers-timeout/v1.0/" + Then the raw response status code should be "408" + And the request should have taken at least "4" seconds since "request_start" + Given I authenticate using basic auth as "admin" + When I delete the API "headers-timeout-api-v1.0" + Then the response should be successful + diff --git a/gateway/it/steps_backend_timeout.go b/gateway/it/steps_backend_timeout.go deleted file mode 100644 index c5d8778acd..0000000000 --- a/gateway/it/steps_backend_timeout.go +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. 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 it - -import ( - "fmt" - "strconv" - "time" - - "github.com/cucumber/godog" -) - -// elapsedTimeToleranceSeconds is the tolerance when asserting minimum elapsed time (e.g. clock skew, scheduling). -const elapsedTimeToleranceSeconds = 1 - -// RegisterBackendTimeoutSteps registers step definitions for backend timeout scenarios -func RegisterBackendTimeoutSteps(ctx *godog.ScenarioContext, state *TestState) { - ctx.Step(`^I record the current time as "([^"]*)"$`, func(key string) error { - state.SetContextValue(key, time.Now()) - return nil - }) - - ctx.Step(`^the request should have taken at least "(\d+)" seconds since "([^"]*)"$`, func(expectedSecondsStr, key string) error { - expectedSeconds, err := strconv.Atoi(expectedSecondsStr) - if err != nil { - return fmt.Errorf("expected seconds must be a number, got: %s", expectedSecondsStr) - } - val, ok := state.GetContextValue(key) - if !ok { - return fmt.Errorf("no start time recorded; record the current time as \"request_start\" before sending the request") - } - start, ok := val.(time.Time) - if !ok { - return fmt.Errorf("no start time recorded; record the current time as %q before sending the request", key) - } - elapsed := time.Since(start) - minElapsed := time.Duration(expectedSeconds-elapsedTimeToleranceSeconds) * time.Second - if elapsed < minElapsed { - return fmt.Errorf("request should have taken at least %d seconds (with %ds tolerance), but elapsed time was %s", - expectedSeconds, elapsedTimeToleranceSeconds, elapsed.Round(time.Millisecond)) - } - return nil - }) -} diff --git a/gateway/it/steps_timeouts.go b/gateway/it/steps_timeouts.go new file mode 100644 index 0000000000..744353fef6 --- /dev/null +++ b/gateway/it/steps_timeouts.go @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. 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 it + +import ( + "fmt" + "io" + "net" + "strconv" + "strings" + "time" + + "github.com/cucumber/godog" +) + +// elapsedTimeToleranceSeconds is the tolerance when asserting minimum elapsed time (e.g. clock skew, scheduling). +const elapsedTimeToleranceSeconds = 1 + +// rawResponseContextKey is where the slow-header raw response is stashed for later assertion. +const rawResponseContextKey = "raw_timeout_response" + +// RegisterTimeoutSteps registers step definitions for upstream and HCM timeout scenarios +func RegisterTimeoutSteps(ctx *godog.ScenarioContext, state *TestState) { + ctx.Step(`^I record the current time as "([^"]*)"$`, func(key string) error { + state.SetContextValue(key, time.Now()) + return nil + }) + + ctx.Step(`^the request should have taken at least "(\d+)" seconds since "([^"]*)"$`, func(expectedSecondsStr, key string) error { + expectedSeconds, err := strconv.Atoi(expectedSecondsStr) + if err != nil { + return fmt.Errorf("expected seconds must be a number, got: %s", expectedSecondsStr) + } + val, ok := state.GetContextValue(key) + if !ok { + return fmt.Errorf("no start time recorded; record the current time as \"request_start\" before sending the request") + } + start, ok := val.(time.Time) + if !ok { + return fmt.Errorf("no start time recorded; record the current time as %q before sending the request", key) + } + elapsed := time.Since(start) + minElapsed := time.Duration(expectedSeconds-elapsedTimeToleranceSeconds) * time.Second + if elapsed < minElapsed { + return fmt.Errorf("request should have taken at least %d seconds (with %ds tolerance), but elapsed time was %s", + expectedSeconds, elapsedTimeToleranceSeconds, elapsed.Round(time.Millisecond)) + } + return nil + }) + + // Opens a raw TCP connection and sends a request line plus one header, but never + // terminates the header block (no final blank line). This forces the HCM + // request_headers_timeout to fire. The connection blocks until the gateway responds + // or closes it, and the raw response is stored for assertion. + ctx.Step(`^I open a raw connection to "([^"]*)" and send incomplete request headers for path "([^"]*)"$`, func(address, path string) error { + conn, err := net.DialTimeout("tcp", address, 10*time.Second) + if err != nil { + return fmt.Errorf("failed to connect to %s: %w", address, err) + } + defer conn.Close() + + // Request line + Host header, with NO terminating CRLF that ends the headers. + partial := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s\r\n", path, address) + if _, err := conn.Write([]byte(partial)); err != nil { + return fmt.Errorf("failed to send partial request: %w", err) + } + + // Read until the gateway responds and closes, bounded by a deadline that is + // comfortably longer than request_headers_timeout so we capture the 408. + if err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil { + return fmt.Errorf("failed to set read deadline: %w", err) + } + raw, err := io.ReadAll(conn) + if err != nil && len(raw) == 0 { + return fmt.Errorf("failed to read response from gateway: %w", err) + } + state.SetContextValue(rawResponseContextKey, string(raw)) + return nil + }) + + ctx.Step(`^the raw response status code should be "([^"]*)"$`, func(expectedCode string) error { + val, ok := state.GetContextValue(rawResponseContextKey) + if !ok { + return fmt.Errorf("no raw response recorded; open a raw connection first") + } + raw, ok := val.(string) + if !ok { + return fmt.Errorf("recorded raw response has unexpected type %T", val) + } + statusLine := raw + if idx := strings.Index(raw, "\r\n"); idx >= 0 { + statusLine = raw[:idx] + } + if !strings.Contains(statusLine, expectedCode) { + return fmt.Errorf("expected raw response status line to contain %q, got %q", expectedCode, statusLine) + } + return nil + }) +} diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index 4cbcf0338e..3496ebcd33 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -339,7 +339,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) { RegisterMetricsSteps(ctx, testState, httpSteps) RegisterAuthSteps(ctx, testState, httpSteps) RegisterAPISteps(ctx, testState, httpSteps) - RegisterBackendTimeoutSteps(ctx, testState) + RegisterTimeoutSteps(ctx, testState) RegisterMCPSteps(ctx, testState, httpSteps, jwtSteps) RegisterLLMSteps(ctx, testState, httpSteps) RegisterJWTSteps(ctx, testState, httpSteps, jwtSteps) diff --git a/gateway/it/test-config.toml b/gateway/it/test-config.toml index 187d32390f..4833aee646 100644 --- a/gateway/it/test-config.toml +++ b/gateway/it/test-config.toml @@ -72,6 +72,10 @@ enabled = true enabled = true format = "text" +[router.http_listener.timeouts] +# This is the only HCM timeout set to a small, testable value here. +request_headers_timeout = "5s" + # ============================================================================= # POLICY ENGINE CONFIGURATION # ============================================================================= From f9c6d6dedd3c9fa4e3e9da2e6b52f834b538efac Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Wed, 24 Jun 2026 07:13:13 +0530 Subject: [PATCH 02/14] Adding backend timeout support for resiliency --- .../api/management-openapi.yaml | 23 + .../pkg/api/management/generated.go | 569 +++++++++--------- .../pkg/config/api_validator.go | 45 ++ .../pkg/config/api_validator_test.go | 48 ++ .../pkg/models/runtime_deploy_config.go | 7 +- .../pkg/transform/restapi.go | 34 ++ .../pkg/transform/restapi_test.go | 74 +++ .../gateway-controller/pkg/xds/translator.go | 127 +++- .../pkg/xds/translator_test.go | 47 ++ gateway/it/features/backend-timeout.feature | 135 +++++ gateway/it/suite_test.go | 2 + .../api/v1alpha1/restapi_types.go | 25 + .../api/v1alpha1/zz_generated.deepcopy.go | 35 ++ ...ateway.api-platform.wso2.com_restapis.yaml | 30 + .../config/samples/api_v1_restapi.yaml | 7 + ...ateway.api-platform.wso2.com_restapis.yaml | 30 + 16 files changed, 941 insertions(+), 297 deletions(-) create mode 100644 gateway/it/features/backend-timeout.feature diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 640796a9fc..a153925e0b 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -4194,6 +4194,8 @@ components: description: List of API-level policies applied to all operations unless overridden items: $ref: "#/components/schemas/Policy" + resilience: + $ref: "#/components/schemas/Resilience" operations: type: array description: List of HTTP operations/routes @@ -4258,6 +4260,25 @@ components: pattern: '^\d+(\.\d+)?(ms|s|m|h)$' example: 5s + Resilience: + type: object + description: > + Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. + Can be set at the API level (applies to all routes) and/or the operation level + (applies to that operation's route). When set at both levels, the operation-level + value takes precedence. When unset, the gateway's global route timeout defaults apply. + properties: + timeout: + type: string + description: Maximum time for the entire route (request to upstream response). "0s" disables the timeout. + pattern: '^\d+(\.\d+)?(ms|s|m|h)$' + example: 15s + idleTimeout: + type: string + description: Per-route stream idle timeout (overrides the listener stream idle timeout for this route). "0s" disables the timeout. + pattern: '^\d+(\.\d+)?(ms|s|m|h)$' + example: 0s + Upstream: type: object oneOf: @@ -4313,6 +4334,8 @@ components: description: List of policies applied only to this operation (overrides or adds to API-level policies) items: $ref: "#/components/schemas/Policy" + resilience: + $ref: "#/components/schemas/Resilience" Policy: type: object diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index 944ff642d3..6d4dc80730 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -504,6 +504,9 @@ type APIConfigData struct { // Policies List of API-level policies applied to all operations unless overridden Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + // Resilience Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply. + Resilience *Resilience `json:"resilience,omitempty" yaml:"resilience,omitempty"` + // SubscriptionPlans List of subscription plan names available for this API SubscriptionPlans *[]string `json:"subscriptionPlans,omitempty" yaml:"subscriptionPlans,omitempty"` @@ -1228,6 +1231,9 @@ type Operation struct { // Policies List of policies applied only to this operation (overrides or adds to API-level policies) Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + + // Resilience Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply. + Resilience *Resilience `json:"resilience,omitempty" yaml:"resilience,omitempty"` } // OperationMethod HTTP method @@ -1269,6 +1275,15 @@ type Policy struct { Version string `json:"version" yaml:"version"` } +// Resilience Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply. +type Resilience struct { + // IdleTimeout Per-route stream idle timeout (overrides the listener stream idle timeout for this route). "0s" disables the timeout. + IdleTimeout *string `json:"idleTimeout,omitempty" yaml:"idleTimeout,omitempty"` + + // Timeout Maximum time for the entire route (request to upstream response). "0s" disables the timeout. + Timeout *string `json:"timeout,omitempty" yaml:"timeout,omitempty"` +} + // ResourceStatus Server-managed lifecycle information for a resource type ResourceStatus struct { // CreatedAt Timestamp when the resource was first created (UTC) @@ -5762,285 +5777,281 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/config/api_validator.go b/gateway/gateway-controller/pkg/config/api_validator.go index bb0698f66a..34ba29a49f 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -436,12 +436,54 @@ func (v *APIValidator) validateRestData(spec *api.APIConfigData) []ValidationErr errors = append(errors, v.validateUpstream("sandbox", spec.Upstream.Sandbox, spec.UpstreamDefinitions)...) } + // Validate API-level resilience block + errors = append(errors, v.validateResilience("spec.resilience", spec.Resilience)...) + // Validate operations errors = append(errors, v.validateOperations(spec.Operations)...) return errors } +// validateResilience validates a resilience block (timeout / idleTimeout). Both fields +// are optional duration strings; "0s" is allowed (disables the timeout), negative and +// malformed values are rejected. fieldPrefix is the path to the block (e.g. +// "spec.resilience" or "spec.operations[2].resilience"). +func (v *APIValidator) validateResilience(fieldPrefix string, r *api.Resilience) []ValidationError { + var errors []ValidationError + if r == nil { + return errors + } + + validate := func(field string, value *string) { + if value == nil { + return + } + s := strings.TrimSpace(*value) + if s == "" { + return + } + d, err := time.ParseDuration(s) + if err != nil { + errors = append(errors, ValidationError{ + Field: field, + Message: fmt.Sprintf("Invalid timeout format: %v (expected format: '30s', '1m', '500ms', or '0s' to disable)", err), + }) + return + } + if d < 0 { + errors = append(errors, ValidationError{ + Field: field, + Message: "Timeout must not be negative", + }) + } + } + + validate(fieldPrefix+".timeout", r.Timeout) + validate(fieldPrefix+".idleTimeout", r.IdleTimeout) + return errors +} + // validateAsyncData validates the data section of the configuration for http/rest kind func (v *APIValidator) validateAsyncData(spec *api.WebhookAPIData) []ValidationError { var errors []ValidationError @@ -621,6 +663,9 @@ func (v *APIValidator) validateOperations(operations []api.Operation) []Validati Message: "Operation path has unbalanced braces in parameters", }) } + + // Validate operation-level resilience block + errors = append(errors, v.validateResilience(fmt.Sprintf("spec.operations[%d].resilience", i), op.Resilience)...) } return errors diff --git a/gateway/gateway-controller/pkg/config/api_validator_test.go b/gateway/gateway-controller/pkg/config/api_validator_test.go index f93a0d58d4..ec8c32cf55 100644 --- a/gateway/gateway-controller/pkg/config/api_validator_test.go +++ b/gateway/gateway-controller/pkg/config/api_validator_test.go @@ -478,6 +478,54 @@ func TestAPIValidator_ValidateOperations(t *testing.T) { } } +func TestAPIValidator_ValidateResilience(t *testing.T) { + v := NewAPIValidator() + + tests := []struct { + name string + apiRes *api.Resilience + opRes *api.Resilience + wantError bool + errField string + }{ + {name: "No resilience is valid", wantError: false}, + {name: "Valid API-level timeout + idleTimeout", apiRes: &api.Resilience{Timeout: stringPtr("15s"), IdleTimeout: stringPtr("0s")}, wantError: false}, + {name: "Valid operation-level timeout", opRes: &api.Resilience{Timeout: stringPtr("2s")}, wantError: false}, + {name: "0s is allowed (disabled)", apiRes: &api.Resilience{Timeout: stringPtr("0s"), IdleTimeout: stringPtr("0s")}, wantError: false}, + {name: "Invalid API-level timeout format", apiRes: &api.Resilience{Timeout: stringPtr("15seconds")}, wantError: true, errField: "spec.resilience.timeout"}, + {name: "Invalid API-level idleTimeout format", apiRes: &api.Resilience{IdleTimeout: stringPtr("abc")}, wantError: true, errField: "spec.resilience.idleTimeout"}, + {name: "Negative API-level timeout rejected", apiRes: &api.Resilience{Timeout: stringPtr("-5s")}, wantError: true, errField: "spec.resilience.timeout"}, + {name: "Invalid operation-level timeout format", opRes: &api.Resilience{Timeout: stringPtr("nope")}, wantError: true, errField: "spec.operations[0].resilience.timeout"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := createValidRestAPIConfig() + cfg.Spec.Resilience = tt.apiRes + cfg.Spec.Operations[0].Resilience = tt.opRes + + errors := v.Validate(cfg) + hasExpectedError := false + for _, e := range errors { + if e.Field == tt.errField { + hasExpectedError = true + break + } + } + if tt.wantError && !hasExpectedError { + t.Errorf("expected error for field %s, got: %v", tt.errField, errors) + } + if !tt.wantError { + for _, e := range errors { + if strings.Contains(e.Field, "resilience") { + t.Errorf("unexpected resilience error: %v", e) + } + } + } + }) + } +} + func TestAPIValidator_ValidateAllHTTPMethods(t *testing.T) { v := NewAPIValidator() diff --git a/gateway/gateway-controller/pkg/models/runtime_deploy_config.go b/gateway/gateway-controller/pkg/models/runtime_deploy_config.go index 8dec6fa8c7..e5111228ec 100644 --- a/gateway/gateway-controller/pkg/models/runtime_deploy_config.go +++ b/gateway/gateway-controller/pkg/models/runtime_deploy_config.go @@ -62,8 +62,13 @@ type Route struct { } // RouteTimeout holds parsed timeout values for a route. +// Timeout and IdleTimeout come from the resilience block (operation-level overriding +// API-level). A nil field means "not configured" — the global route timeout default +// applies. A non-nil zero value means "explicitly disabled". type RouteTimeout struct { - Connect *time.Duration + Connect *time.Duration + Timeout *time.Duration // route timeout -> RouteAction.Timeout + IdleTimeout *time.Duration // route idle timeout -> RouteAction.IdleTimeout } // RouteUpstream links a route to its upstream cluster. diff --git a/gateway/gateway-controller/pkg/transform/restapi.go b/gateway/gateway-controller/pkg/transform/restapi.go index 8d404dca3d..053fdb3223 100644 --- a/gateway/gateway-controller/pkg/transform/restapi.go +++ b/gateway/gateway-controller/pkg/transform/restapi.go @@ -24,6 +24,7 @@ import ( "net/url" "strconv" "strings" + "time" commonconstants "github.com/wso2/api-platform/common/constants" versionutil "github.com/wso2/api-platform/common/version" @@ -151,8 +152,22 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim return nil, fmt.Errorf("sandbox upstream is configured but resolves to the same vhost %q as the main upstream; configure distinct vhosts to avoid route conflicts", effectiveMainVHost) } + // Resolve API-level resilience timeouts once; operation-level values override these. + apiTimeout, apiIdleTimeout, err := xds.ResolveResilience(apiData.Resilience) + if err != nil { + return nil, fmt.Errorf("invalid API-level resilience: %w", err) + } + // Build routes and policy chains for each operation for _, op := range apiData.Operations { + // Operation-level resilience overrides API-level (per field); nil leaves the + // global route timeout default in effect. + opTimeout, opIdleTimeout, err := xds.ResolveResilience(op.Resilience) + if err != nil { + return nil, fmt.Errorf("invalid resilience for operation %s %s: %w", op.Method, op.Path, err) + } + routeTimeout := buildRouteTimeout(opTimeout, apiTimeout, opIdleTimeout, apiIdleTimeout) + vhosts := []string{effectiveMainVHost} if hasSandbox { vhosts = append(vhosts, effectiveSandboxVHost) @@ -168,6 +183,7 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim OperationPath: op.Path, Vhost: vhost, AutoHostRewrite: mainAutoHostRewrite, + Timeout: routeTimeout, Upstream: models.RouteUpstream{ ClusterKey: mainUpstream.ClusterKey, UseClusterHeader: useClusterHeader, @@ -246,6 +262,24 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim } // collectAPIPolicies validates and collects API-level policies into SDK format. +// buildRouteTimeout applies operation-over-API precedence (per field) and returns a +// *models.RouteTimeout, or nil when neither level configured any timeout (so the global +// route timeout default applies). +func buildRouteTimeout(opTimeout, apiTimeout, opIdle, apiIdle *time.Duration) *models.RouteTimeout { + timeout := opTimeout + if timeout == nil { + timeout = apiTimeout + } + idle := opIdle + if idle == nil { + idle = apiIdle + } + if timeout == nil && idle == nil { + return nil + } + return &models.RouteTimeout{Timeout: timeout, IdleTimeout: idle} +} + func (t *RestAPITransformer) collectAPIPolicies(policies *[]api.Policy) map[string]policyenginev1.PolicyInstance { result := make(map[string]policyenginev1.PolicyInstance) if policies == nil { diff --git a/gateway/gateway-controller/pkg/transform/restapi_test.go b/gateway/gateway-controller/pkg/transform/restapi_test.go index 5edf451df9..2f5835715c 100644 --- a/gateway/gateway-controller/pkg/transform/restapi_test.go +++ b/gateway/gateway-controller/pkg/transform/restapi_test.go @@ -21,6 +21,7 @@ package transform import ( "net/url" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -84,6 +85,79 @@ func makeRestAPIStoredConfig(apiPolicies []api.Policy, opPolicies []api.Policy) } } +// makeRestAPIStoredConfigWithResilience builds a RestAPI StoredConfig whose single +// operation (GET /hello) carries optional API-level and operation-level resilience blocks. +func makeRestAPIStoredConfigWithResilience(apiRes, opRes *api.Resilience) *models.StoredConfig { + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "1.0.0", + Resilience: apiRes, + Operations: []api.Operation{ + {Method: "GET", Path: "/hello", Resilience: opRes}, + }, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: ptrStr("http://backend:8080")}, + }, + } + return &models.StoredConfig{ + UUID: "test-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{Kind: api.RestAPIKindRestApi, Metadata: api.Metadata{Name: "test-api"}, Spec: apiData}, + } +} + +func TestRestAPITransformer_ResiliencePrecedence(t *testing.T) { + const routeKey = "GET|/test/hello|main.local" + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + t.Run("operation-level overrides API-level", func(t *testing.T) { + cfg := makeRestAPIStoredConfigWithResilience( + &api.Resilience{Timeout: ptrStr("10s"), IdleTimeout: ptrStr("30s")}, + &api.Resilience{Timeout: ptrStr("2s")}, + ) + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + rt := rdc.Routes[routeKey].Timeout + require.NotNil(t, rt) + require.NotNil(t, rt.Timeout) + assert.Equal(t, 2*time.Second, *rt.Timeout, "operation timeout should win") + require.NotNil(t, rt.IdleTimeout) + assert.Equal(t, 30*time.Second, *rt.IdleTimeout, "idleTimeout falls back to API-level when op omits it") + }) + + t.Run("API-level applies when operation omits resilience", func(t *testing.T) { + cfg := makeRestAPIStoredConfigWithResilience(&api.Resilience{Timeout: ptrStr("7s")}, nil) + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + rt := rdc.Routes[routeKey].Timeout + require.NotNil(t, rt) + require.NotNil(t, rt.Timeout) + assert.Equal(t, 7*time.Second, *rt.Timeout) + assert.Nil(t, rt.IdleTimeout) + }) + + t.Run("no resilience leaves Timeout nil (global default applies)", func(t *testing.T) { + cfg := makeRestAPIStoredConfigWithResilience(nil, nil) + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + assert.Nil(t, rdc.Routes[routeKey].Timeout) + }) + + t.Run("0s is preserved as explicit disable", func(t *testing.T) { + cfg := makeRestAPIStoredConfigWithResilience(&api.Resilience{Timeout: ptrStr("0s")}, nil) + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + rt := rdc.Routes[routeKey].Timeout + require.NotNil(t, rt) + require.NotNil(t, rt.Timeout) + assert.Equal(t, time.Duration(0), *rt.Timeout) + }) +} + // findPolicyInChain returns true if any policy chain for the given route key // contains a policy with the given name. func findPolicyInChain(rdc *models.RuntimeDeployConfig, routeKey, policyName string) bool { diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index e174c5c89b..efab56d459 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -97,9 +97,11 @@ type Translator struct { } // resolvedTimeout represents parsed timeout values for an upstream. -// Currently only connect timeout is supported at the upstream definition level. +// Route and Idle come from the resilience block. type resolvedTimeout struct { Connect *time.Duration + Route *time.Duration + Idle *time.Duration } // NewTranslator creates a new translator @@ -223,6 +225,16 @@ func (t *Translator) translateRuntimeConfig(rdc *models.RuntimeDeployConfig) ([] return routes, clusters, nil } +// routeTimeoutOrDefault returns the per-route timeout when configured (including an +// explicit zero, which disables the timeout in Envoy), otherwise the global default +// expressed in milliseconds. +func (t *Translator) routeTimeoutOrDefault(v *time.Duration, defaultMs uint32) *durationpb.Duration { + if v != nil { + return durationpb.New(*v) + } + return durationpb.New(time.Duration(defaultMs) * time.Millisecond) +} + // createRouteFromRDC creates an Envoy route from a RuntimeDeployConfig Route. func (t *Translator) createRouteFromRDC(routeKey string, rdcRoute *models.Route, rdc *models.RuntimeDeployConfig) *route.Route { fullPath := rdcRoute.Path @@ -267,15 +279,17 @@ func (t *Translator) createRouteFromRDC(routeKey string, rdcRoute *models.Route, } } - // Build route action with timeouts + // Build route action with timeouts. Per-route resilience values (from the API/operation + // resilience block) take precedence; otherwise fall back to the global route defaults. + var routeResilienceTimeout, routeResilienceIdle *time.Duration + if rdcRoute.Timeout != nil { + routeResilienceTimeout = rdcRoute.Timeout.Timeout + routeResilienceIdle = rdcRoute.Timeout.IdleTimeout + } routeAction := &route.Route_Route{ Route: &route.RouteAction{ - Timeout: durationpb.New( - time.Duration(t.routerConfig.Upstream.Timeouts.RouteTimeoutMs) * time.Millisecond, - ), - IdleTimeout: durationpb.New( - time.Duration(t.routerConfig.Upstream.Timeouts.RouteIdleTimeoutMs) * time.Millisecond, - ), + Timeout: t.routeTimeoutOrDefault(routeResilienceTimeout, t.routerConfig.Upstream.Timeouts.RouteTimeoutMs), + IdleTimeout: t.routeTimeoutOrDefault(routeResilienceIdle, t.routerConfig.Upstream.Timeouts.RouteIdleTimeoutMs), }, } @@ -862,13 +876,25 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* } } + // Resolve API-level resilience timeouts once; operation-level values override per field. + apiTimeout, apiIdleTimeout, err := ResolveResilience(apiData.Resilience) + if err != nil { + return nil, nil, fmt.Errorf("invalid API-level resilience: %w", err) + } + for _, op := range apiData.Operations { // Determine if dynamic cluster selection should be used // When upstreamDefinitions exist, use cluster_header routing so policies can select the upstream useClusterHeader := apiData.UpstreamDefinitions != nil && len(*apiData.UpstreamDefinitions) > 0 + opTimeout, opIdleTimeout, err := ResolveResilience(op.Resilience) + if err != nil { + return nil, nil, fmt.Errorf("invalid resilience for operation %s %s: %w", op.Method, op.Path, err) + } + opTimeoutCfg := combineRouteResilience(mainTimeout, apiTimeout, apiIdleTimeout, opTimeout, opIdleTimeout) + r := t.createRoute(cfg.UUID, apiData.DisplayName, apiData.Version, apiData.Context, string(op.Method), op.Path, - mainClusterName, parsedMainURL.Path, effectiveMainVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Main.HostRewrite, apiProjectID, mainTimeout, useClusterHeader, upstreamDefPaths) + mainClusterName, parsedMainURL.Path, effectiveMainVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Main.HostRewrite, apiProjectID, opTimeoutCfg, useClusterHeader, upstreamDefPaths) mainRoutesList = append(mainRoutesList, r) } routesList = append(routesList, mainRoutesList...) @@ -894,8 +920,14 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* sbRoutesList := make([]*route.Route, 0) sbUseClusterHeader := apiData.UpstreamDefinitions != nil && len(*apiData.UpstreamDefinitions) > 0 for _, op := range apiData.Operations { + opTimeout, opIdleTimeout, err := ResolveResilience(op.Resilience) + if err != nil { + return nil, nil, fmt.Errorf("invalid resilience for operation %s %s: %w", op.Method, op.Path, err) + } + opTimeoutCfg := combineRouteResilience(sbTimeout, apiTimeout, apiIdleTimeout, opTimeout, opIdleTimeout) + r := t.createRoute(cfg.UUID, apiData.DisplayName, apiData.Version, apiData.Context, string(op.Method), op.Path, - sbClusterName, parsedSbURL.Path, effectiveSandboxVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Sandbox.HostRewrite, apiProjectID, sbTimeout, sbUseClusterHeader, upstreamDefPaths) + sbClusterName, parsedSbURL.Path, effectiveSandboxVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Sandbox.HostRewrite, apiProjectID, opTimeoutCfg, sbUseClusterHeader, upstreamDefPaths) sbRoutesList = append(sbRoutesList, r) } routesList = append(routesList, sbRoutesList...) @@ -1857,15 +1889,17 @@ func (t *Translator) createRoute(apiId, apiName, apiVersion, context, method, pa } } - // Currently the route level timeouts are configurable using the global configuration. + // Route-level timeouts: per-route resilience values (resilience block) take precedence, + // otherwise fall back to the global configuration defaults. + var routeTimeout, routeIdleTimeout *time.Duration + if timeoutCfg != nil { + routeTimeout = timeoutCfg.Route + routeIdleTimeout = timeoutCfg.Idle + } routeAction := &route.Route_Route{ Route: &route.RouteAction{ - Timeout: durationpb.New( - time.Duration(t.routerConfig.Upstream.Timeouts.RouteTimeoutMs) * time.Millisecond, - ), - IdleTimeout: durationpb.New( - time.Duration(t.routerConfig.Upstream.Timeouts.RouteIdleTimeoutMs) * time.Millisecond, - ), + Timeout: t.routeTimeoutOrDefault(routeTimeout, t.routerConfig.Upstream.Timeouts.RouteTimeoutMs), + IdleTimeout: t.routeTimeoutOrDefault(routeIdleTimeout, t.routerConfig.Upstream.Timeouts.RouteIdleTimeoutMs), }, } @@ -3211,6 +3245,65 @@ func parseTimeout(timeoutStr *string) (*time.Duration, error) { return &duration, nil } +// parseDurationAllowZero parses a duration string (e.g. "15s", "0s") into a *time.Duration. +// Unlike parseTimeout it accepts zero ("0s" means the timeout is explicitly disabled), +// rejecting only malformed and negative values. Returns nil for nil/empty input. +func parseDurationAllowZero(timeoutStr *string) (*time.Duration, error) { + if timeoutStr == nil || strings.TrimSpace(*timeoutStr) == "" { + return nil, nil + } + + duration, err := time.ParseDuration(strings.TrimSpace(*timeoutStr)) + if err != nil { + return nil, fmt.Errorf("invalid timeout format: %w", err) + } + + if duration < 0 { + return nil, fmt.Errorf("timeout must not be negative, got: %v", duration) + } + + return &duration, nil +} + +// ResolveResilience parses a resilience block into route timeout and idle-timeout durations. +// A nil block, or unset fields, yield nil durations (meaning "use the global default"). +// "0s" yields a non-nil zero duration (meaning "explicitly disabled"). +func ResolveResilience(r *api.Resilience) (timeout *time.Duration, idleTimeout *time.Duration, err error) { + if r == nil { + return nil, nil, nil + } + if timeout, err = parseDurationAllowZero(r.Timeout); err != nil { + return nil, nil, fmt.Errorf("invalid resilience.timeout: %w", err) + } + if idleTimeout, err = parseDurationAllowZero(r.IdleTimeout); err != nil { + return nil, nil, fmt.Errorf("invalid resilience.idleTimeout: %w", err) + } + return timeout, idleTimeout, nil +} + +// combineRouteResilience returns a resolvedTimeout for a single route, preserving the +// upstream connect timeout from base and applying the effective route/idle timeouts +// (operation-level overriding API-level, per field). It returns base unchanged when no +// resilience is configured at either level. +func combineRouteResilience(base *resolvedTimeout, apiTimeout, apiIdle, opTimeout, opIdle *time.Duration) *resolvedTimeout { + effTimeout := opTimeout + if effTimeout == nil { + effTimeout = apiTimeout + } + effIdle := opIdle + if effIdle == nil { + effIdle = apiIdle + } + if effTimeout == nil && effIdle == nil { + return base + } + rt := resolvedTimeout{Route: effTimeout, Idle: effIdle} + if base != nil { + rt.Connect = base.Connect + } + return &rt +} + // resolveTimeoutFromDefinition converts an UpstreamDefinition's timeout block into a resolvedTimeout. // Returns nil if there is no timeout block or all fields are empty. func resolveTimeoutFromDefinition(def *api.UpstreamDefinition) (*resolvedTimeout, error) { diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index 678844ab8a..babf2a8500 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -790,6 +790,53 @@ func TestTranslator_WildcardUpstreamRewriteFromRDC(t *testing.T) { } } +// TestTranslator_RouteResilienceTimeoutsFromRDC verifies that per-route resilience +// timeouts on a models.Route flow into the Envoy RouteAction, with fallback to the +// global defaults (60s / 300s from testRouterConfig) when unset, and that an explicit +// 0s is preserved (disables the timeout). +func TestTranslator_RouteResilienceTimeoutsFromRDC(t *testing.T) { + logger := createTestLogger() + routerCfg := testRouterConfig() + cfg := testConfig() + translator := NewTranslator(logger, routerCfg, nil, cfg) + + dur := func(d time.Duration) *time.Duration { return &d } + + tests := []struct { + name string + timeout *models.RouteTimeout + wantTimeout time.Duration + wantIdle time.Duration + }{ + {name: "nil timeout uses global defaults", timeout: nil, wantTimeout: 60 * time.Second, wantIdle: 300 * time.Second}, + {name: "configured values applied", timeout: &models.RouteTimeout{Timeout: dur(2 * time.Second), IdleTimeout: dur(10 * time.Second)}, wantTimeout: 2 * time.Second, wantIdle: 10 * time.Second}, + {name: "timeout set, idle falls back", timeout: &models.RouteTimeout{Timeout: dur(3 * time.Second)}, wantTimeout: 3 * time.Second, wantIdle: 300 * time.Second}, + {name: "explicit 0s disables route timeout", timeout: &models.RouteTimeout{Timeout: dur(0)}, wantTimeout: 0, wantIdle: 300 * time.Second}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rdc := &models.RuntimeDeployConfig{ + UpstreamClusters: map[string]*models.UpstreamCluster{ + "main": {Endpoints: []models.Endpoint{{Host: "echo", Port: 80}}}, + }, + } + rdcRoute := &models.Route{ + Method: "GET", + Path: "/api/v1.0/items", + OperationPath: "/items", + AutoHostRewrite: true, + Timeout: tt.timeout, + Upstream: models.RouteUpstream{ClusterKey: "main"}, + } + r := translator.createRouteFromRDC("GET|/api/v1.0/items|", rdcRoute, rdc) + require.NotNil(t, r) + assert.Equal(t, tt.wantTimeout, r.GetRoute().GetTimeout().AsDuration(), "route timeout") + assert.Equal(t, tt.wantIdle, r.GetRoute().GetIdleTimeout().AsDuration(), "route idle timeout") + }) + } +} + // TestTranslator_MCPUpstreamRewriteFromRDC verifies the MCP "/mcp"-not-appended behavior on the // RuntimeDeployConfig path (createRouteFromRDC), which the policy/runtime xDS pipeline uses. func TestTranslator_MCPUpstreamRewriteFromRDC(t *testing.T) { diff --git a/gateway/it/features/backend-timeout.feature b/gateway/it/features/backend-timeout.feature new file mode 100644 index 0000000000..031a28ca86 --- /dev/null +++ b/gateway/it/features/backend-timeout.feature @@ -0,0 +1,135 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. 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. +# -------------------------------------------------------------------- + +@backend-timeout @resilience +Feature: Backend route timeouts via the resilience block + As an API developer + I want to configure the route timeout via a resilience block at the API and operation level + So that requests to slow backends are terminated by the gateway within the configured time + + # These scenarios exercise resilience.timeout (Envoy RouteAction.Timeout). The slow + # backend is httpbin (echo-backend) /delay/{n}, which sleeps n seconds before responding; + # when the route timeout is shorter than the delay, the gateway returns 504. + # resilience.idleTimeout maps to RouteAction.IdleTimeout and is covered by unit tests + # (it cannot be exercised deterministically over HTTP here). + + Background: + Given the gateway services are running + + # API-level resilience.timeout (2s) is shorter than the backend delay (5s), so the + # gateway must time the route out with 504 at ~2s instead of waiting for the backend. + Scenario: API-level resilience timeout terminates a slow backend + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: backend-timeout-api-v1.0 + spec: + displayName: Backend-Timeout-API + version: v1.0 + context: /backend-timeout-api/$version + upstream: + main: + url: http://echo-backend:80 + resilience: + timeout: 2s + operations: + - method: GET + path: /get + - method: GET + path: /delay/5 + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/backend-timeout-api/v1.0/get" to be ready + And I record the current time as "request_start" + When I send a GET request to "http://localhost:8080/backend-timeout-api/v1.0/delay/5" + Then the response status code should be 504 + And the request should have taken at least "2" seconds since "request_start" + Given I authenticate using basic auth as "admin" + When I delete the API "backend-timeout-api-v1.0" + Then the response should be successful + + # Operation-level resilience overrides the API level: the API-level timeout (10s) is + # longer than the backend delay (5s) and would let the request succeed, but the + # operation-level timeout (2s) wins, so the gateway returns 504 at ~2s. + Scenario: Operation-level resilience timeout overrides the API level + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: backend-timeout-override-v1.0 + spec: + displayName: Backend-Timeout-Override + version: v1.0 + context: /backend-timeout-override/$version + upstream: + main: + url: http://echo-backend:80 + resilience: + timeout: 10s + operations: + - method: GET + path: /get + - method: GET + path: /delay/5 + resilience: + timeout: 2s + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/backend-timeout-override/v1.0/get" to be ready + And I record the current time as "request_start" + When I send a GET request to "http://localhost:8080/backend-timeout-override/v1.0/delay/5" + Then the response status code should be 504 + And the request should have taken at least "2" seconds since "request_start" + Given I authenticate using basic auth as "admin" + When I delete the API "backend-timeout-override-v1.0" + Then the response should be successful + + # Without a resilience block the global route timeout default (60s) applies, so a + # backend that responds within it succeeds normally. + Scenario: No resilience block falls back to the global default and succeeds + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: backend-timeout-default-v1.0 + spec: + displayName: Backend-Timeout-Default + version: v1.0 + context: /backend-timeout-default/$version + upstream: + main: + url: http://echo-backend:80 + operations: + - method: GET + path: /get + - method: GET + path: /delay/2 + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/backend-timeout-default/v1.0/get" to be ready + When I send a GET request to "http://localhost:8080/backend-timeout-default/v1.0/delay/2" + Then the response status code should be 200 + Given I authenticate using basic auth as "admin" + When I delete the API "backend-timeout-default-v1.0" + Then the response should be successful diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index 3496ebcd33..0e1df34ac0 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -143,6 +143,8 @@ func getFeaturePaths() []string { "features/route-path-matching.feature", "features/secrets.feature", "features/template-functions.feature", + "features/timeouts.feature", + "features/backend-timeout.feature", // Runs late: it restarts the gateway-controller (reject/reconnect scenario), so keep it // after features that assume an uninterrupted controller. Verifies the DP->CP artifact push. "features/dp-to-cp.feature", diff --git a/kubernetes/gateway-operator/api/v1alpha1/restapi_types.go b/kubernetes/gateway-operator/api/v1alpha1/restapi_types.go index 6077eb981f..a24bf15b78 100644 --- a/kubernetes/gateway-operator/api/v1alpha1/restapi_types.go +++ b/kubernetes/gateway-operator/api/v1alpha1/restapi_types.go @@ -74,6 +74,11 @@ type APIConfigData struct { // +optional Policies []Policy `json:"policies,omitempty"` + // Resilience API-level backend/route timeout configuration applied to all operations + // unless overridden at the operation level + // +optional + Resilience *Resilience `json:"resilience,omitempty"` + // Upstream API-level upstream configuration // +kubebuilder:validation:Required Upstream UpstreamConfig `json:"upstream"` @@ -125,6 +130,26 @@ type Operation struct { // Policies List of policies applied only to this operation (overrides or adds to API-level policies) // +optional Policies []Policy `json:"policies,omitempty"` + + // Resilience Operation-level backend/route timeout configuration (overrides API-level) + // +optional + Resilience *Resilience `json:"resilience,omitempty"` +} + +// Resilience defines backend/route timeout configuration (maps to Envoy RouteAction +// timeouts). Settable at the API level (applies to all routes) and/or the operation level +// (overrides the API level). "0s" disables a timeout; unset falls back to the gateway's +// global route timeout defaults. +type Resilience struct { + // Timeout Maximum time for the entire route (request to upstream response). "0s" disables. + // +optional + // +kubebuilder:validation:Pattern=`^\d+(\.\d+)?(ms|s|m|h)$` + Timeout *string `json:"timeout,omitempty"` + + // IdleTimeout Per-route stream idle timeout. "0s" disables. + // +optional + // +kubebuilder:validation:Pattern=`^\d+(\.\d+)?(ms|s|m|h)$` + IdleTimeout *string `json:"idleTimeout,omitempty"` } // OperationMethod HTTP method diff --git a/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go b/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go index b6163d5fd5..14c78bcddc 100644 --- a/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -43,6 +43,11 @@ func (in *APIConfigData) DeepCopyInto(out *APIConfigData) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Resilience != nil { + in, out := &in.Resilience, &out.Resilience + *out = new(Resilience) + (*in).DeepCopyInto(*out) + } in.Upstream.DeepCopyInto(&out.Upstream) if in.Vhosts != nil { in, out := &in.Vhosts, &out.Vhosts @@ -1587,6 +1592,11 @@ func (in *Operation) DeepCopyInto(out *Operation) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Resilience != nil { + in, out := &in.Resilience, &out.Resilience + *out = new(Resilience) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Operation. @@ -1624,6 +1634,31 @@ func (in *Policy) DeepCopy() *Policy { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Resilience) DeepCopyInto(out *Resilience) { + *out = *in + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(string) + **out = **in + } + if in.IdleTimeout != nil { + in, out := &in.IdleTimeout, &out.IdleTimeout + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Resilience. +func (in *Resilience) DeepCopy() *Resilience { + if in == nil { + return nil + } + out := new(Resilience) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceStatus) DeepCopyInto(out *ResourceStatus) { *out = *in diff --git a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_restapis.yaml b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_restapis.yaml index 3d1ad1a2aa..4e62b044a0 100644 --- a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_restapis.yaml +++ b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_restapis.yaml @@ -98,6 +98,21 @@ spec: - version type: object type: array + resilience: + description: Resilience Operation-level backend/route timeout + configuration (overrides API-level) + properties: + idleTimeout: + description: IdleTimeout Per-route stream idle timeout. + "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + timeout: + description: Timeout Maximum time for the entire route (request + to upstream response). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object required: - method - path @@ -132,6 +147,21 @@ spec: - version type: object type: array + resilience: + description: |- + Resilience API-level backend/route timeout configuration applied to all operations + unless overridden at the operation level + properties: + idleTimeout: + description: IdleTimeout Per-route stream idle timeout. "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + timeout: + description: Timeout Maximum time for the entire route (request + to upstream response). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object upstream: description: Upstream API-level upstream configuration properties: diff --git a/kubernetes/gateway-operator/config/samples/api_v1_restapi.yaml b/kubernetes/gateway-operator/config/samples/api_v1_restapi.yaml index 750e88cf08..74ce33658d 100644 --- a/kubernetes/gateway-operator/config/samples/api_v1_restapi.yaml +++ b/kubernetes/gateway-operator/config/samples/api_v1_restapi.yaml @@ -21,11 +21,18 @@ spec: # params: # issuers: # - WSO2KeyManager1 + # API-level resilience applies to all operations unless overridden per operation. + resilience: + timeout: 15s + idleTimeout: 0s operations: - method: GET path: /info - method: POST path: /submit + # Operation-level resilience overrides the API-level values for this route. + resilience: + timeout: 5s - method: GET path: /helloworld - method: GET diff --git a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_restapis.yaml b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_restapis.yaml index 3d1ad1a2aa..4e62b044a0 100644 --- a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_restapis.yaml +++ b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_restapis.yaml @@ -98,6 +98,21 @@ spec: - version type: object type: array + resilience: + description: Resilience Operation-level backend/route timeout + configuration (overrides API-level) + properties: + idleTimeout: + description: IdleTimeout Per-route stream idle timeout. + "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + timeout: + description: Timeout Maximum time for the entire route (request + to upstream response). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object required: - method - path @@ -132,6 +147,21 @@ spec: - version type: object type: array + resilience: + description: |- + Resilience API-level backend/route timeout configuration applied to all operations + unless overridden at the operation level + properties: + idleTimeout: + description: IdleTimeout Per-route stream idle timeout. "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + timeout: + description: Timeout Maximum time for the entire route (request + to upstream response). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object upstream: description: Upstream API-level upstream configuration properties: From 483d06bc6e5476c50c5452291ee5355d603dfc09 Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Thu, 25 Jun 2026 11:00:50 +0530 Subject: [PATCH 03/14] Update build manifest and test file rename --- .../{timeouts.feature => upstream-connect-timeout.feature} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename gateway/it/features/{timeouts.feature => upstream-connect-timeout.feature} (100%) diff --git a/gateway/it/features/timeouts.feature b/gateway/it/features/upstream-connect-timeout.feature similarity index 100% rename from gateway/it/features/timeouts.feature rename to gateway/it/features/upstream-connect-timeout.feature From b5dbfbb6ddfd3304ac1ad00445a7e3893767e65e Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Thu, 25 Jun 2026 11:58:16 +0530 Subject: [PATCH 04/14] Update generated.go --- .../pkg/api/management/generated.go | 554 +++++++++--------- 1 file changed, 279 insertions(+), 275 deletions(-) diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index 6d4dc80730..bfed52d5a6 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -5777,281 +5777,285 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file From 988954f4fccfdc118b05655a8c94a9da7579b717 Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Thu, 25 Jun 2026 12:11:38 +0530 Subject: [PATCH 05/14] update test feature list to match the new one --- gateway/it/suite_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index 0e1df34ac0..f8c017122c 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -143,7 +143,7 @@ func getFeaturePaths() []string { "features/route-path-matching.feature", "features/secrets.feature", "features/template-functions.feature", - "features/timeouts.feature", + "features/upstream-connect-timeout.feature", "features/backend-timeout.feature", // Runs late: it restarts the gateway-controller (reject/reconnect scenario), so keep it // after features that assume an uninterrupted controller. Verifies the DP->CP artifact push. From 65788fb1949c8096008eef705eb2b2b884c0c9d5 Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Thu, 25 Jun 2026 16:07:49 +0530 Subject: [PATCH 06/14] Adding request timeout support for llmprovider and proxy --- .../api/management-openapi.yaml | 14 + .../pkg/api/management/generated.go | 444 +++++++++--------- .../pkg/config/api_validator.go | 5 + .../pkg/config/llm_validator.go | 8 + .../config/llm_validator_resilience_test.go | 122 +++++ .../pkg/utils/llm_resilience_test.go | 236 ++++++++++ .../pkg/utils/llm_transformer.go | 33 ++ gateway/it/features/backend-timeout.feature | 6 +- .../it/features/llm-backend-timeout.feature | 225 +++++++++ .../features/upstream-connect-timeout.feature | 6 +- gateway/it/steps_timeouts.go | 22 + gateway/it/suite_test.go | 1 + .../api/v1alpha1/llmprovider_types.go | 5 + .../api/v1alpha1/llmproxy_types.go | 5 + .../api/v1alpha1/zz_generated.deepcopy.go | 10 + ...ay.api-platform.wso2.com_llmproviders.yaml | 15 + ...eway.api-platform.wso2.com_llmproxies.yaml | 15 + ...ay.api-platform.wso2.com_llmproviders.yaml | 15 + ...eway.api-platform.wso2.com_llmproxies.yaml | 15 + 19 files changed, 977 insertions(+), 225 deletions(-) create mode 100644 gateway/gateway-controller/pkg/config/llm_validator_resilience_test.go create mode 100644 gateway/gateway-controller/pkg/utils/llm_resilience_test.go create mode 100644 gateway/it/features/llm-backend-timeout.feature diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index a153925e0b..183e016755 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -5841,6 +5841,13 @@ components: enum: [deployed, undeployed] default: deployed example: deployed + resilience: + $ref: '#/components/schemas/Resilience' + description: > + API-level backend/route timeout configuration. Applies to all routes generated + for this LLM Provider (the routes that forward traffic upstream). Supported at the + API level only - LLM routes are synthesized by the gateway, so there is no + operation-level override. UpstreamAuth: type: object @@ -6131,6 +6138,13 @@ components: enum: [deployed, undeployed] default: deployed example: deployed + resilience: + $ref: '#/components/schemas/Resilience' + description: > + API-level backend/route timeout configuration. Applies to all routes generated + for this LLM Proxy (the routes that forward traffic upstream). Supported at the + API level only - LLM routes are synthesized by the gateway, so there is no + operation-level override. SecretConfigurationRequest: type: object diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index bfed52d5a6..61a428d785 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -790,6 +790,9 @@ type LLMProviderConfigData struct { // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set Policies *[]LLMPolicy `json:"policies,omitempty" yaml:"policies,omitempty"` + // Resilience Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply. + Resilience *Resilience `json:"resilience,omitempty" yaml:"resilience,omitempty"` + // Template Template name to use for this LLM Provider Template string `json:"template" yaml:"template"` Upstream LLMProviderConfigData_Upstream `json:"upstream" yaml:"upstream"` @@ -977,6 +980,9 @@ type LLMProxyConfigData struct { Policies *[]LLMPolicy `json:"policies,omitempty" yaml:"policies,omitempty"` Provider LLMProxyProvider `json:"provider" yaml:"provider"` + // Resilience Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply. + Resilience *Resilience `json:"resilience,omitempty" yaml:"resilience,omitempty"` + // Version Semantic version of the LLM proxy Version string `json:"version" yaml:"version"` @@ -5837,225 +5843,225 @@ var swaggerSpec = []string{ "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/config/api_validator.go b/gateway/gateway-controller/pkg/config/api_validator.go index 34ba29a49f..41e4b3fd2a 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -450,6 +450,11 @@ func (v *APIValidator) validateRestData(spec *api.APIConfigData) []ValidationErr // malformed values are rejected. fieldPrefix is the path to the block (e.g. // "spec.resilience" or "spec.operations[2].resilience"). func (v *APIValidator) validateResilience(fieldPrefix string, r *api.Resilience) []ValidationError { + return validateResilienceTimeouts(fieldPrefix, r) +} + +// validateResilienceTimeouts validates the timeout fields of a resilience block. +func validateResilienceTimeouts(fieldPrefix string, r *api.Resilience) []ValidationError { var errors []ValidationError if r == nil { return errors diff --git a/gateway/gateway-controller/pkg/config/llm_validator.go b/gateway/gateway-controller/pkg/config/llm_validator.go index 9a1ca3cf08..67c82965ff 100644 --- a/gateway/gateway-controller/pkg/config/llm_validator.go +++ b/gateway/gateway-controller/pkg/config/llm_validator.go @@ -387,6 +387,10 @@ func (v *LLMValidator) validateProviderSpec(spec *api.LLMProviderConfigData) []V // The deprecated `policies` list must not coexist with the new policy lists errors = append(errors, v.validatePolicyListExclusivity(spec.GlobalPolicies, spec.OperationPolicies, spec.Policies)...) + // Validate API-level resilience (timeout / idleTimeout). LLM kinds support resilience at + // the API level only. + errors = append(errors, validateResilienceTimeouts("spec.resilience", spec.Resilience)...) + return errors } @@ -594,6 +598,10 @@ func (v *LLMValidator) validateProxyData(spec *api.LLMProxyConfigData) []Validat // The deprecated `policies` list must not coexist with the new policy lists errors = append(errors, v.validatePolicyListExclusivity(spec.GlobalPolicies, spec.OperationPolicies, spec.Policies)...) + // Validate API-level resilience (timeout / idleTimeout). LLM kinds support resilience at + // the API level only. + errors = append(errors, validateResilienceTimeouts("spec.resilience", spec.Resilience)...) + return errors } diff --git a/gateway/gateway-controller/pkg/config/llm_validator_resilience_test.go b/gateway/gateway-controller/pkg/config/llm_validator_resilience_test.go new file mode 100644 index 0000000000..7523bfe48c --- /dev/null +++ b/gateway/gateway-controller/pkg/config/llm_validator_resilience_test.go @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. 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 config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" +) + +func validProviderWithResilience(r *api.Resilience) api.LLMProviderConfiguration { + return api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: api.LLMProviderConfigurationKindLlmProvider, + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "my-provider", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.openai.com")}, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + Resilience: r, + }, + } +} + +func validProxyWithResilience(r *api.Resilience) api.LLMProxyConfiguration { + return api.LLMProxyConfiguration{ + ApiVersion: api.LLMProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.LLMProxyConfigurationKindLlmProxy, + Metadata: api.Metadata{Name: "openai-proxy"}, + Spec: api.LLMProxyConfigData{ + DisplayName: "my-proxy", + Version: "v1.0", + Provider: api.LLMProxyProvider{Id: "openai"}, + Resilience: r, + }, + } +} + +func TestValidateLLMProvider_Resilience(t *testing.T) { + validator := NewLLMValidator() + + t.Run("valid timeout and idleTimeout", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(&api.Resilience{ + Timeout: stringPtr("30s"), + IdleTimeout: stringPtr("0s"), + })) + assert.Empty(t, errs) + }) + + t.Run("nil resilience is fine", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(nil)) + assert.Empty(t, errs) + }) + + t.Run("malformed timeout is rejected", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(&api.Resilience{Timeout: stringPtr("30")})) + assertHasFieldError(t, errs, "spec.resilience.timeout") + }) + + t.Run("negative timeout is rejected", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(&api.Resilience{Timeout: stringPtr("-5s")})) + assertHasFieldError(t, errs, "spec.resilience.timeout") + }) + + t.Run("malformed idleTimeout is rejected", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(&api.Resilience{IdleTimeout: stringPtr("abc")})) + assertHasFieldError(t, errs, "spec.resilience.idleTimeout") + }) +} + +func TestValidateLLMProxy_Resilience(t *testing.T) { + validator := NewLLMValidator() + + t.Run("valid timeout", func(t *testing.T) { + errs := validator.Validate(validProxyWithResilience(&api.Resilience{Timeout: stringPtr("75s")})) + assert.Empty(t, errs) + }) + + t.Run("nil resilience is fine", func(t *testing.T) { + errs := validator.Validate(validProxyWithResilience(nil)) + assert.Empty(t, errs) + }) + + t.Run("malformed timeout is rejected", func(t *testing.T) { + errs := validator.Validate(validProxyWithResilience(&api.Resilience{Timeout: stringPtr("fast")})) + assertHasFieldError(t, errs, "spec.resilience.timeout") + }) + + t.Run("negative idleTimeout is rejected", func(t *testing.T) { + errs := validator.Validate(validProxyWithResilience(&api.Resilience{IdleTimeout: stringPtr("-1s")})) + assertHasFieldError(t, errs, "spec.resilience.idleTimeout") + }) +} + +func assertHasFieldError(t *testing.T, errs []ValidationError, field string) { + t.Helper() + for _, e := range errs { + if e.Field == field { + return + } + } + t.Fatalf("expected a validation error on field %q, got %+v", field, errs) +} diff --git a/gateway/gateway-controller/pkg/utils/llm_resilience_test.go b/gateway/gateway-controller/pkg/utils/llm_resilience_test.go new file mode 100644 index 0000000000..38cb518f17 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/llm_resilience_test.go @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. 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 utils + +import ( + "io" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" +) + +// These tests pin the LLM resilience route-mapping rule: API-level resilience is attached to every +// traffic-forwarding route and never to the access-control deny routes. + +// In allow_all mode, the catch-all (and any policy-derived routes) carry the resilience block while +// the exception/deny routes (which only return a 404) are left untouched. +func TestTransformProvider_AllowAll_ResilienceSkipsDenyRoutes(t *testing.T) { + transformer, _ := setupTestTransformer(t) + + exceptions := []api.RouteException{ + {Path: "/admin", Methods: []api.RouteExceptionMethods{api.RouteExceptionMethodsGET, api.RouteExceptionMethodsPOST}}, + } + timeout := "30s" + provider := &api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: "LlmProvider", + Metadata: api.Metadata{Name: "openai-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "test", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.example.com")}, + AccessControl: api.LLMAccessControl{ + Mode: api.AllowAll, + Exceptions: &exceptions, + }, + Resilience: &api.Resilience{Timeout: &timeout}, + }, + } + + result, err := transformer.Transform(provider, &api.RestAPI{}) + require.NoError(t, err) + + sawCatchAll := false + sawDeny := false + for _, op := range result.Spec.Operations { + if op.Path == "/admin" { // the deny/exception routes + sawDeny = true + assert.Nil(t, op.Resilience, "deny route %s %s must not carry resilience", op.Method, op.Path) + continue + } + // catch-all (traffic-forwarding) routes + sawCatchAll = true + require.NotNil(t, op.Resilience, "traffic route %s %s should carry resilience", op.Method, op.Path) + require.NotNil(t, op.Resilience.Timeout) + assert.Equal(t, "30s", *op.Resilience.Timeout) + } + assert.True(t, sawCatchAll, "expected at least one traffic-forwarding route") + assert.True(t, sawDeny, "expected at least one deny route") +} + +// In deny_all mode every created route is an allow-listed forwarding route, so all of them carry the +// resilience block. +func TestTransformProvider_DenyAll_ResilienceOnAllRoutes(t *testing.T) { + transformer, _ := setupTestTransformer(t) + + exceptions := []api.RouteException{ + {Path: "/chat/completions", Methods: []api.RouteExceptionMethods{api.RouteExceptionMethodsPOST}}, + {Path: "/models", Methods: []api.RouteExceptionMethods{api.RouteExceptionMethodsGET}}, + } + timeout := "45s" + idle := "0s" + provider := &api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: "LlmProvider", + Metadata: api.Metadata{Name: "openai-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "test", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.example.com")}, + AccessControl: api.LLMAccessControl{ + Mode: api.DenyAll, + Exceptions: &exceptions, + }, + Resilience: &api.Resilience{Timeout: &timeout, IdleTimeout: &idle}, + }, + } + + result, err := transformer.Transform(provider, &api.RestAPI{}) + require.NoError(t, err) + require.NotEmpty(t, result.Spec.Operations) + + for _, op := range result.Spec.Operations { + require.NotNil(t, op.Resilience, "route %s %s should carry resilience", op.Method, op.Path) + require.NotNil(t, op.Resilience.Timeout) + assert.Equal(t, "45s", *op.Resilience.Timeout) + require.NotNil(t, op.Resilience.IdleTimeout) + assert.Equal(t, "0s", *op.Resilience.IdleTimeout) + } +} + +// With no resilience block on the source provider, no operation gets a resilience block (unchanged +// behavior — routes fall back to the gateway's global default timeout). +func TestTransformProvider_NoResilience_NotAttached(t *testing.T) { + transformer, _ := setupTestTransformer(t) + + provider := &api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: "LlmProvider", + Metadata: api.Metadata{Name: "openai-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "test", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.example.com")}, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + + result, err := transformer.Transform(provider, &api.RestAPI{}) + require.NoError(t, err) + require.NotEmpty(t, result.Spec.Operations) + + for _, op := range result.Spec.Operations { + assert.Nil(t, op.Resilience, "route %s %s should not carry resilience", op.Method, op.Path) + } +} + +// A proxy is always allow-all with no access control, so every generated route carries the +// resilience block. +func TestTransformProxy_ResilienceOnAllRoutes(t *testing.T) { + store := storage.NewConfigStore() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + db := newTestSQLiteStorage(t, logger) + + template := &models.StoredLLMProviderTemplate{ + UUID: "0000-db-template-id-0000-000000000001", + Configuration: api.LLMProviderTemplate{ + ApiVersion: api.LLMProviderTemplateApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.LLMProviderTemplateKindLlmProviderTemplate, + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{DisplayName: "openai"}, + }, + } + require.NoError(t, db.SaveLLMProviderTemplate(template)) + + now := time.Now() + provider := &models.StoredConfig{ + UUID: "0000-db-provider-id-0000-000000000000", + Kind: string(api.LLMProviderConfigurationKindLlmProvider), + Handle: "db-provider", + DisplayName: "db-provider", + Version: "v1.0", + Configuration: api.RestAPI{ + ApiVersion: api.RestAPIApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "db-provider"}, + Spec: api.APIConfigData{ + DisplayName: "db-provider", + Version: "v1.0", + Context: "/db-provider", + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{Main: api.Upstream{Url: stringPtr("https://api.openai.com")}}, + }, + }, + SourceConfiguration: api.LLMProviderConfiguration{ + ApiVersion: api.LLMProviderConfigurationApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.LLMProviderConfigurationKindLlmProvider, + Metadata: api.Metadata{Name: "db-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "db-provider", + Version: "v1.0", + Context: stringPtr("/db-provider"), + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.openai.com")}, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + }, + DesiredState: models.StateDeployed, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, db.SaveConfig(provider)) + + routerConfig := &config.RouterConfig{ListenerPort: 8080} + transformer := NewLLMProviderTransformer(store, db, routerConfig, newTestPolicyVersionResolver()) + + timeout := "75s" + proxy := &api.LLMProxyConfiguration{ + ApiVersion: api.LLMProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.LLMProxyConfigurationKindLlmProxy, + Metadata: api.Metadata{Name: "db-proxy"}, + Spec: api.LLMProxyConfigData{ + DisplayName: "db-proxy", + Version: "v1.0", + Provider: api.LLMProxyProvider{Id: "db-provider"}, + Resilience: &api.Resilience{Timeout: &timeout}, + }, + } + + result, err := transformer.Transform(proxy, &api.RestAPI{}) + require.NoError(t, err) + require.NotEmpty(t, result.Spec.Operations) + + for _, op := range result.Spec.Operations { + require.NotNil(t, op.Resilience, "proxy route %s %s should carry resilience", op.Method, op.Path) + require.NotNil(t, op.Resilience.Timeout) + assert.Equal(t, "75s", *op.Resilience.Timeout) + } +} diff --git a/gateway/gateway-controller/pkg/utils/llm_transformer.go b/gateway/gateway-controller/pkg/utils/llm_transformer.go index 0146d2ad64..d4e1409062 100644 --- a/gateway/gateway-controller/pkg/utils/llm_transformer.go +++ b/gateway/gateway-controller/pkg/utils/llm_transformer.go @@ -295,6 +295,9 @@ func (t *LLMProviderTransformer) transformProxy(proxy *api.LLMProxyConfiguration } } } + // A proxy is always allow-all with no access control, so there are no deny routes: + // attach API-level resilience to all generated routes. + applyResilienceToTrafficRoutes(ops, proxy.Spec.Resilience, nil) spec.Operations = ops // Global (api-level) policies: route into the derived RestAPI's spec.Policies so they are @@ -388,6 +391,12 @@ func (t *LLMProviderTransformer) transformProvider(provider *api.LLMProviderConf var ops []api.Operation + // denyOpKeys tracks the routes created purely to deny traffic (the access-control + // exception routes, which carry the 404 respond policy). API-level resilience is NOT + // attached to these (they never reach an upstream). Populated in allow_all mode; empty + // in deny_all (where every created route is an allowed/forwarding route). + denyOpKeys := make(map[pathMethodKey]bool) + switch mode { case api.AllowAll: var denyPolicyVersion string @@ -427,6 +436,7 @@ func (t *LLMProviderTransformer) transformProvider(provider *api.LLMProviderConf for _, method := range methods { key := pathMethodKey{path: ex.Path, method: method} deniedPathMethods[key] = true + denyOpKeys[key] = true // Check if operation exists if _, exists := operationRegistry[key]; !exists { @@ -634,6 +644,8 @@ func (t *LLMProviderTransformer) transformProvider(provider *api.LLMProviderConf } } } + // Attach API-level resilience to every traffic-forwarding route, skipping the deny routes. + applyResilienceToTrafficRoutes(ops, provider.Spec.Resilience, denyOpKeys) spec.Operations = ops // Global (api-level) policies: route into the derived RestAPI's spec.Policies so they are @@ -658,6 +670,27 @@ func (t *LLMProviderTransformer) transformProvider(provider *api.LLMProviderConf return output, nil } +// applyResilienceToTrafficRoutes attaches the API-level resilience block to every operation that +// forwards traffic upstream, skipping access-control deny routes (which only return a canned 404 +// and never reach an upstream, so a timeout on them is meaningless). denyKeys identifies the deny +// routes by path+method; it is empty for deny_all and proxy (no deny routes exist there), so in +// those cases the block is applied to all routes. A nil resilience block is a no-op. +// +// Resilience is attached at the operation level (not the derived API level) on purpose: that keeps +// the deny routes on the gateway's global default timeout instead of inheriting the API-level value +// via the translator's per-field fallback. +func applyResilienceToTrafficRoutes(ops []api.Operation, resilience *api.Resilience, denyKeys map[pathMethodKey]bool) { + if resilience == nil { + return + } + for i := range ops { + if denyKeys[pathMethodKey{path: ops[i].Path, method: string(ops[i].Method)}] { + continue + } + ops[i].Resilience = resilience + } +} + // GetUpstreamAuthApikeyPolicyParams renders the policy params with given header and value func GetUpstreamAuthApikeyPolicyParams(header, value string) (map[string]interface{}, error) { rendered := fmt.Sprintf(constants.UPSTREAM_AUTH_APIKEY_POLICY_PARAMS, header, value) diff --git a/gateway/it/features/backend-timeout.feature b/gateway/it/features/backend-timeout.feature index 031a28ca86..d701f21f4d 100644 --- a/gateway/it/features/backend-timeout.feature +++ b/gateway/it/features/backend-timeout.feature @@ -36,7 +36,7 @@ Feature: Backend route timeouts via the resilience block Given I authenticate using basic auth as "admin" When I deploy this API configuration: """ - apiVersion: gateway.api-platform.wso2.com/v1alpha1 + apiVersion: gateway.api-platform.wso2.com/v1 kind: RestApi metadata: name: backend-timeout-api-v1.0 @@ -72,7 +72,7 @@ Feature: Backend route timeouts via the resilience block Given I authenticate using basic auth as "admin" When I deploy this API configuration: """ - apiVersion: gateway.api-platform.wso2.com/v1alpha1 + apiVersion: gateway.api-platform.wso2.com/v1 kind: RestApi metadata: name: backend-timeout-override-v1.0 @@ -109,7 +109,7 @@ Feature: Backend route timeouts via the resilience block Given I authenticate using basic auth as "admin" When I deploy this API configuration: """ - apiVersion: gateway.api-platform.wso2.com/v1alpha1 + apiVersion: gateway.api-platform.wso2.com/v1 kind: RestApi metadata: name: backend-timeout-default-v1.0 diff --git a/gateway/it/features/llm-backend-timeout.feature b/gateway/it/features/llm-backend-timeout.feature new file mode 100644 index 0000000000..98b72aba88 --- /dev/null +++ b/gateway/it/features/llm-backend-timeout.feature @@ -0,0 +1,225 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. 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. +# -------------------------------------------------------------------- + +@llm @backend-timeout @resilience +Feature: Backend route timeouts for LLM providers and proxies via the resilience block + As an API developer + I want to configure the route timeout via an API-level resilience block on LLM providers and proxies + So that requests to slow LLM backends are terminated by the gateway within the configured time + + # LLM kinds support resilience at the API level only + + Background: + Given the gateway services are running + + # allow_all creates a catch-all route for all traffic; the API-level resilience.timeout (2s) is + # shorter than the backend delay (5s), so the gateway times the route out with 504 at ~2s. + Scenario: LLM provider API-level resilience timeout terminates a slow backend + Given I authenticate using basic auth as "admin" + When I create this LLM provider: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: LlmProvider + metadata: + name: llm-timeout-provider + spec: + displayName: LLM Timeout Provider + version: v1.0 + template: openai + context: /llm-timeout + upstream: + url: http://echo-backend:80 + accessControl: + mode: allow_all + resilience: + timeout: 2s + """ + Then the response status code should be 201 + And I wait for the endpoint "http://localhost:8080/llm-timeout/get" to be ready + And I record the current time as "request_start" + When I send a GET request to "http://localhost:8080/llm-timeout/delay/5" + Then the response status code should be 504 + And the request should have taken at least "2" seconds since "request_start" + Given I authenticate using basic auth as "admin" + When I delete the LLM provider "llm-timeout-provider" + Then the response should be successful + + # deny_all creates routes only for the allow-listed exception paths; the resilience block must be + # attached to those (forwarding) routes too. The allow-listed /delay/5 route times out at ~2s. + Scenario: LLM provider deny_all attaches the timeout to allow-listed routes + Given I authenticate using basic auth as "admin" + When I create this LLM provider: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: LlmProvider + metadata: + name: llm-timeout-deny-provider + spec: + displayName: LLM Timeout Deny Provider + version: v1.0 + template: openai + context: /llm-timeout-deny + upstream: + url: http://echo-backend:80 + accessControl: + mode: deny_all + exceptions: + - path: /delay/5 + methods: [GET] + resilience: + timeout: 2s + """ + Then the response status code should be 201 + And I record the current time as "request_start" + When I send a GET request to "http://localhost:8080/llm-timeout-deny/delay/5" + Then the response status code should be 504 + And the request should have taken at least "2" seconds since "request_start" + Given I authenticate using basic auth as "admin" + When I delete the LLM provider "llm-timeout-deny-provider" + Then the response should be successful + + # Without a resilience block the global route timeout default (60s) applies, so a backend that + # responds within it succeeds normally. + Scenario: LLM provider without a resilience block falls back to the global default and succeeds + Given I authenticate using basic auth as "admin" + When I create this LLM provider: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: LlmProvider + metadata: + name: llm-timeout-default-provider + spec: + displayName: LLM Timeout Default Provider + version: v1.0 + template: openai + context: /llm-timeout-default + upstream: + url: http://echo-backend:80 + accessControl: + mode: allow_all + """ + Then the response status code should be 201 + And I wait for the endpoint "http://localhost:8080/llm-timeout-default/get" to be ready + When I send a GET request to "http://localhost:8080/llm-timeout-default/delay/2" + Then the response status code should be 200 + Given I authenticate using basic auth as "admin" + When I delete the LLM provider "llm-timeout-default-provider" + Then the response should be successful + + # A proxy is always allow-all (catch-all). The proxy's own API-level resilience.timeout (2s) + # bounds the whole proxied call (client -> proxy route -> loopback -> provider route -> backend). + # The backing provider is left on the global default, so the 504 is attributable to the proxy. + Scenario: LLM proxy API-level resilience timeout terminates a slow backend + Given I authenticate using basic auth as "admin" + When I create this LLM provider: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: LlmProvider + metadata: + name: llm-timeout-proxy-provider + spec: + displayName: LLM Timeout Proxy Provider + version: v1.0 + template: openai + context: /llm-timeout-backing + upstream: + url: http://echo-backend:80 + accessControl: + mode: allow_all + """ + Then the response status code should be 201 + When I deploy this LLM proxy configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: LlmProxy + metadata: + name: llm-timeout-proxy + spec: + displayName: LLM Timeout Proxy + version: v1.0 + context: /llm-timeout-proxy + provider: + id: llm-timeout-proxy-provider + resilience: + timeout: 2s + """ + Then the response status code should be 201 + And I wait for the endpoint "http://localhost:8080/llm-timeout-proxy/get" to be ready + And I record the current time as "request_start" + When I send a GET request to "http://localhost:8080/llm-timeout-proxy/delay/5" + Then the response status code should be 504 + And the request should have taken at least "2" seconds since "request_start" + Given I authenticate using basic auth as "admin" + When I send a DELETE request to the "gateway-controller" service at "/llm-proxies/llm-timeout-proxy" + Then the response should be successful + Given I authenticate using basic auth as "admin" + When I delete the LLM provider "llm-timeout-proxy-provider" + Then the response should be successful + + # Both the proxy and its backing provider set their own resilience.timeout. The request traverses + # both routes (proxy 6s -> loopback -> provider 2s -> backend /delay/10), so the shorter inner + # provider timeout (2s) must fire first and bound the whole call. The upper-bound assertion proves + # the 2s provider timeout won, not the 6s proxy timeout. + Scenario: LLM proxy and provider each set resilience and the shorter (provider) timeout wins + Given I authenticate using basic auth as "admin" + When I create this LLM provider: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: LlmProvider + metadata: + name: llm-timeout-both-provider + spec: + displayName: LLM Timeout Both Provider + version: v1.0 + template: openai + context: /llm-timeout-both-backing + upstream: + url: http://echo-backend:80 + accessControl: + mode: allow_all + resilience: + timeout: 2s + """ + Then the response status code should be 201 + When I deploy this LLM proxy configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: LlmProxy + metadata: + name: llm-timeout-both-proxy + spec: + displayName: LLM Timeout Both Proxy + version: v1.0 + context: /llm-timeout-both + provider: + id: llm-timeout-both-provider + resilience: + timeout: 6s + """ + Then the response status code should be 201 + And I wait for the endpoint "http://localhost:8080/llm-timeout-both/get" to be ready + And I record the current time as "request_start" + When I send a GET request to "http://localhost:8080/llm-timeout-both/delay/10" + Then the response status code should be 504 + And the request should have taken at least "2" seconds since "request_start" + And the request should have taken at most "4" seconds since "request_start" + Given I authenticate using basic auth as "admin" + When I send a DELETE request to the "gateway-controller" service at "/llm-proxies/llm-timeout-both-proxy" + Then the response should be successful + Given I authenticate using basic auth as "admin" + When I delete the LLM provider "llm-timeout-both-provider" + Then the response should be successful diff --git a/gateway/it/features/upstream-connect-timeout.feature b/gateway/it/features/upstream-connect-timeout.feature index f2ae26afeb..f0374b89cd 100644 --- a/gateway/it/features/upstream-connect-timeout.feature +++ b/gateway/it/features/upstream-connect-timeout.feature @@ -47,7 +47,7 @@ Feature: Timeouts timeout: connect: 6000ms upstreams: - - url: http://192.0.2.1:80 + - url: 10.255.255.1:8080 upstream: main: ref: my-timeout-upstream @@ -82,7 +82,7 @@ Feature: Timeouts upstreamDefinitions: - name: my-timeout-upstream-global upstreams: - - url: http://192.0.2.1:80 + - url: http://10.255.255.1:8080 upstream: main: ref: my-timeout-upstream-global @@ -108,7 +108,7 @@ Feature: Timeouts Given I authenticate using basic auth as "admin" When I deploy this API configuration: """ - apiVersion: gateway.api-platform.wso2.com/v1alpha1 + apiVersion: gateway.api-platform.wso2.com/v1 kind: RestApi metadata: name: headers-timeout-api-v1.0 diff --git a/gateway/it/steps_timeouts.go b/gateway/it/steps_timeouts.go index 744353fef6..f8bea39830 100644 --- a/gateway/it/steps_timeouts.go +++ b/gateway/it/steps_timeouts.go @@ -64,6 +64,28 @@ func RegisterTimeoutSteps(ctx *godog.ScenarioContext, state *TestState) { return nil }) + ctx.Step(`^the request should have taken at most "(\d+)" seconds since "([^"]*)"$`, func(expectedSecondsStr, key string) error { + expectedSeconds, err := strconv.Atoi(expectedSecondsStr) + if err != nil { + return fmt.Errorf("expected seconds must be a number, got: %s", expectedSecondsStr) + } + val, ok := state.GetContextValue(key) + if !ok { + return fmt.Errorf("no start time recorded; record the current time as \"request_start\" before sending the request") + } + start, ok := val.(time.Time) + if !ok { + return fmt.Errorf("no start time recorded; record the current time as %q before sending the request", key) + } + elapsed := time.Since(start) + maxElapsed := time.Duration(expectedSeconds+elapsedTimeToleranceSeconds) * time.Second + if elapsed > maxElapsed { + return fmt.Errorf("request should have taken at most %d seconds (with %ds tolerance), but elapsed time was %s", + expectedSeconds, elapsedTimeToleranceSeconds, elapsed.Round(time.Millisecond)) + } + return nil + }) + // Opens a raw TCP connection and sends a request line plus one header, but never // terminates the header block (no final blank line). This forces the HCM // request_headers_timeout to fire. The connection blocks until the gateway responds diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index f8c017122c..d994bd8e6e 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -145,6 +145,7 @@ func getFeaturePaths() []string { "features/template-functions.feature", "features/upstream-connect-timeout.feature", "features/backend-timeout.feature", + "features/llm-backend-timeout.feature", // Runs late: it restarts the gateway-controller (reject/reconnect scenario), so keep it // after features that assume an uninterrupted controller. Verifies the DP->CP artifact push. "features/dp-to-cp.feature", diff --git a/kubernetes/gateway-operator/api/v1alpha1/llmprovider_types.go b/kubernetes/gateway-operator/api/v1alpha1/llmprovider_types.go index 5d2fae5583..9535b86216 100644 --- a/kubernetes/gateway-operator/api/v1alpha1/llmprovider_types.go +++ b/kubernetes/gateway-operator/api/v1alpha1/llmprovider_types.go @@ -128,6 +128,11 @@ type LLMProviderConfigData struct { // Policies is the list of policies applied to this LLM provider. // +optional Policies []LLMPolicy `json:"policies,omitempty"` + + // Resilience configures API-level backend/route timeouts applied to all routes + // generated for this LLM provider. Supported at the API level only. + // +optional + Resilience *Resilience `json:"resilience,omitempty"` } // LLMPolicyPath defines a path/methods combination together with policy diff --git a/kubernetes/gateway-operator/api/v1alpha1/llmproxy_types.go b/kubernetes/gateway-operator/api/v1alpha1/llmproxy_types.go index 046f492af5..62aac873c9 100644 --- a/kubernetes/gateway-operator/api/v1alpha1/llmproxy_types.go +++ b/kubernetes/gateway-operator/api/v1alpha1/llmproxy_types.go @@ -64,6 +64,11 @@ type LLMProxyConfigData struct { // Policies is the list of policies applied to this LLM proxy. // +optional Policies []LLMPolicy `json:"policies,omitempty"` + + // Resilience configures API-level backend/route timeouts applied to all routes + // generated for this LLM proxy. Supported at the API level only. + // +optional + Resilience *Resilience `json:"resilience,omitempty"` } //+kubebuilder:object:root=true diff --git a/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go b/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go index 14c78bcddc..69e6912d3f 100644 --- a/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -802,6 +802,11 @@ func (in *LLMProviderConfigData) DeepCopyInto(out *LLMProviderConfigData) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Resilience != nil { + in, out := &in.Resilience, &out.Resilience + *out = new(Resilience) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLMProviderConfigData. @@ -992,6 +997,11 @@ func (in *LLMProxyConfigData) DeepCopyInto(out *LLMProxyConfigData) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Resilience != nil { + in, out := &in.Resilience, &out.Resilience + *out = new(Resilience) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLMProxyConfigData. diff --git a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml index 8d48f6cbe8..78f1879087 100644 --- a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml +++ b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml @@ -153,6 +153,21 @@ spec: - version type: object type: array + resilience: + description: |- + Resilience configures API-level backend/route timeouts applied to all routes + generated for this LLM provider. Supported at the API level only. + properties: + idleTimeout: + description: IdleTimeout Per-route stream idle timeout. "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + timeout: + description: Timeout Maximum time for the entire route (request + to upstream response). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object template: description: Template is the LlmProviderTemplate name to apply. type: string diff --git a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproxies.yaml b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproxies.yaml index dd5a2a5579..81da02ebd1 100644 --- a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproxies.yaml +++ b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproxies.yaml @@ -180,6 +180,21 @@ spec: required: - id type: object + resilience: + description: |- + Resilience configures API-level backend/route timeouts applied to all routes + generated for this LLM proxy. Supported at the API level only. + properties: + idleTimeout: + description: IdleTimeout Per-route stream idle timeout. "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + timeout: + description: Timeout Maximum time for the entire route (request + to upstream response). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object version: description: Version is the semantic version of the LLM proxy. pattern: ^v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ diff --git a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml index 8d48f6cbe8..78f1879087 100644 --- a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml +++ b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml @@ -153,6 +153,21 @@ spec: - version type: object type: array + resilience: + description: |- + Resilience configures API-level backend/route timeouts applied to all routes + generated for this LLM provider. Supported at the API level only. + properties: + idleTimeout: + description: IdleTimeout Per-route stream idle timeout. "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + timeout: + description: Timeout Maximum time for the entire route (request + to upstream response). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object template: description: Template is the LlmProviderTemplate name to apply. type: string diff --git a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproxies.yaml b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproxies.yaml index dd5a2a5579..81da02ebd1 100644 --- a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproxies.yaml +++ b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproxies.yaml @@ -180,6 +180,21 @@ spec: required: - id type: object + resilience: + description: |- + Resilience configures API-level backend/route timeouts applied to all routes + generated for this LLM proxy. Supported at the API level only. + properties: + idleTimeout: + description: IdleTimeout Per-route stream idle timeout. "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + timeout: + description: Timeout Maximum time for the entire route (request + to upstream response). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object version: description: Version is the semantic version of the LLM proxy. pattern: ^v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ From aa268306cb9cb6349d7fd9aa76cdc5eef5dcd52f Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Fri, 26 Jun 2026 07:00:18 +0530 Subject: [PATCH 07/14] Fix test failures in connect timeout --- gateway/it/features/llm-backend-timeout.feature | 3 +++ gateway/it/features/upstream-connect-timeout.feature | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/gateway/it/features/llm-backend-timeout.feature b/gateway/it/features/llm-backend-timeout.feature index 98b72aba88..addc30347b 100644 --- a/gateway/it/features/llm-backend-timeout.feature +++ b/gateway/it/features/llm-backend-timeout.feature @@ -78,12 +78,15 @@ Feature: Backend route timeouts for LLM providers and proxies via the resilience accessControl: mode: deny_all exceptions: + - path: /get + methods: [GET] - path: /delay/5 methods: [GET] resilience: timeout: 2s """ Then the response status code should be 201 + And I wait for the endpoint "http://localhost:8080/llm-timeout-deny/get" to be ready And I record the current time as "request_start" When I send a GET request to "http://localhost:8080/llm-timeout-deny/delay/5" Then the response status code should be 504 diff --git a/gateway/it/features/upstream-connect-timeout.feature b/gateway/it/features/upstream-connect-timeout.feature index f0374b89cd..b20d5b819f 100644 --- a/gateway/it/features/upstream-connect-timeout.feature +++ b/gateway/it/features/upstream-connect-timeout.feature @@ -47,7 +47,7 @@ Feature: Timeouts timeout: connect: 6000ms upstreams: - - url: 10.255.255.1:8080 + - url: http://10.255.255.1:8080 upstream: main: ref: my-timeout-upstream @@ -58,6 +58,7 @@ Feature: Timeouts Then the response should be successful And the response should be valid JSON And the JSON response field "status" should be "success" + And I wait for policy snapshot sync And I record the current time as "request_start" When I send a GET request to "http://localhost:8080/timeout-api/v1.0/" Then the response status code should be 503 @@ -93,6 +94,7 @@ Feature: Timeouts Then the response should be successful And the response should be valid JSON And the JSON response field "status" should be "success" + And I wait for policy snapshot sync And I record the current time as "request_start" When I send a GET request to "http://localhost:8080/timeout-global/v1.0/" Then the response status code should be 503 From fe01dc65fca235f75a595fad858d49a90b35c422 Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Fri, 26 Jun 2026 10:22:39 +0530 Subject: [PATCH 08/14] Address coderabbit review comments --- gateway/configs/config-template.toml | 2 +- .../pkg/config/api_validator.go | 15 +++-- .../pkg/config/api_validator_test.go | 6 ++ .../config/llm_validator_resilience_test.go | 10 ++++ .../pkg/constants/constants.go | 11 ++++ .../pkg/xds/resilience_duration_test.go | 58 +++++++++++++++++++ .../gateway-controller/pkg/xds/translator.go | 17 +++--- .../features/upstream-connect-timeout.feature | 4 +- 8 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 gateway/gateway-controller/pkg/xds/resilience_duration_test.go diff --git a/gateway/configs/config-template.toml b/gateway/configs/config-template.toml index 31ef8bae2c..0630ad36f2 100644 --- a/gateway/configs/config-template.toml +++ b/gateway/configs/config-template.toml @@ -213,7 +213,7 @@ server_header_transformation = "OVERWRITE" server_header_value = "WSO2 API Platform" # HTTP Connection Manager (downstream) timeouts -# Except for idle_timeout, a value of 0 disables all other timeout settings. +# A value of zero disables any of these timeout settings. [router.http_listener.timeouts] # Max duration for the entire downstream request request_timeout = "0s" diff --git a/gateway/gateway-controller/pkg/config/api_validator.go b/gateway/gateway-controller/pkg/config/api_validator.go index 41e4b3fd2a..daefcfee0a 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -26,6 +26,7 @@ import ( "time" api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" ) // APIValidator validates API configurations using rule-based validation @@ -468,18 +469,22 @@ func validateResilienceTimeouts(fieldPrefix string, r *api.Resilience) []Validat if s == "" { return } - d, err := time.ParseDuration(s) - if err != nil { + // Enforce the same single-unit format as the CRD admission controller (see + // constants.ResilienceDurationPattern). This rejects compound durations ("1h30m"), + // negatives ("-30s"), and unitless values ("0", "30"), while accepting "0s" to disable. + if !constants.ResilienceDurationRegex.MatchString(s) { errors = append(errors, ValidationError{ Field: field, - Message: fmt.Sprintf("Invalid timeout format: %v (expected format: '30s', '1m', '500ms', or '0s' to disable)", err), + Message: "Invalid timeout format (expected a single-unit duration like '30s', '1m', '500ms', or '0s' to disable; compound, negative, and unitless values are not allowed)", }) return } - if d < 0 { + // The pattern guarantees a parseable, non-negative, single-unit value; ParseDuration is a + // final guard against pathological overflow. + if _, err := time.ParseDuration(s); err != nil { errors = append(errors, ValidationError{ Field: field, - Message: "Timeout must not be negative", + Message: fmt.Sprintf("Invalid timeout format: %v", err), }) } } diff --git a/gateway/gateway-controller/pkg/config/api_validator_test.go b/gateway/gateway-controller/pkg/config/api_validator_test.go index ec8c32cf55..8745ee6cdf 100644 --- a/gateway/gateway-controller/pkg/config/api_validator_test.go +++ b/gateway/gateway-controller/pkg/config/api_validator_test.go @@ -495,7 +495,13 @@ func TestAPIValidator_ValidateResilience(t *testing.T) { {name: "Invalid API-level timeout format", apiRes: &api.Resilience{Timeout: stringPtr("15seconds")}, wantError: true, errField: "spec.resilience.timeout"}, {name: "Invalid API-level idleTimeout format", apiRes: &api.Resilience{IdleTimeout: stringPtr("abc")}, wantError: true, errField: "spec.resilience.idleTimeout"}, {name: "Negative API-level timeout rejected", apiRes: &api.Resilience{Timeout: stringPtr("-5s")}, wantError: true, errField: "spec.resilience.timeout"}, + {name: "Compound API-level timeout rejected (must match CRD pattern)", apiRes: &api.Resilience{Timeout: stringPtr("1h30m")}, wantError: true, errField: "spec.resilience.timeout"}, + {name: "Compound API-level idleTimeout rejected", apiRes: &api.Resilience{IdleTimeout: stringPtr("1m30s")}, wantError: true, errField: "spec.resilience.idleTimeout"}, + {name: "Unitless API-level timeout rejected", apiRes: &api.Resilience{Timeout: stringPtr("30")}, wantError: true, errField: "spec.resilience.timeout"}, + {name: "Bare 0 rejected (unit required)", apiRes: &api.Resilience{Timeout: stringPtr("0")}, wantError: true, errField: "spec.resilience.timeout"}, + {name: "Fractional single-unit timeout allowed", apiRes: &api.Resilience{Timeout: stringPtr("1.5s")}, wantError: false}, {name: "Invalid operation-level timeout format", opRes: &api.Resilience{Timeout: stringPtr("nope")}, wantError: true, errField: "spec.operations[0].resilience.timeout"}, + {name: "Compound operation-level timeout rejected", opRes: &api.Resilience{Timeout: stringPtr("1h30m")}, wantError: true, errField: "spec.operations[0].resilience.timeout"}, } for _, tt := range tests { diff --git a/gateway/gateway-controller/pkg/config/llm_validator_resilience_test.go b/gateway/gateway-controller/pkg/config/llm_validator_resilience_test.go index 7523bfe48c..db67f81e7f 100644 --- a/gateway/gateway-controller/pkg/config/llm_validator_resilience_test.go +++ b/gateway/gateway-controller/pkg/config/llm_validator_resilience_test.go @@ -76,6 +76,16 @@ func TestValidateLLMProvider_Resilience(t *testing.T) { assertHasFieldError(t, errs, "spec.resilience.timeout") }) + t.Run("compound timeout is rejected (must match CRD pattern)", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(&api.Resilience{Timeout: stringPtr("1h30m")})) + assertHasFieldError(t, errs, "spec.resilience.timeout") + }) + + t.Run("0s is accepted (disables)", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(&api.Resilience{Timeout: stringPtr("0s")})) + assert.Empty(t, errs) + }) + t.Run("negative timeout is rejected", func(t *testing.T) { errs := validator.Validate(validProviderWithResilience(&api.Resilience{Timeout: stringPtr("-5s")})) assertHasFieldError(t, errs, "spec.resilience.timeout") diff --git a/gateway/gateway-controller/pkg/constants/constants.go b/gateway/gateway-controller/pkg/constants/constants.go index cced5e512a..4833f0502d 100644 --- a/gateway/gateway-controller/pkg/constants/constants.go +++ b/gateway/gateway-controller/pkg/constants/constants.go @@ -179,6 +179,12 @@ const ( // System policy constants ANALYTICS_SYSTEM_POLICY_NAME = "wso2_apip_sys_analytics" ANALYTICS_SYSTEM_POLICY_VERSION = "v1" + + // ResilienceDurationPattern is the single source of truth for the format of resilience + // timeout strings. + // - accepted: "30s", "500ms", "1m", "2h", "1.5s", and "0s" (zero disables the timeout) + // - rejected: compound durations ("1h30m"), negatives ("-30s"), and unitless values ("0", "30") + ResilienceDurationPattern = `^\d+(\.\d+)?(ms|s|m|h)$` ) // DP->CP artifact push timing. The bottom-up (DP->CP) push waits for the local deployment @@ -199,3 +205,8 @@ var WILDCARD_HTTP_METHODS = []string{ "DELETE", "OPTIONS", } + +// ResilienceDurationRegex is the compiled ResilienceDurationPattern, shared by the management-API +// validator and the xDS downstream parser so both enforce exactly what the +// CRD admission controller enforces. +var ResilienceDurationRegex = regexp.MustCompile(ResilienceDurationPattern) \ No newline at end of file diff --git a/gateway/gateway-controller/pkg/xds/resilience_duration_test.go b/gateway/gateway-controller/pkg/xds/resilience_duration_test.go new file mode 100644 index 0000000000..714c99d83b --- /dev/null +++ b/gateway/gateway-controller/pkg/xds/resilience_duration_test.go @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. 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 xds + +import "testing" + +// parseDurationAllowZero must accept exactly what the CRD admission controller accepts +// (constants.ResilienceDurationPattern): single-unit durations including "0s" to disable, while +// rejecting compound, negative, and unitless values. +func TestParseDurationAllowZero_MatchesCRDPattern(t *testing.T) { + ptr := func(s string) *string { return &s } + + t.Run("accepts single-unit and zero", func(t *testing.T) { + for _, in := range []string{"30s", "500ms", "1m", "2h", "1.5s", "0s", "0ms"} { + d, err := parseDurationAllowZero(ptr(in)) + if err != nil { + t.Errorf("expected %q to be accepted, got error: %v", in, err) + continue + } + if d == nil { + t.Errorf("expected %q to yield a non-nil duration", in) + } + } + }) + + t.Run("nil and empty yield nil without error", func(t *testing.T) { + for _, in := range []*string{nil, ptr(""), ptr(" ")} { + d, err := parseDurationAllowZero(in) + if err != nil || d != nil { + t.Errorf("expected nil,nil for empty input, got %v,%v", d, err) + } + } + }) + + t.Run("rejects compound, negative, and unitless", func(t *testing.T) { + for _, in := range []string{"1h30m", "1m30s", "-30s", "-5s", "30", "0", "15seconds", "abc"} { + if _, err := parseDurationAllowZero(ptr(in)); err == nil { + t.Errorf("expected %q to be rejected, but it was accepted", in) + } + } + }) +} diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index efab56d459..182f879db7 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -3246,20 +3246,23 @@ func parseTimeout(timeoutStr *string) (*time.Duration, error) { } // parseDurationAllowZero parses a duration string (e.g. "15s", "0s") into a *time.Duration. -// Unlike parseTimeout it accepts zero ("0s" means the timeout is explicitly disabled), -// rejecting only malformed and negative values. Returns nil for nil/empty input. +// Unlike parseTimeout it accepts zero ("0s" means the timeout is explicitly disabled). The format +// is enforced against constants.ResilienceDurationPattern so this downstream parser stays +// consistent with the CRD admission controller and the management-API validator: compound +// ("1h30m"), negative ("-30s"), and unitless ("0") values are rejected. Returns nil for nil/empty. func parseDurationAllowZero(timeoutStr *string) (*time.Duration, error) { if timeoutStr == nil || strings.TrimSpace(*timeoutStr) == "" { return nil, nil } - duration, err := time.ParseDuration(strings.TrimSpace(*timeoutStr)) - if err != nil { - return nil, fmt.Errorf("invalid timeout format: %w", err) + s := strings.TrimSpace(*timeoutStr) + if !constants.ResilienceDurationRegex.MatchString(s) { + return nil, fmt.Errorf("invalid timeout format %q: expected a single-unit duration like \"30s\", \"500ms\", or \"0s\" to disable", s) } - if duration < 0 { - return nil, fmt.Errorf("timeout must not be negative, got: %v", duration) + duration, err := time.ParseDuration(s) + if err != nil { + return nil, fmt.Errorf("invalid timeout format: %w", err) } return &duration, nil diff --git a/gateway/it/features/upstream-connect-timeout.feature b/gateway/it/features/upstream-connect-timeout.feature index b20d5b819f..ab855c50be 100644 --- a/gateway/it/features/upstream-connect-timeout.feature +++ b/gateway/it/features/upstream-connect-timeout.feature @@ -47,7 +47,7 @@ Feature: Timeouts timeout: connect: 6000ms upstreams: - - url: http://10.255.255.1:8080 + - url: http://192.0.2.1:8080 upstream: main: ref: my-timeout-upstream @@ -83,7 +83,7 @@ Feature: Timeouts upstreamDefinitions: - name: my-timeout-upstream-global upstreams: - - url: http://10.255.255.1:8080 + - url: http://192.0.2.1:8080 upstream: main: ref: my-timeout-upstream-global From 9fa34f4b8045653e3f903e6f06f4360ae8b3ddb9 Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Tue, 7 Jul 2026 16:14:48 +0530 Subject: [PATCH 09/14] Introduce upstreamDefinitions to llmprovider and MCP types --- .../api/management-openapi.yaml | 10 + .../pkg/api/management/generated.go | 444 +++++++++--------- .../pkg/config/api_validator.go | 128 +---- .../llm_mcp_upstream_ref_validation_test.go | 153 ++++++ .../pkg/config/llm_validator.go | 32 +- .../config/llm_validator_additional_test.go | 6 +- .../pkg/config/mcp_validator.go | 77 +-- .../pkg/config/upstream_validation.go | 168 +++++++ .../pkg/utils/llm_mcp_upstream_ref_test.go | 145 ++++++ .../pkg/utils/llm_transformer.go | 16 +- .../pkg/utils/mcp_transformer.go | 13 +- .../features/upstream-connect-timeout.feature | 75 +++ .../api/v1alpha1/llmprovider_types.go | 5 + .../api/v1alpha1/mcp_types.go | 5 + .../api/v1alpha1/upstream_definition_types.go | 67 +++ .../api/v1alpha1/zz_generated.deepcopy.go | 86 ++++ ...ay.api-platform.wso2.com_llmproviders.yaml | 59 +++ .../gateway.api-platform.wso2.com_mcps.yaml | 59 +++ ...ay.api-platform.wso2.com_llmproviders.yaml | 59 +++ .../gateway.api-platform.wso2.com_mcps.yaml | 59 +++ 20 files changed, 1277 insertions(+), 389 deletions(-) create mode 100644 gateway/gateway-controller/pkg/config/llm_mcp_upstream_ref_validation_test.go create mode 100644 gateway/gateway-controller/pkg/config/upstream_validation.go create mode 100644 gateway/gateway-controller/pkg/utils/llm_mcp_upstream_ref_test.go create mode 100644 kubernetes/gateway-operator/api/v1alpha1/upstream_definition_types.go diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 183e016755..0cfd2bec74 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -5345,6 +5345,11 @@ components: minLength: 1 maxLength: 253 example: "mcp1.example.com" + upstreamDefinitions: + type: array + description: List of reusable upstream definitions with optional timeout configurations. Referenced by upstream.ref. + items: + $ref: "#/components/schemas/UpstreamDefinition" upstream: description: The backend MCP server url and auth configurations allOf: @@ -5813,6 +5818,11 @@ components: type: string description: Template name to use for this LLM Provider example: openai + upstreamDefinitions: + type: array + description: List of reusable upstream definitions with optional timeout configurations. Referenced by upstream.ref. + items: + $ref: "#/components/schemas/UpstreamDefinition" upstream: allOf: - $ref: "#/components/schemas/Upstream" diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index 61a428d785..f0624e9d1f 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -797,6 +797,9 @@ type LLMProviderConfigData struct { Template string `json:"template" yaml:"template"` Upstream LLMProviderConfigData_Upstream `json:"upstream" yaml:"upstream"` + // UpstreamDefinitions List of reusable upstream definitions with optional timeout configurations. Referenced by upstream.ref. + UpstreamDefinitions *[]UpstreamDefinition `json:"upstreamDefinitions,omitempty" yaml:"upstreamDefinitions,omitempty"` + // Version Semantic version of the LLM Provider Version string `json:"version" yaml:"version"` @@ -1098,6 +1101,9 @@ type MCPProxyConfigData struct { // Upstream The backend MCP server url and auth configurations Upstream MCPProxyConfigData_Upstream `json:"upstream" yaml:"upstream"` + // UpstreamDefinitions List of reusable upstream definitions with optional timeout configurations. Referenced by upstream.ref. + UpstreamDefinitions *[]UpstreamDefinition `json:"upstreamDefinitions,omitempty" yaml:"upstreamDefinitions,omitempty"` + // Version MCP Proxy version Version string `json:"version" yaml:"version"` @@ -5843,225 +5849,225 @@ var swaggerSpec = []string{ "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/config/api_validator.go b/gateway/gateway-controller/pkg/config/api_validator.go index daefcfee0a..832b58bcc6 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -264,130 +264,10 @@ func (v *APIValidator) validateUpstreamRef(label string, ref *string, upstreamDe return errors } -// validateUpstreamDefinitions validates the upstreamDefinitions array +// validateUpstreamDefinitions validates the upstreamDefinitions array. Delegates to the shared +// validateUpstreamDefinitionsList so RestApi, LLM Provider, and MCP validate identically. func (v *APIValidator) validateUpstreamDefinitions(definitions *[]api.UpstreamDefinition) []ValidationError { - var errors []ValidationError - - if definitions == nil { - return errors - } - - // Track definition names to check for duplicates - namesSeen := make(map[string]bool) - - for i, def := range *definitions { - // Validate name - if def.Name == "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("spec.upstreamDefinitions[%d].name", i), - Message: "Upstream definition name is required", - }) - continue - } - - // Check for duplicate names - if namesSeen[def.Name] { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("spec.upstreamDefinitions[%d].name", i), - Message: fmt.Sprintf("Duplicate upstream definition name '%s'", def.Name), - }) - continue - } - namesSeen[def.Name] = true - - // Validate upstreams array - if len(def.Upstreams) == 0 { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("spec.upstreamDefinitions[%d].upstreams", i), - Message: "At least one upstream target is required", - }) - } - - for j, upstream := range def.Upstreams { - // Validate URL - if upstream.Url == "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("spec.upstreamDefinitions[%d].upstreams[%d].url", i, j), - Message: "URL is required", - }) - continue - } - - parsedURL, err := url.Parse(upstream.Url) - if err != nil { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("spec.upstreamDefinitions[%d].upstreams[%d].url", i, j), - Message: fmt.Sprintf("Invalid URL format: %v", err), - }) - } else { - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("spec.upstreamDefinitions[%d].upstreams[%d].url", i, j), - Message: "URL must use http or https scheme", - }) - } - - if parsedURL.Host == "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("spec.upstreamDefinitions[%d].upstreams[%d].url", i, j), - Message: "URL must include a host", - }) - } - - // upstreamDefinitions URLs must be host[:port] only — the base path is - // configured exclusively via upstreamDefinitions[].basePath. - if parsedURL.Path != "" && parsedURL.Path != "/" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("spec.upstreamDefinitions[%d].upstreams[%d].url", i, j), - Message: "URL must not include a path; set the base path in upstreamDefinitions[].basePath instead", - }) - } - - // A query string or fragment is not part of the upstream cluster - // (host[:port] only), so it would be silently dropped. Reject it. - if parsedURL.RawQuery != "" || parsedURL.ForceQuery { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("spec.upstreamDefinitions[%d].upstreams[%d].url", i, j), - Message: "URL must not include a query string; only host[:port] is used", - }) - } - - if parsedURL.Fragment != "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("spec.upstreamDefinitions[%d].upstreams[%d].url", i, j), - Message: "URL must not include a fragment; only host[:port] is used", - }) - } - } - - // Validate weight if present - if upstream.Weight != nil { - if *upstream.Weight < 0 || *upstream.Weight > 100 { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("spec.upstreamDefinitions[%d].upstreams[%d].weight", i, j), - Message: "Weight must be between 0 and 100", - }) - } - } - } - - // Timeout validation is limited to connect timeout; request and idle - // timeouts are no longer supported at the upstream definition level. - if def.Timeout != nil && def.Timeout.Connect != nil { - timeoutStr := strings.TrimSpace(*def.Timeout.Connect) - if timeoutStr != "" { - _, err := time.ParseDuration(timeoutStr) - if err != nil { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("spec.upstreamDefinitions[%d].timeout.connect", i), - Message: fmt.Sprintf("Invalid timeout format: %v (expected format: '30s', '1m', '500ms')", err), - }) - } - } - } - } - - return errors + return validateUpstreamDefinitionsList("spec.upstreamDefinitions", definitions) } // validateRestData validates the data section of the configuration for RestApi kind @@ -454,7 +334,7 @@ func (v *APIValidator) validateResilience(fieldPrefix string, r *api.Resilience) return validateResilienceTimeouts(fieldPrefix, r) } -// validateResilienceTimeouts validates the timeout fields of a resilience block. +// validateResilienceTimeouts validates the timeout fields of a resilience block. func validateResilienceTimeouts(fieldPrefix string, r *api.Resilience) []ValidationError { var errors []ValidationError if r == nil { diff --git a/gateway/gateway-controller/pkg/config/llm_mcp_upstream_ref_validation_test.go b/gateway/gateway-controller/pkg/config/llm_mcp_upstream_ref_validation_test.go new file mode 100644 index 0000000000..f9774376ec --- /dev/null +++ b/gateway/gateway-controller/pkg/config/llm_mcp_upstream_ref_validation_test.go @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. 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 config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" +) + +// upstreamDef builds a valid upstream definition (one host-only target) with an optional connect +// timeout. connect == "" leaves the timeout unset. +func upstreamDef(name, connect string) api.UpstreamDefinition { + def := api.UpstreamDefinition{ + Name: name, + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://backend:8080"}, + }, + } + if connect != "" { + def.Timeout = &api.UpstreamTimeout{Connect: stringPtr(connect)} + } + return def +} + +func providerWithUpstream(defs *[]api.UpstreamDefinition, up api.LLMProviderConfigData_Upstream) api.LLMProviderConfiguration { + return api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: api.LLMProviderConfigurationKindLlmProvider, + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "my-provider", + Version: "v1.0", + Template: "openai", + UpstreamDefinitions: defs, + Upstream: up, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } +} + +func mcpWithUpstream(defs *[]api.UpstreamDefinition, up api.MCPProxyConfigData_Upstream) api.MCPProxyConfiguration { + ctx := "/everything" + specVersion := constants.SPEC_VERSION_2025_JUNE + return api.MCPProxyConfiguration{ + ApiVersion: api.MCPProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.MCPProxyConfigurationKindMcp, + Metadata: api.Metadata{Name: "everything"}, + Spec: api.MCPProxyConfigData{ + DisplayName: "Everything", + Version: "v1.0", + Context: &ctx, + SpecVersion: &specVersion, + UpstreamDefinitions: defs, + Upstream: up, + }, + } +} + +func TestValidateLLMProvider_UpstreamRef(t *testing.T) { + validator := NewLLMValidator() + + t.Run("valid ref resolves to a definition", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "6s")} + errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) + assert.Empty(t, errs) + }) + + t.Run("ref not found in definitions", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("other", "6s")} + errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("missing")})) + assertHasFieldError(t, errs, "spec.upstream.ref") + }) + + t.Run("both url and ref rejected", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "6s")} + errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{ + Url: stringPtr("https://api.openai.com"), + Ref: stringPtr("openai-backend"), + })) + assertHasFieldError(t, errs, "spec.upstream") + }) + + t.Run("malformed connect timeout rejected (must match CRD pattern)", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "1h30m")} + errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) + assertHasFieldError(t, errs, "spec.upstreamDefinitions[0].timeout.connect") + }) + + t.Run("valid fractional connect timeout accepted", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "500ms")} + errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) + assert.Empty(t, errs) + }) +} + +func TestValidateMCP_UpstreamRef(t *testing.T) { + validator := NewMCPValidator() + + t.Run("valid ref resolves to a definition", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("mcp-backend", "6s")} + errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{Ref: stringPtr("mcp-backend")})) + assert.Empty(t, errs) + }) + + t.Run("ref not found in definitions", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("other", "6s")} + errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{Ref: stringPtr("missing")})) + assertHasFieldError(t, errs, "spec.upstream.ref") + }) + + t.Run("both url and ref rejected", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("mcp-backend", "6s")} + errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{ + Url: stringPtr("http://backend:8080"), + Ref: stringPtr("mcp-backend"), + })) + assertHasFieldError(t, errs, "spec.upstream") + }) + + t.Run("malformed connect timeout rejected (must match CRD pattern)", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("mcp-backend", "1h30m")} + errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{Ref: stringPtr("mcp-backend")})) + assertHasFieldError(t, errs, "spec.upstreamDefinitions[0].timeout.connect") + }) + + t.Run("valid connect timeout accepted", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("mcp-backend", "5s")} + errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{Ref: stringPtr("mcp-backend")})) + assert.Empty(t, errs) + }) +} diff --git a/gateway/gateway-controller/pkg/config/llm_validator.go b/gateway/gateway-controller/pkg/config/llm_validator.go index 67c82965ff..8ea26767eb 100644 --- a/gateway/gateway-controller/pkg/config/llm_validator.go +++ b/gateway/gateway-controller/pkg/config/llm_validator.go @@ -378,8 +378,10 @@ func (v *LLMValidator) validateProviderSpec(spec *api.LLMProviderConfigData) []V }) } - // Validate upstreams - errors = append(errors, v.validateUpstreamWithAuth(fmt.Sprintf("spec.upstream"), &spec.Upstream)...) + // Validate upstream definitions (name/url/connect timeout), then the upstream itself (which + // may reference one of them via `ref`). + errors = append(errors, validateUpstreamDefinitionsList("spec.upstreamDefinitions", spec.UpstreamDefinitions)...) + errors = append(errors, v.validateUpstreamWithAuth("spec.upstream", &spec.Upstream, spec.UpstreamDefinitions)...) // Validate access control errors = append(errors, v.validateAccessControl("spec.accessControl", &spec.AccessControl)...) @@ -416,9 +418,10 @@ func (v *LLMValidator) validatePolicyListExclusivity(globalPolicies *[]api.Polic return nil } -// validateUpstreamWithAuth validates an UpstreamWithAuth configuration +// validateUpstreamWithAuth validates an UpstreamWithAuth configuration. The upstream may specify +// either a direct `url` or a `ref` to one of the provided upstream definitions (exactly one). func (v *LLMValidator) validateUpstreamWithAuth(fieldPrefix string, - upstream *api.LLMProviderConfigData_Upstream) []ValidationError { + upstream *api.LLMProviderConfigData_Upstream, definitions *[]api.UpstreamDefinition) []ValidationError { var errors []ValidationError if upstream == nil { @@ -429,13 +432,21 @@ func (v *LLMValidator) validateUpstreamWithAuth(fieldPrefix string, return errors } - // Validate URL - if upstream.Url == nil || *upstream.Url == "" { + // Validate url XOR ref + hasURL := upstream.Url != nil && strings.TrimSpace(*upstream.Url) != "" + hasRef := upstream.Ref != nil && strings.TrimSpace(*upstream.Ref) != "" + switch { + case hasURL && hasRef: + errors = append(errors, ValidationError{ + Field: fieldPrefix, + Message: "Specify exactly one of 'url' or 'ref'", + }) + case !hasURL && !hasRef: errors = append(errors, ValidationError{ Field: fmt.Sprintf("%s.url", fieldPrefix), Message: "Upstream URL is required", }) - } else { + case hasURL: parsedURL, err := url.Parse(*upstream.Url) if err != nil { errors = append(errors, ValidationError{ @@ -453,6 +464,13 @@ func (v *LLMValidator) validateUpstreamWithAuth(fieldPrefix string, Message: "Upstream URL must include a host", }) } + case hasRef: + if !upstreamRefResolves(*upstream.Ref, definitions) { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s.ref", fieldPrefix), + Message: fmt.Sprintf("Referenced upstream definition '%s' not found in upstreamDefinitions", strings.TrimSpace(*upstream.Ref)), + }) + } } // Validate auth if present diff --git a/gateway/gateway-controller/pkg/config/llm_validator_additional_test.go b/gateway/gateway-controller/pkg/config/llm_validator_additional_test.go index 00f19e97cc..bd343404b5 100644 --- a/gateway/gateway-controller/pkg/config/llm_validator_additional_test.go +++ b/gateway/gateway-controller/pkg/config/llm_validator_additional_test.go @@ -275,7 +275,7 @@ func TestLLMValidator_ValidateUpstreamWithAuth_Nil(t *testing.T) { validator := NewLLMValidator() t.Run("Nil upstream", func(t *testing.T) { - errors := validator.validateUpstreamWithAuth("test", nil) + errors := validator.validateUpstreamWithAuth("test", nil, nil) if len(errors) != 1 { t.Errorf("Expected 1 error for nil upstream, got %d", len(errors)) @@ -297,7 +297,7 @@ func TestLLMValidator_UpstreamURLValidation(t *testing.T) { Url: &malformed, } - errors := validator.validateUpstreamWithAuth("test", upstream) + errors := validator.validateUpstreamWithAuth("test", upstream, nil) found := false for _, err := range errors { if err.Field == "test.url" && err.Message != "" { @@ -317,7 +317,7 @@ func TestLLMValidator_UpstreamURLValidation(t *testing.T) { Url: &invalidScheme, } - errors := validator.validateUpstreamWithAuth("test", upstream) + errors := validator.validateUpstreamWithAuth("test", upstream, nil) found := false for _, err := range errors { if err.Field == "test.url" && err.Message == "Upstream URL must use http or https scheme" { diff --git a/gateway/gateway-controller/pkg/config/mcp_validator.go b/gateway/gateway-controller/pkg/config/mcp_validator.go index 51b47d49fb..77272a88a7 100644 --- a/gateway/gateway-controller/pkg/config/mcp_validator.go +++ b/gateway/gateway-controller/pkg/config/mcp_validator.go @@ -149,8 +149,10 @@ func (v *MCPValidator) validateSpec(spec *api.MCPProxyConfigData) []ValidationEr // Validate context errors = append(errors, v.validateContextAndVhost(spec.Context, spec.Vhost)...) - // Validate upstream - errors = append(errors, v.validateUpstream("spec.upstream", &spec.Upstream)...) + // Validate upstream definitions (name/url/connect timeout), then the upstream itself (which + // may reference one of them via `ref`). + errors = append(errors, validateUpstreamDefinitionsList("spec.upstreamDefinitions", spec.UpstreamDefinitions)...) + errors = append(errors, v.validateUpstream("spec.upstream", &spec.Upstream, spec.UpstreamDefinitions)...) return errors } @@ -192,8 +194,9 @@ func (v *MCPValidator) validateContextAndVhost(context, vhost *string) []Validat return errors } -// validateUpstream validates the upstream configuration -func (v *MCPValidator) validateUpstream(fieldPrefix string, upstream *api.MCPProxyConfigData_Upstream) []ValidationError { +// validateUpstream validates the upstream configuration. The upstream may specify either a direct +// `url` or a `ref` to one of the provided upstream definitions (exactly one). +func (v *MCPValidator) validateUpstream(fieldPrefix string, upstream *api.MCPProxyConfigData_Upstream, definitions *[]api.UpstreamDefinition) []ValidationError { var errors []ValidationError if upstream == nil { @@ -204,38 +207,52 @@ func (v *MCPValidator) validateUpstream(fieldPrefix string, upstream *api.MCPPro return errors } - if upstream.Url == nil || *upstream.Url == "" { + // Validate url XOR ref + hasURL := upstream.Url != nil && strings.TrimSpace(*upstream.Url) != "" + hasRef := upstream.Ref != nil && strings.TrimSpace(*upstream.Ref) != "" + switch { + case hasURL && hasRef: errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s.url", fieldPrefix), - Message: "Upstream URL is required", - }) - return errors - } - - // Validate URL format - parsedURL, err := url.Parse(*upstream.Url) - if err != nil { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s.url", fieldPrefix), - Message: fmt.Sprintf("Invalid URL format: %v", err), + Field: fieldPrefix, + Message: "Specify exactly one of 'url' or 'ref'", }) - return errors - } - - // Ensure scheme is http or https - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + case !hasURL && !hasRef: errors = append(errors, ValidationError{ Field: fmt.Sprintf("%s.url", fieldPrefix), - Message: "Upstream URL must use http or https scheme", + Message: "Upstream URL is required", }) - } + case hasRef: + if !upstreamRefResolves(*upstream.Ref, definitions) { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s.ref", fieldPrefix), + Message: fmt.Sprintf("Referenced upstream definition '%s' not found in upstreamDefinitions", strings.TrimSpace(*upstream.Ref)), + }) + } + case hasURL: + // Validate URL format + parsedURL, err := url.Parse(*upstream.Url) + if err != nil { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s.url", fieldPrefix), + Message: fmt.Sprintf("Invalid URL format: %v", err), + }) + } else { + // Ensure scheme is http or https + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s.url", fieldPrefix), + Message: "Upstream URL must use http or https scheme", + }) + } - // Ensure host is present - if parsedURL.Host == "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s.url", fieldPrefix), - Message: "Upstream URL must include a host", - }) + // Ensure host is present + if parsedURL.Host == "" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s.url", fieldPrefix), + Message: "Upstream URL must include a host", + }) + } + } } // Validate auth if present diff --git a/gateway/gateway-controller/pkg/config/upstream_validation.go b/gateway/gateway-controller/pkg/config/upstream_validation.go new file mode 100644 index 0000000000..73267338c9 --- /dev/null +++ b/gateway/gateway-controller/pkg/config/upstream_validation.go @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. 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 config + +import ( + "fmt" + "net/url" + "strings" + + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" +) + +// validateUpstreamDefinitionsList validates an upstreamDefinitions array. fieldPrefix is the path +// to the array (e.g. "spec.upstreamDefinitions"). It is shared by the RestApi, LLM Provider, and +// MCP validators so the three kinds validate upstream definitions identically. The connect timeout +// is validated against the shared CRD duration pattern (constants.ResilienceDurationRegex) so +// gateway-side validation matches CRD admission (compound/unitless/negative values are rejected). +func validateUpstreamDefinitionsList(fieldPrefix string, definitions *[]api.UpstreamDefinition) []ValidationError { + var errors []ValidationError + + if definitions == nil { + return errors + } + + // Track definition names to check for duplicates + namesSeen := make(map[string]bool) + + for i, def := range *definitions { + // Validate name + if def.Name == "" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].name", fieldPrefix, i), + Message: "Upstream definition name is required", + }) + continue + } + + // Check for duplicate names + if namesSeen[def.Name] { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].name", fieldPrefix, i), + Message: fmt.Sprintf("Duplicate upstream definition name '%s'", def.Name), + }) + continue + } + namesSeen[def.Name] = true + + // Validate upstreams array + if len(def.Upstreams) == 0 { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams", fieldPrefix, i), + Message: "At least one upstream target is required", + }) + } + + for j, upstream := range def.Upstreams { + if upstream.Url == "" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: "URL is required", + }) + continue + } + + parsedURL, err := url.Parse(upstream.Url) + if err != nil { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: fmt.Sprintf("Invalid URL format: %v", err), + }) + } else { + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: "URL must use http or https scheme", + }) + } + + if parsedURL.Host == "" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: "URL must include a host", + }) + } + + // upstreamDefinitions URLs must be host[:port] only — the base path is + // configured exclusively via upstreamDefinitions[].basePath. + if parsedURL.Path != "" && parsedURL.Path != "/" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: "URL must not include a path; set the base path in upstreamDefinitions[].basePath instead", + }) + } + + // A query string or fragment is not part of the upstream cluster + // (host[:port] only), so it would be silently dropped. Reject it. + if parsedURL.RawQuery != "" || parsedURL.ForceQuery { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: "URL must not include a query string; only host[:port] is used", + }) + } + + if parsedURL.Fragment != "" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: "URL must not include a fragment; only host[:port] is used", + }) + } + } + + // Validate weight if present + if upstream.Weight != nil { + if *upstream.Weight < 0 || *upstream.Weight > 100 { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].weight", fieldPrefix, i, j), + Message: "Weight must be between 0 and 100", + }) + } + } + } + + // Timeout validation is limited to connect timeout. Enforce the same single-unit duration + // pattern as the CRD/OpenAPI so gateway validation cannot diverge from CRD admission. + if def.Timeout != nil && def.Timeout.Connect != nil { + timeoutStr := strings.TrimSpace(*def.Timeout.Connect) + if timeoutStr != "" && !constants.ResilienceDurationRegex.MatchString(timeoutStr) { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].timeout.connect", fieldPrefix, i), + Message: "Invalid timeout format (expected a single-unit duration like '30s', '1m', '500ms')", + }) + } + } + } + + return errors +} + +// upstreamRefResolves reports whether ref names one of the provided upstream definitions. +func upstreamRefResolves(ref string, definitions *[]api.UpstreamDefinition) bool { + if definitions == nil { + return false + } + refName := strings.TrimSpace(ref) + for _, def := range *definitions { + if def.Name == refName { + return true + } + } + return false +} diff --git a/gateway/gateway-controller/pkg/utils/llm_mcp_upstream_ref_test.go b/gateway/gateway-controller/pkg/utils/llm_mcp_upstream_ref_test.go new file mode 100644 index 0000000000..0d9a2df2fe --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/llm_mcp_upstream_ref_test.go @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. 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 utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" +) + +// These tests verify that the LLM Provider and MCP converters map an upstream `ref` onto the +// derived RestAPI (instead of a direct url) and thread the upstreamDefinitions through, so the +// per-upstream connect timeout resolves the same way it does for RestApi. + +func TestTransform_Provider_UpstreamRef_ThreadsDefinitions(t *testing.T) { + transformer, _ := setupTestTransformer(t) + + defs := &[]api.UpstreamDefinition{{ + Name: "openai-backend", + Timeout: &api.UpstreamTimeout{Connect: stringPtr("6s")}, + }} + provider := &api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: "LlmProvider", + Metadata: api.Metadata{Name: "openai-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "ref-provider", + Version: "v1.0", + Template: "openai", + UpstreamDefinitions: defs, + Upstream: api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")}, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + + res, err := transformer.Transform(provider, &api.RestAPI{}) + require.NoError(t, err) + + require.NotNil(t, res.Spec.Upstream.Main.Ref, "ref should be mapped onto the derived upstream") + assert.Equal(t, "openai-backend", *res.Spec.Upstream.Main.Ref) + assert.Nil(t, res.Spec.Upstream.Main.Url, "ref-based upstream must not also set url") + + require.NotNil(t, res.Spec.UpstreamDefinitions, "upstreamDefinitions must be threaded through") + require.Len(t, *res.Spec.UpstreamDefinitions, 1) + require.NotNil(t, (*res.Spec.UpstreamDefinitions)[0].Timeout) + assert.Equal(t, "6s", *(*res.Spec.UpstreamDefinitions)[0].Timeout.Connect) +} + +func TestTransform_Provider_UpstreamUrl_Unchanged(t *testing.T) { + transformer, _ := setupTestTransformer(t) + + provider := &api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: "LlmProvider", + Metadata: api.Metadata{Name: "openai-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "url-provider", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.openai.com")}, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + + res, err := transformer.Transform(provider, &api.RestAPI{}) + require.NoError(t, err) + + require.NotNil(t, res.Spec.Upstream.Main.Url) + assert.Equal(t, "https://api.openai.com", *res.Spec.Upstream.Main.Url) + assert.Nil(t, res.Spec.Upstream.Main.Ref) + assert.Nil(t, res.Spec.UpstreamDefinitions) +} + +func TestMCPTransform_UpstreamRef_ThreadsDefinitions(t *testing.T) { + context := "/everything" + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + defs := []api.UpstreamDefinition{{ + Name: "mcp-backend", + Timeout: &api.UpstreamTimeout{Connect: stringPtr("6s")}, + }} + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "everything", + Version: "v1.0", + Context: &context, + SpecVersion: &latest, + UpstreamDefinitions: &defs, + Upstream: api.MCPProxyConfigData_Upstream{Ref: stringPtr("mcp-backend")}, + }, + } + + var out api.RestAPI + res, err := (&MCPTransformer{}).Transform(in, &out) + require.NoError(t, err) + + require.NotNil(t, res.Spec.Upstream.Main.Ref) + assert.Equal(t, "mcp-backend", *res.Spec.Upstream.Main.Ref) + assert.Nil(t, res.Spec.Upstream.Main.Url) + + require.NotNil(t, res.Spec.UpstreamDefinitions) + require.Len(t, *res.Spec.UpstreamDefinitions, 1) + require.NotNil(t, (*res.Spec.UpstreamDefinitions)[0].Timeout) + assert.Equal(t, "6s", *(*res.Spec.UpstreamDefinitions)[0].Timeout.Connect) +} + +func TestMCPTransform_UpstreamUrl_Unchanged(t *testing.T) { + context := "/everything" + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "everything", + Version: "v1.0", + Context: &context, + SpecVersion: &latest, + Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:8080")}, + }, + } + + var out api.RestAPI + res, err := (&MCPTransformer{}).Transform(in, &out) + require.NoError(t, err) + + require.NotNil(t, res.Spec.Upstream.Main.Url) + assert.Equal(t, "http://backend:8080", *res.Spec.Upstream.Main.Url) + assert.Nil(t, res.Spec.Upstream.Main.Ref) + assert.Nil(t, res.Spec.UpstreamDefinitions) +} diff --git a/gateway/gateway-controller/pkg/utils/llm_transformer.go b/gateway/gateway-controller/pkg/utils/llm_transformer.go index d4e1409062..52fe234c64 100644 --- a/gateway/gateway-controller/pkg/utils/llm_transformer.go +++ b/gateway/gateway-controller/pkg/utils/llm_transformer.go @@ -344,11 +344,19 @@ func (t *LLMProviderTransformer) transformProvider(provider *api.LLMProviderConf spec.Context = *provider.Spec.Context } - // Step 2) Upstreams: map provider.Spec.Upstreams to api.Upstreams - // Map provider upstream and vhost to API main upstream and vhost - spec.Upstream.Main = api.Upstream{ - Url: provider.Spec.Upstream.Url, + // Step 2) Upstreams: map provider upstream (direct url or upstreamDefinition ref) and vhost + // to the API main upstream. When a ref is used, carry the upstreamDefinitions through so the + // per-upstream connect timeout resolves the same way it does for RestApi. + if provider.Spec.Upstream.Ref != nil && strings.TrimSpace(*provider.Spec.Upstream.Ref) != "" { + spec.Upstream.Main = api.Upstream{ + Ref: provider.Spec.Upstream.Ref, + } + } else { + spec.Upstream.Main = api.Upstream{ + Url: provider.Spec.Upstream.Url, + } } + spec.UpstreamDefinitions = provider.Spec.UpstreamDefinitions if provider.Spec.Vhost != nil { spec.Vhosts = &struct { Main string `json:"main" yaml:"main"` diff --git a/gateway/gateway-controller/pkg/utils/mcp_transformer.go b/gateway/gateway-controller/pkg/utils/mcp_transformer.go index 2e5b0c6451..b7d25bda9c 100644 --- a/gateway/gateway-controller/pkg/utils/mcp_transformer.go +++ b/gateway/gateway-controller/pkg/utils/mcp_transformer.go @@ -114,9 +114,18 @@ func (t *MCPTransformer) Transform(input any, output *api.RestAPI) (*api.RestAPI apiData.Context = *mcpConfig.Spec.Context } - apiData.Upstream.Main = api.Upstream{ - Url: mcpConfig.Spec.Upstream.Url, + // Map the MCP backend (direct url or upstreamDefinition ref). When a ref is used, carry the + // upstreamDefinitions through so the per-upstream connect timeout resolves like it does for RestApi. + if mcpConfig.Spec.Upstream.Ref != nil && strings.TrimSpace(*mcpConfig.Spec.Upstream.Ref) != "" { + apiData.Upstream.Main = api.Upstream{ + Ref: mcpConfig.Spec.Upstream.Ref, + } + } else { + apiData.Upstream.Main = api.Upstream{ + Url: mcpConfig.Spec.Upstream.Url, + } } + apiData.UpstreamDefinitions = mcpConfig.Spec.UpstreamDefinitions // Process policies var policies []api.Policy diff --git a/gateway/it/features/upstream-connect-timeout.feature b/gateway/it/features/upstream-connect-timeout.feature index ab855c50be..451ea0684d 100644 --- a/gateway/it/features/upstream-connect-timeout.feature +++ b/gateway/it/features/upstream-connect-timeout.feature @@ -140,3 +140,78 @@ Feature: Timeouts When I delete the API "headers-timeout-api-v1.0" Then the response should be successful + # LLM Provider connect_timeout via upstreamDefinitions ref: the provider's upstream references a + # definition whose only target is unreachable (192.0.2.1). The connect attempt hangs until the + # per-upstream connect timeout (6s), then the gateway returns 503. Proves the ref -> upstreamDefinition + # connect timeout works for LLM providers exactly as it does for RestApi. + Scenario: LLM provider backend connect timeout using upstreamDefinitions ref + Given I authenticate using basic auth as "admin" + When I create this LLM provider: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: LlmProvider + metadata: + name: llm-connect-timeout-provider + spec: + displayName: LLM Connect Timeout Provider + version: v1.0 + template: openai + context: /llm-connect-timeout + upstreamDefinitions: + - name: llm-unreachable-upstream + timeout: + connect: 6000ms + upstreams: + - url: http://192.0.2.1:8080 + upstream: + ref: llm-unreachable-upstream + accessControl: + mode: allow_all + """ + Then the response status code should be 201 + And I wait for policy snapshot sync + And I record the current time as "request_start" + When I send a GET request to "http://localhost:8080/llm-connect-timeout/get" + Then the response status code should be 503 + And the request should have taken at least "6" seconds since "request_start" + Given I authenticate using basic auth as "admin" + When I delete the LLM provider "llm-connect-timeout-provider" + Then the response should be successful + + # MCP connect_timeout via upstreamDefinitions ref: the MCP backend reference is unreachable, so the + # synthesized /mcp route's connect attempt hangs until the per-upstream connect timeout (6s) -> 503. + Scenario: MCP backend connect timeout using upstreamDefinitions ref + Given I authenticate using basic auth as "admin" + When I deploy this MCP configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: Mcp + metadata: + name: mcp-connect-timeout + spec: + displayName: MCP Connect Timeout + version: v1.0 + context: /mcp-connect-timeout + specVersion: "2025-06-18" + upstreamDefinitions: + - name: mcp-unreachable-upstream + timeout: + connect: 6000ms + upstreams: + - url: http://192.0.2.1:3001 + upstream: + ref: mcp-unreachable-upstream + tools: [] + resources: [] + prompts: [] + """ + Then the response should be successful + And I wait for policy snapshot sync + And I record the current time as "request_start" + When I send a GET request to "http://localhost:8080/mcp-connect-timeout/mcp" + Then the response status code should be 503 + And the request should have taken at least "6" seconds since "request_start" + Given I authenticate using basic auth as "admin" + When I delete the MCP proxy "mcp-connect-timeout" + Then the response should be successful + diff --git a/kubernetes/gateway-operator/api/v1alpha1/llmprovider_types.go b/kubernetes/gateway-operator/api/v1alpha1/llmprovider_types.go index 9535b86216..9b1c0be98e 100644 --- a/kubernetes/gateway-operator/api/v1alpha1/llmprovider_types.go +++ b/kubernetes/gateway-operator/api/v1alpha1/llmprovider_types.go @@ -107,6 +107,11 @@ type LLMProviderConfigData struct { // +kubebuilder:validation:Required AccessControl LLMAccessControl `json:"accessControl"` + // UpstreamDefinitions is the list of reusable upstream definitions (with optional + // connect timeout) that upstream.ref can reference. + // +optional + UpstreamDefinitions []UpstreamDefinition `json:"upstreamDefinitions,omitempty"` + // Upstream configures the LLM upstream. // +kubebuilder:validation:Required Upstream LLMProviderUpstream `json:"upstream"` diff --git a/kubernetes/gateway-operator/api/v1alpha1/mcp_types.go b/kubernetes/gateway-operator/api/v1alpha1/mcp_types.go index 8044dd8c79..48367a217f 100644 --- a/kubernetes/gateway-operator/api/v1alpha1/mcp_types.go +++ b/kubernetes/gateway-operator/api/v1alpha1/mcp_types.go @@ -156,6 +156,11 @@ type MCPProxyConfigData struct { // +kubebuilder:validation:Pattern=`^v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$` Version string `json:"version"` + // UpstreamDefinitions is the list of reusable upstream definitions (with optional + // connect timeout) that upstream.ref can reference. + // +optional + UpstreamDefinitions []UpstreamDefinition `json:"upstreamDefinitions,omitempty"` + // Upstream is the MCP backend. // +kubebuilder:validation:Required Upstream MCPUpstream `json:"upstream"` diff --git a/kubernetes/gateway-operator/api/v1alpha1/upstream_definition_types.go b/kubernetes/gateway-operator/api/v1alpha1/upstream_definition_types.go new file mode 100644 index 0000000000..ebf79c8f89 --- /dev/null +++ b/kubernetes/gateway-operator/api/v1alpha1/upstream_definition_types.go @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. 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 + +// UpstreamDefinition is a reusable upstream configuration with an optional connect +// timeout and load-balancing targets. Referenced from an upstream via its `ref` field. +// Mirrors the management-API UpstreamDefinition schema. +type UpstreamDefinition struct { + // Name Unique identifier for this upstream definition (referenced by upstream.ref). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=100 + // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9\-_]+$` + Name string `json:"name"` + + // BasePath Base path prefix prepended to all requests routed through this upstream (e.g. /api/v2). + // +optional + BasePath *string `json:"basePath,omitempty"` + + // Timeout Optional timeout configuration for this upstream (connect timeout). + // +optional + Timeout *UpstreamTimeout `json:"timeout,omitempty"` + + // Upstreams List of backend targets with optional weights for load balancing. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + Upstreams []UpstreamTarget `json:"upstreams"` +} + +// UpstreamTimeout carries the per-upstream timeout configuration. Only the connect +// timeout is supported at the upstream-definition level. +type UpstreamTimeout struct { + // Connect Connection-establishment timeout duration (e.g. "5s", "500ms"). "0s" disables. + // +optional + // +kubebuilder:validation:Pattern=`^\d+(\.\d+)?(ms|s|m|h)$` + Connect *string `json:"connect,omitempty"` +} + +// UpstreamTarget is a single backend target within an UpstreamDefinition. +type UpstreamTarget struct { + // Url Backend URL (host and port only; path comes from the definition's basePath). + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$` + Url string `json:"url"` + + // Weight Weight for load balancing (optional, default 100). + // +optional + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=100 + Weight *int `json:"weight,omitempty"` +} diff --git a/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go b/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go index 69e6912d3f..43837970b1 100644 --- a/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -779,6 +779,13 @@ func (in *LLMPolicyPath) DeepCopy() *LLMPolicyPath { func (in *LLMProviderConfigData) DeepCopyInto(out *LLMProviderConfigData) { *out = *in in.AccessControl.DeepCopyInto(&out.AccessControl) + if in.UpstreamDefinitions != nil { + in, out := &in.UpstreamDefinitions, &out.UpstreamDefinitions + *out = make([]UpstreamDefinition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } in.Upstream.DeepCopyInto(&out.Upstream) if in.Context != nil { in, out := &in.Context, &out.Context @@ -1297,6 +1304,13 @@ func (in *MCPPromptArgument) DeepCopy() *MCPPromptArgument { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPProxyConfigData) DeepCopyInto(out *MCPProxyConfigData) { *out = *in + if in.UpstreamDefinitions != nil { + in, out := &in.UpstreamDefinitions, &out.UpstreamDefinitions + *out = make([]UpstreamDefinition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } in.Upstream.DeepCopyInto(&out.Upstream) if in.Context != nil { in, out := &in.Context, &out.Context @@ -2085,6 +2099,78 @@ func (in *UpstreamConfig) DeepCopy() *UpstreamConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpstreamDefinition) DeepCopyInto(out *UpstreamDefinition) { + *out = *in + if in.BasePath != nil { + in, out := &in.BasePath, &out.BasePath + *out = new(string) + **out = **in + } + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(UpstreamTimeout) + (*in).DeepCopyInto(*out) + } + if in.Upstreams != nil { + in, out := &in.Upstreams, &out.Upstreams + *out = make([]UpstreamTarget, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamDefinition. +func (in *UpstreamDefinition) DeepCopy() *UpstreamDefinition { + if in == nil { + return nil + } + out := new(UpstreamDefinition) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpstreamTarget) DeepCopyInto(out *UpstreamTarget) { + *out = *in + if in.Weight != nil { + in, out := &in.Weight, &out.Weight + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamTarget. +func (in *UpstreamTarget) DeepCopy() *UpstreamTarget { + if in == nil { + return nil + } + out := new(UpstreamTarget) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpstreamTimeout) DeepCopyInto(out *UpstreamTimeout) { + *out = *in + if in.Connect != nil { + in, out := &in.Connect, &out.Connect + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamTimeout. +func (in *UpstreamTimeout) DeepCopy() *UpstreamTimeout { + if in == nil { + return nil + } + out := new(UpstreamTimeout) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VhostConfig) DeepCopyInto(out *VhostConfig) { *out = *in diff --git a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml index 78f1879087..c38f002ed5 100644 --- a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml +++ b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml @@ -246,6 +246,65 @@ spec: description: Url is the direct backend URL. type: string type: object + upstreamDefinitions: + description: |- + UpstreamDefinitions is the list of reusable upstream definitions (with optional + connect timeout) that upstream.ref can reference. + items: + description: |- + UpstreamDefinition is a reusable upstream configuration with an optional connect + timeout and load-balancing targets. Referenced from an upstream via its `ref` field. + Mirrors the management-API UpstreamDefinition schema. + properties: + basePath: + description: BasePath Base path prefix prepended to all requests + routed through this upstream (e.g. /api/v2). + type: string + name: + description: Name Unique identifier for this upstream definition + (referenced by upstream.ref). + maxLength: 100 + minLength: 1 + pattern: ^[a-zA-Z0-9\-_]+$ + type: string + timeout: + description: Timeout Optional timeout configuration for this + upstream (connect timeout). + properties: + connect: + description: Connect Connection-establishment timeout duration + (e.g. "5s", "500ms"). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object + upstreams: + description: Upstreams List of backend targets with optional + weights for load balancing. + items: + description: UpstreamTarget is a single backend target within + an UpstreamDefinition. + properties: + url: + description: Url Backend URL (host and port only; path + comes from the definition's basePath). + pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ + type: string + weight: + description: Weight Weight for load balancing (optional, + default 100). + maximum: 100 + minimum: 0 + type: integer + required: + - url + type: object + minItems: 1 + type: array + required: + - name + - upstreams + type: object + type: array version: description: Version is the semantic version of the LLM provider. pattern: ^v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ diff --git a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml index e6d3402a6c..ef8c6b80b9 100644 --- a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml +++ b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml @@ -254,6 +254,65 @@ spec: description: Url is the direct backend URL. type: string type: object + upstreamDefinitions: + description: |- + UpstreamDefinitions is the list of reusable upstream definitions (with optional + connect timeout) that upstream.ref can reference. + items: + description: |- + UpstreamDefinition is a reusable upstream configuration with an optional connect + timeout and load-balancing targets. Referenced from an upstream via its `ref` field. + Mirrors the management-API UpstreamDefinition schema. + properties: + basePath: + description: BasePath Base path prefix prepended to all requests + routed through this upstream (e.g. /api/v2). + type: string + name: + description: Name Unique identifier for this upstream definition + (referenced by upstream.ref). + maxLength: 100 + minLength: 1 + pattern: ^[a-zA-Z0-9\-_]+$ + type: string + timeout: + description: Timeout Optional timeout configuration for this + upstream (connect timeout). + properties: + connect: + description: Connect Connection-establishment timeout duration + (e.g. "5s", "500ms"). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object + upstreams: + description: Upstreams List of backend targets with optional + weights for load balancing. + items: + description: UpstreamTarget is a single backend target within + an UpstreamDefinition. + properties: + url: + description: Url Backend URL (host and port only; path + comes from the definition's basePath). + pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ + type: string + weight: + description: Weight Weight for load balancing (optional, + default 100). + maximum: 100 + minimum: 0 + type: integer + required: + - url + type: object + minItems: 1 + type: array + required: + - name + - upstreams + type: object + type: array version: description: Version is the MCP proxy semantic version. pattern: ^v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ diff --git a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml index 78f1879087..c38f002ed5 100644 --- a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml +++ b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml @@ -246,6 +246,65 @@ spec: description: Url is the direct backend URL. type: string type: object + upstreamDefinitions: + description: |- + UpstreamDefinitions is the list of reusable upstream definitions (with optional + connect timeout) that upstream.ref can reference. + items: + description: |- + UpstreamDefinition is a reusable upstream configuration with an optional connect + timeout and load-balancing targets. Referenced from an upstream via its `ref` field. + Mirrors the management-API UpstreamDefinition schema. + properties: + basePath: + description: BasePath Base path prefix prepended to all requests + routed through this upstream (e.g. /api/v2). + type: string + name: + description: Name Unique identifier for this upstream definition + (referenced by upstream.ref). + maxLength: 100 + minLength: 1 + pattern: ^[a-zA-Z0-9\-_]+$ + type: string + timeout: + description: Timeout Optional timeout configuration for this + upstream (connect timeout). + properties: + connect: + description: Connect Connection-establishment timeout duration + (e.g. "5s", "500ms"). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object + upstreams: + description: Upstreams List of backend targets with optional + weights for load balancing. + items: + description: UpstreamTarget is a single backend target within + an UpstreamDefinition. + properties: + url: + description: Url Backend URL (host and port only; path + comes from the definition's basePath). + pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ + type: string + weight: + description: Weight Weight for load balancing (optional, + default 100). + maximum: 100 + minimum: 0 + type: integer + required: + - url + type: object + minItems: 1 + type: array + required: + - name + - upstreams + type: object + type: array version: description: Version is the semantic version of the LLM provider. pattern: ^v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ diff --git a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml index e6d3402a6c..ef8c6b80b9 100644 --- a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml +++ b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml @@ -254,6 +254,65 @@ spec: description: Url is the direct backend URL. type: string type: object + upstreamDefinitions: + description: |- + UpstreamDefinitions is the list of reusable upstream definitions (with optional + connect timeout) that upstream.ref can reference. + items: + description: |- + UpstreamDefinition is a reusable upstream configuration with an optional connect + timeout and load-balancing targets. Referenced from an upstream via its `ref` field. + Mirrors the management-API UpstreamDefinition schema. + properties: + basePath: + description: BasePath Base path prefix prepended to all requests + routed through this upstream (e.g. /api/v2). + type: string + name: + description: Name Unique identifier for this upstream definition + (referenced by upstream.ref). + maxLength: 100 + minLength: 1 + pattern: ^[a-zA-Z0-9\-_]+$ + type: string + timeout: + description: Timeout Optional timeout configuration for this + upstream (connect timeout). + properties: + connect: + description: Connect Connection-establishment timeout duration + (e.g. "5s", "500ms"). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object + upstreams: + description: Upstreams List of backend targets with optional + weights for load balancing. + items: + description: UpstreamTarget is a single backend target within + an UpstreamDefinition. + properties: + url: + description: Url Backend URL (host and port only; path + comes from the definition's basePath). + pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ + type: string + weight: + description: Weight Weight for load balancing (optional, + default 100). + maximum: 100 + minimum: 0 + type: integer + required: + - url + type: object + minItems: 1 + type: array + required: + - name + - upstreams + type: object + type: array version: description: Version is the MCP proxy semantic version. pattern: ^v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ From 8c46be3a6fead7af025b948ccf1cd49a90e02f07 Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Tue, 7 Jul 2026 17:06:18 +0530 Subject: [PATCH 10/14] Adding API level timeout support(resiliency) for the MCP APIs --- .../api/management-openapi.yaml | 9 + .../pkg/api/management/generated.go | 404 +++++++++--------- .../pkg/config/mcp_validator.go | 4 + .../config/mcp_validator_resilience_test.go | 82 ++++ .../pkg/utils/mcp_resilience_test.go | 132 ++++++ .../pkg/utils/mcp_transformer.go | 42 ++ .../api/v1alpha1/mcp_types.go | 6 + .../api/v1alpha1/zz_generated.deepcopy.go | 5 + .../gateway.api-platform.wso2.com_mcps.yaml | 16 + .../gateway.api-platform.wso2.com_mcps.yaml | 16 + 10 files changed, 516 insertions(+), 200 deletions(-) create mode 100644 gateway/gateway-controller/pkg/config/mcp_validator_resilience_test.go create mode 100644 gateway/gateway-controller/pkg/utils/mcp_resilience_test.go diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 0cfd2bec74..f062f7a3fb 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -5378,6 +5378,15 @@ components: enum: [deployed, undeployed] default: deployed example: deployed + resilience: + $ref: '#/components/schemas/Resilience' + description: > + API-level backend/route timeout configuration. Applies to the traffic-forwarding + routes generated for this MCP proxy (GET/POST/DELETE on the MCP resource path). + Supported at the API level only. Because MCP transports are long-lived streams, + the route timeout defaults to disabled ("0s") for MCP unless a timeout is set here + (unlike REST/LLM, which fall back to the gateway's global route timeout); the idle + timeout remains the liveness guard. MCPTool: type: object diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index f0624e9d1f..6d973b27e1 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -1090,9 +1090,12 @@ type MCPProxyConfigData struct { DisplayName string `json:"displayName" yaml:"displayName"` // Policies List of MCP Proxy level policies applied - Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` - Prompts *[]MCPPrompt `json:"prompts,omitempty" yaml:"prompts,omitempty"` - Resources *[]MCPResource `json:"resources,omitempty" yaml:"resources,omitempty"` + Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + Prompts *[]MCPPrompt `json:"prompts,omitempty" yaml:"prompts,omitempty"` + + // Resilience Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply. + Resilience *Resilience `json:"resilience,omitempty" yaml:"resilience,omitempty"` + Resources *[]MCPResource `json:"resources,omitempty" yaml:"resources,omitempty"` // SpecVersion MCP specification version SpecVersion *string `json:"specVersion,omitempty" yaml:"specVersion,omitempty"` @@ -5871,203 +5874,204 @@ var swaggerSpec = []string{ "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", + "AA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/config/mcp_validator.go b/gateway/gateway-controller/pkg/config/mcp_validator.go index 77272a88a7..8a4ed0a5b2 100644 --- a/gateway/gateway-controller/pkg/config/mcp_validator.go +++ b/gateway/gateway-controller/pkg/config/mcp_validator.go @@ -154,6 +154,10 @@ func (v *MCPValidator) validateSpec(spec *api.MCPProxyConfigData) []ValidationEr errors = append(errors, validateUpstreamDefinitionsList("spec.upstreamDefinitions", spec.UpstreamDefinitions)...) errors = append(errors, v.validateUpstream("spec.upstream", &spec.Upstream, spec.UpstreamDefinitions)...) + // Validate API-level resilience (timeout / idleTimeout). MCP supports resilience at the API + // level only; the route timeout defaults to disabled for MCP when unset (see mcp-timeout-divergence.md). + errors = append(errors, validateResilienceTimeouts("spec.resilience", spec.Resilience)...) + return errors } diff --git a/gateway/gateway-controller/pkg/config/mcp_validator_resilience_test.go b/gateway/gateway-controller/pkg/config/mcp_validator_resilience_test.go new file mode 100644 index 0000000000..759e6f68f6 --- /dev/null +++ b/gateway/gateway-controller/pkg/config/mcp_validator_resilience_test.go @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. 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 config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" +) + +func mcpWithResilience(r *api.Resilience) api.MCPProxyConfiguration { + ctx := "/everything" + specVersion := constants.SPEC_VERSION_2025_JUNE + return api.MCPProxyConfiguration{ + ApiVersion: api.MCPProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.MCPProxyConfigurationKindMcp, + Metadata: api.Metadata{Name: "everything"}, + Spec: api.MCPProxyConfigData{ + DisplayName: "Everything", + Version: "v1.0", + Context: &ctx, + SpecVersion: &specVersion, + Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:3001")}, + Resilience: r, + }, + } +} + +func TestValidateMCP_Resilience(t *testing.T) { + validator := NewMCPValidator() + + t.Run("valid timeout and idleTimeout", func(t *testing.T) { + errs := validator.Validate(mcpWithResilience(&api.Resilience{ + Timeout: stringPtr("30s"), + IdleTimeout: stringPtr("0s"), + })) + assert.Empty(t, errs) + }) + + t.Run("nil resilience is fine (defaults applied downstream)", func(t *testing.T) { + errs := validator.Validate(mcpWithResilience(nil)) + assert.Empty(t, errs) + }) + + t.Run("0s is accepted (explicit disable)", func(t *testing.T) { + errs := validator.Validate(mcpWithResilience(&api.Resilience{Timeout: stringPtr("0s")})) + assert.Empty(t, errs) + }) + + t.Run("malformed timeout is rejected", func(t *testing.T) { + errs := validator.Validate(mcpWithResilience(&api.Resilience{Timeout: stringPtr("30")})) + assertHasFieldError(t, errs, "spec.resilience.timeout") + }) + + t.Run("compound timeout is rejected (must match CRD pattern)", func(t *testing.T) { + errs := validator.Validate(mcpWithResilience(&api.Resilience{Timeout: stringPtr("1h30m")})) + assertHasFieldError(t, errs, "spec.resilience.timeout") + }) + + t.Run("negative idleTimeout is rejected", func(t *testing.T) { + errs := validator.Validate(mcpWithResilience(&api.Resilience{IdleTimeout: stringPtr("-5s")})) + assertHasFieldError(t, errs, "spec.resilience.idleTimeout") + }) +} diff --git a/gateway/gateway-controller/pkg/utils/mcp_resilience_test.go b/gateway/gateway-controller/pkg/utils/mcp_resilience_test.go new file mode 100644 index 0000000000..092d9935da --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/mcp_resilience_test.go @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. 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 utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" +) + +// mcpOpResilience returns the resilience block attached to the operation matching method+path, +// or nil if the operation is absent or carries no resilience. +func mcpOpResilience(ops []api.Operation, method api.OperationMethod, path string) *api.Resilience { + for _, op := range ops { + if op.Method == method && op.Path == path { + return op.Resilience + } + } + return nil +} + +// When no resilience is set, the MCP forwarding routes (GET/POST/DELETE /mcp) default the route +// timeout to "0s" (disabled) — MCP is streaming, so a finite total cap would sever a live stream. +// The local-response routes (OPTIONS, PRM) must carry no resilience. See mcp-timeout-divergence.md. +func TestMCPTransform_Resilience_DefaultsRouteTimeoutDisabled(t *testing.T) { + context := "/everything" + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "everything", + Version: "v1.0", + Context: &context, + SpecVersion: &latest, + Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:8080")}, + // A cors policy makes the transformer emit the OPTIONS routes so we can assert they are skipped. + Policies: &[]api.Policy{{Name: "cors", Version: "v1"}}, + }, + } + + res, err := (&MCPTransformer{}).Transform(in, &api.RestAPI{}) + require.NoError(t, err) + ops := res.Spec.Operations + + for _, m := range []api.OperationMethod{api.OperationMethodGET, api.OperationMethodPOST, api.OperationMethodDELETE} { + r := mcpOpResilience(ops, m, constants.MCP_RESOURCE_PATH) + require.NotNil(t, r, "forwarding %s %s should carry resilience", m, constants.MCP_RESOURCE_PATH) + require.NotNil(t, r.Timeout) + assert.Equal(t, "0s", *r.Timeout, "route timeout should default to disabled for MCP") + assert.Nil(t, r.IdleTimeout, "idle timeout should be left to the global default when unset") + } + + // Local-response routes must not carry a route timeout. + assert.Nil(t, mcpOpResilience(ops, api.OperationMethodOPTIONS, constants.MCP_RESOURCE_PATH), "OPTIONS /mcp is a local reply") + assert.Nil(t, mcpOpResilience(ops, api.OperationMethodGET, constants.MCP_PRM_RESOURCE_PATH), "PRM route is a local reply") + assert.Nil(t, mcpOpResilience(ops, api.OperationMethodOPTIONS, constants.MCP_PRM_RESOURCE_PATH), "OPTIONS PRM is a local reply") +} + +// An explicit resilience block overrides the disabled default and its idleTimeout is carried through, +// on the forwarding routes only. +func TestMCPTransform_Resilience_UserOverride(t *testing.T) { + context := "/everything" + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "everything", + Version: "v1.0", + Context: &context, + SpecVersion: &latest, + Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:8080")}, + Resilience: &api.Resilience{Timeout: stringPtr("30s"), IdleTimeout: stringPtr("120s")}, + }, + } + + res, err := (&MCPTransformer{}).Transform(in, &api.RestAPI{}) + require.NoError(t, err) + ops := res.Spec.Operations + + r := mcpOpResilience(ops, api.OperationMethodGET, constants.MCP_RESOURCE_PATH) + require.NotNil(t, r) + require.NotNil(t, r.Timeout) + assert.Equal(t, "30s", *r.Timeout) + require.NotNil(t, r.IdleTimeout) + assert.Equal(t, "120s", *r.IdleTimeout) + + // The PRM route (present for spec >= 2025-06-18) is still skipped. + assert.Nil(t, mcpOpResilience(ops, api.OperationMethodGET, constants.MCP_PRM_RESOURCE_PATH)) +} + +// A user timeout with no idle override still disables nothing extra: timeout is the user value, +// idle stays unset (global default). +func TestMCPTransform_Resilience_TimeoutOnly(t *testing.T) { + context := "/everything" + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "everything", + Version: "v1.0", + Context: &context, + SpecVersion: &latest, + Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:8080")}, + Resilience: &api.Resilience{Timeout: stringPtr("45s")}, + }, + } + + res, err := (&MCPTransformer{}).Transform(in, &api.RestAPI{}) + require.NoError(t, err) + + r := mcpOpResilience(res.Spec.Operations, api.OperationMethodPOST, constants.MCP_RESOURCE_PATH) + require.NotNil(t, r) + require.NotNil(t, r.Timeout) + assert.Equal(t, "45s", *r.Timeout) + assert.Nil(t, r.IdleTimeout) +} diff --git a/gateway/gateway-controller/pkg/utils/mcp_transformer.go b/gateway/gateway-controller/pkg/utils/mcp_transformer.go index b7d25bda9c..7e11a29ac2 100644 --- a/gateway/gateway-controller/pkg/utils/mcp_transformer.go +++ b/gateway/gateway-controller/pkg/utils/mcp_transformer.go @@ -96,6 +96,45 @@ func addMCPSpecificOperations(mcpConfig *api.MCPProxyConfiguration, optionsRequi return operations } +// isMCPForwardingOperation reports whether an MCP-synthesized operation forwards traffic to the +// upstream (where a route/idle timeout is meaningful). The non-forwarding routes are answered +// locally by the policy engine and must be skipped: +// - any OPTIONS route -> CORS preflight, answered locally by the cors policy +// - the PRM path -> OAuth protected-resource metadata, synthesized by the mcp-auth policy +func isMCPForwardingOperation(op api.Operation) bool { + if op.Method == api.OperationMethodOPTIONS { + return false + } + if op.Path == constants.MCP_PRM_RESOURCE_PATH { + return false + } + return true +} + +// applyMCPResilience attaches the API-level resilience block to the MCP traffic-forwarding routes +// (GET/POST/DELETE on the MCP resource path), skipping the local-response routes (OPTIONS, PRM). +// +// Because MCP transports are long-lived streams (SSE / Streamable HTTP), a finite route timeout +// would sever a healthy stream. So — unlike REST/LLM, which fall back to the gateway's global route +// timeout — the MCP route timeout defaults to disabled ("0s") when the user does not set one; the +// idle timeout remains the liveness guard. An explicit resilience.timeout always wins. +func applyMCPResilience(ops []api.Operation, userRes *api.Resilience) { + disabled := "0s" + for i := range ops { + if !isMCPForwardingOperation(ops[i]) { + continue + } + res := &api.Resilience{Timeout: &disabled} + if userRes != nil { + if userRes.Timeout != nil { + res.Timeout = userRes.Timeout + } + res.IdleTimeout = userRes.IdleTimeout + } + ops[i].Resilience = res + } +} + // Transform converts an MCP proxy configuration (input) to an API configuration (output) func (t *MCPTransformer) Transform(input any, output *api.RestAPI) (*api.RestAPI, error) { mcpConfig, ok := input.(*api.MCPProxyConfiguration) @@ -149,6 +188,9 @@ func (t *MCPTransformer) Transform(input any, output *api.RestAPI) (*api.RestAPI // Add MCP-specific operations, conditionally including OPTIONS when CORS is enabled apiData.Operations = addMCPSpecificOperations(mcpConfig, optionsRequired) + // Attach the API-level resilience (route/idle timeout) to the traffic-forwarding routes only. + applyMCPResilience(apiData.Operations, mcpConfig.Spec.Resilience) + // Set upstream auth if present upstream := mcpConfig.Spec.Upstream if upstream.Auth != nil { diff --git a/kubernetes/gateway-operator/api/v1alpha1/mcp_types.go b/kubernetes/gateway-operator/api/v1alpha1/mcp_types.go index 48367a217f..bdb4f24884 100644 --- a/kubernetes/gateway-operator/api/v1alpha1/mcp_types.go +++ b/kubernetes/gateway-operator/api/v1alpha1/mcp_types.go @@ -198,6 +198,12 @@ type MCPProxyConfigData struct { // Policies are MCP proxy-level policies. // +optional Policies []Policy `json:"policies,omitempty"` + + // Resilience configures API-level backend/route timeouts applied to the traffic-forwarding + // routes generated for this MCP proxy. Supported at the API level only. Because MCP transports + // are long-lived streams, the route timeout defaults to disabled ("0s") for MCP when unset. + // +optional + Resilience *Resilience `json:"resilience,omitempty"` } //+kubebuilder:object:root=true diff --git a/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go b/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go index 43837970b1..77b0dd5914 100644 --- a/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -1360,6 +1360,11 @@ func (in *MCPProxyConfigData) DeepCopyInto(out *MCPProxyConfigData) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Resilience != nil { + in, out := &in.Resilience, &out.Resilience + *out = new(Resilience) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPProxyConfigData. diff --git a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml index ef8c6b80b9..6b9c546506 100644 --- a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml +++ b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml @@ -122,6 +122,22 @@ spec: - name type: object type: array + resilience: + description: |- + Resilience configures API-level backend/route timeouts applied to the traffic-forwarding + routes generated for this MCP proxy. Supported at the API level only. Because MCP transports + are long-lived streams, the route timeout defaults to disabled ("0s") for MCP when unset. + properties: + idleTimeout: + description: IdleTimeout Per-route stream idle timeout. "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + timeout: + description: Timeout Maximum time for the entire route (request + to upstream response). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object resources: description: Resources lists optional MCP resources exposed by this proxy. diff --git a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml index ef8c6b80b9..6b9c546506 100644 --- a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml +++ b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml @@ -122,6 +122,22 @@ spec: - name type: object type: array + resilience: + description: |- + Resilience configures API-level backend/route timeouts applied to the traffic-forwarding + routes generated for this MCP proxy. Supported at the API level only. Because MCP transports + are long-lived streams, the route timeout defaults to disabled ("0s") for MCP when unset. + properties: + idleTimeout: + description: IdleTimeout Per-route stream idle timeout. "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + timeout: + description: Timeout Maximum time for the entire route (request + to upstream response). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object resources: description: Resources lists optional MCP resources exposed by this proxy. From d0fd568a9b6d050015e67f1d54caf584c538ef33 Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Tue, 7 Jul 2026 21:16:04 +0530 Subject: [PATCH 11/14] Refactor unit tests --- .../pkg/config/api_validator.go | 140 +++++++++ .../llm_mcp_upstream_ref_validation_test.go | 153 ---------- .../config/llm_validator_resilience_test.go | 132 --------- .../pkg/config/llm_validator_test.go | 187 ++++++++++++ .../config/mcp_validator_resilience_test.go | 82 ------ .../pkg/config/mcp_validator_test.go | 119 ++++++++ .../pkg/config/upstream_validation.go | 168 ----------- .../pkg/utils/llm_mcp_upstream_ref_test.go | 145 --------- .../utils/llm_provider_transformer_test.go | 276 ++++++++++++++++++ .../pkg/utils/llm_resilience_test.go | 236 --------------- .../pkg/utils/mcp_resilience_test.go | 132 --------- .../pkg/utils/mcp_transformer_test.go | 170 +++++++++++ .../pkg/xds/resilience_duration_test.go | 58 ---- .../pkg/xds/translator_test.go | 37 +++ 14 files changed, 929 insertions(+), 1106 deletions(-) delete mode 100644 gateway/gateway-controller/pkg/config/llm_mcp_upstream_ref_validation_test.go delete mode 100644 gateway/gateway-controller/pkg/config/llm_validator_resilience_test.go delete mode 100644 gateway/gateway-controller/pkg/config/mcp_validator_resilience_test.go delete mode 100644 gateway/gateway-controller/pkg/config/upstream_validation.go delete mode 100644 gateway/gateway-controller/pkg/utils/llm_mcp_upstream_ref_test.go delete mode 100644 gateway/gateway-controller/pkg/utils/llm_resilience_test.go delete mode 100644 gateway/gateway-controller/pkg/utils/mcp_resilience_test.go delete mode 100644 gateway/gateway-controller/pkg/xds/resilience_duration_test.go diff --git a/gateway/gateway-controller/pkg/config/api_validator.go b/gateway/gateway-controller/pkg/config/api_validator.go index 832b58bcc6..5f6b754abc 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -270,6 +270,146 @@ func (v *APIValidator) validateUpstreamDefinitions(definitions *[]api.UpstreamDe return validateUpstreamDefinitionsList("spec.upstreamDefinitions", definitions) } +// validateUpstreamDefinitionsList validates an upstreamDefinitions array. fieldPrefix is the path +// to the array (e.g. "spec.upstreamDefinitions"). It is shared by the RestApi, LLM Provider, and +// MCP validators so the three kinds validate upstream definitions identically. The connect timeout +// is validated against the shared CRD duration pattern (constants.ResilienceDurationRegex) so +// gateway-side validation matches CRD admission (compound/unitless/negative values are rejected). +func validateUpstreamDefinitionsList(fieldPrefix string, definitions *[]api.UpstreamDefinition) []ValidationError { + var errors []ValidationError + + if definitions == nil { + return errors + } + + // Track definition names to check for duplicates + namesSeen := make(map[string]bool) + + for i, def := range *definitions { + // Validate name + if def.Name == "" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].name", fieldPrefix, i), + Message: "Upstream definition name is required", + }) + continue + } + + // Check for duplicate names + if namesSeen[def.Name] { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].name", fieldPrefix, i), + Message: fmt.Sprintf("Duplicate upstream definition name '%s'", def.Name), + }) + continue + } + namesSeen[def.Name] = true + + // Validate upstreams array + if len(def.Upstreams) == 0 { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams", fieldPrefix, i), + Message: "At least one upstream target is required", + }) + } + + for j, upstream := range def.Upstreams { + if upstream.Url == "" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: "URL is required", + }) + continue + } + + parsedURL, err := url.Parse(upstream.Url) + if err != nil { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: fmt.Sprintf("Invalid URL format: %v", err), + }) + } else { + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: "URL must use http or https scheme", + }) + } + + if parsedURL.Host == "" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: "URL must include a host", + }) + } + + // upstreamDefinitions URLs must be host[:port] only — the base path is + // configured exclusively via upstreamDefinitions[].basePath. + if parsedURL.Path != "" && parsedURL.Path != "/" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: "URL must not include a path; set the base path in upstreamDefinitions[].basePath instead", + }) + } + + // A query string or fragment is not part of the upstream cluster + // (host[:port] only), so it would be silently dropped. Reject it. + if parsedURL.RawQuery != "" || parsedURL.ForceQuery { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: "URL must not include a query string; only host[:port] is used", + }) + } + + if parsedURL.Fragment != "" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), + Message: "URL must not include a fragment; only host[:port] is used", + }) + } + } + + // Validate weight if present + if upstream.Weight != nil { + if *upstream.Weight < 0 || *upstream.Weight > 100 { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].upstreams[%d].weight", fieldPrefix, i, j), + Message: "Weight must be between 0 and 100", + }) + } + } + } + + // Timeout validation is limited to connect timeout. Enforce the same single-unit duration + // pattern as the CRD/OpenAPI so gateway validation cannot diverge from CRD admission. + if def.Timeout != nil && def.Timeout.Connect != nil { + timeoutStr := strings.TrimSpace(*def.Timeout.Connect) + if timeoutStr != "" && !constants.ResilienceDurationRegex.MatchString(timeoutStr) { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].timeout.connect", fieldPrefix, i), + Message: "Invalid timeout format (expected a single-unit duration like '30s', '1m', '500ms')", + }) + } + } + } + + return errors +} + +// upstreamRefResolves reports whether ref names one of the provided upstream definitions. +func upstreamRefResolves(ref string, definitions *[]api.UpstreamDefinition) bool { + if definitions == nil { + return false + } + refName := strings.TrimSpace(ref) + for _, def := range *definitions { + if def.Name == refName { + return true + } + } + return false +} + // validateRestData validates the data section of the configuration for RestApi kind func (v *APIValidator) validateRestData(spec *api.APIConfigData) []ValidationError { var errors []ValidationError diff --git a/gateway/gateway-controller/pkg/config/llm_mcp_upstream_ref_validation_test.go b/gateway/gateway-controller/pkg/config/llm_mcp_upstream_ref_validation_test.go deleted file mode 100644 index f9774376ec..0000000000 --- a/gateway/gateway-controller/pkg/config/llm_mcp_upstream_ref_validation_test.go +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. 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 config - -import ( - "testing" - - "github.com/stretchr/testify/assert" - api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" -) - -// upstreamDef builds a valid upstream definition (one host-only target) with an optional connect -// timeout. connect == "" leaves the timeout unset. -func upstreamDef(name, connect string) api.UpstreamDefinition { - def := api.UpstreamDefinition{ - Name: name, - Upstreams: []struct { - Url string `json:"url" yaml:"url"` - Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` - }{ - {Url: "http://backend:8080"}, - }, - } - if connect != "" { - def.Timeout = &api.UpstreamTimeout{Connect: stringPtr(connect)} - } - return def -} - -func providerWithUpstream(defs *[]api.UpstreamDefinition, up api.LLMProviderConfigData_Upstream) api.LLMProviderConfiguration { - return api.LLMProviderConfiguration{ - ApiVersion: "gateway.api-platform.wso2.com/v1", - Kind: api.LLMProviderConfigurationKindLlmProvider, - Metadata: api.Metadata{Name: "openai"}, - Spec: api.LLMProviderConfigData{ - DisplayName: "my-provider", - Version: "v1.0", - Template: "openai", - UpstreamDefinitions: defs, - Upstream: up, - AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, - }, - } -} - -func mcpWithUpstream(defs *[]api.UpstreamDefinition, up api.MCPProxyConfigData_Upstream) api.MCPProxyConfiguration { - ctx := "/everything" - specVersion := constants.SPEC_VERSION_2025_JUNE - return api.MCPProxyConfiguration{ - ApiVersion: api.MCPProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, - Kind: api.MCPProxyConfigurationKindMcp, - Metadata: api.Metadata{Name: "everything"}, - Spec: api.MCPProxyConfigData{ - DisplayName: "Everything", - Version: "v1.0", - Context: &ctx, - SpecVersion: &specVersion, - UpstreamDefinitions: defs, - Upstream: up, - }, - } -} - -func TestValidateLLMProvider_UpstreamRef(t *testing.T) { - validator := NewLLMValidator() - - t.Run("valid ref resolves to a definition", func(t *testing.T) { - defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "6s")} - errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) - assert.Empty(t, errs) - }) - - t.Run("ref not found in definitions", func(t *testing.T) { - defs := &[]api.UpstreamDefinition{upstreamDef("other", "6s")} - errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("missing")})) - assertHasFieldError(t, errs, "spec.upstream.ref") - }) - - t.Run("both url and ref rejected", func(t *testing.T) { - defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "6s")} - errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{ - Url: stringPtr("https://api.openai.com"), - Ref: stringPtr("openai-backend"), - })) - assertHasFieldError(t, errs, "spec.upstream") - }) - - t.Run("malformed connect timeout rejected (must match CRD pattern)", func(t *testing.T) { - defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "1h30m")} - errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) - assertHasFieldError(t, errs, "spec.upstreamDefinitions[0].timeout.connect") - }) - - t.Run("valid fractional connect timeout accepted", func(t *testing.T) { - defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "500ms")} - errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) - assert.Empty(t, errs) - }) -} - -func TestValidateMCP_UpstreamRef(t *testing.T) { - validator := NewMCPValidator() - - t.Run("valid ref resolves to a definition", func(t *testing.T) { - defs := &[]api.UpstreamDefinition{upstreamDef("mcp-backend", "6s")} - errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{Ref: stringPtr("mcp-backend")})) - assert.Empty(t, errs) - }) - - t.Run("ref not found in definitions", func(t *testing.T) { - defs := &[]api.UpstreamDefinition{upstreamDef("other", "6s")} - errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{Ref: stringPtr("missing")})) - assertHasFieldError(t, errs, "spec.upstream.ref") - }) - - t.Run("both url and ref rejected", func(t *testing.T) { - defs := &[]api.UpstreamDefinition{upstreamDef("mcp-backend", "6s")} - errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{ - Url: stringPtr("http://backend:8080"), - Ref: stringPtr("mcp-backend"), - })) - assertHasFieldError(t, errs, "spec.upstream") - }) - - t.Run("malformed connect timeout rejected (must match CRD pattern)", func(t *testing.T) { - defs := &[]api.UpstreamDefinition{upstreamDef("mcp-backend", "1h30m")} - errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{Ref: stringPtr("mcp-backend")})) - assertHasFieldError(t, errs, "spec.upstreamDefinitions[0].timeout.connect") - }) - - t.Run("valid connect timeout accepted", func(t *testing.T) { - defs := &[]api.UpstreamDefinition{upstreamDef("mcp-backend", "5s")} - errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{Ref: stringPtr("mcp-backend")})) - assert.Empty(t, errs) - }) -} diff --git a/gateway/gateway-controller/pkg/config/llm_validator_resilience_test.go b/gateway/gateway-controller/pkg/config/llm_validator_resilience_test.go deleted file mode 100644 index db67f81e7f..0000000000 --- a/gateway/gateway-controller/pkg/config/llm_validator_resilience_test.go +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. 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 config - -import ( - "testing" - - "github.com/stretchr/testify/assert" - api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" -) - -func validProviderWithResilience(r *api.Resilience) api.LLMProviderConfiguration { - return api.LLMProviderConfiguration{ - ApiVersion: "gateway.api-platform.wso2.com/v1", - Kind: api.LLMProviderConfigurationKindLlmProvider, - Metadata: api.Metadata{Name: "openai"}, - Spec: api.LLMProviderConfigData{ - DisplayName: "my-provider", - Version: "v1.0", - Template: "openai", - Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.openai.com")}, - AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, - Resilience: r, - }, - } -} - -func validProxyWithResilience(r *api.Resilience) api.LLMProxyConfiguration { - return api.LLMProxyConfiguration{ - ApiVersion: api.LLMProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, - Kind: api.LLMProxyConfigurationKindLlmProxy, - Metadata: api.Metadata{Name: "openai-proxy"}, - Spec: api.LLMProxyConfigData{ - DisplayName: "my-proxy", - Version: "v1.0", - Provider: api.LLMProxyProvider{Id: "openai"}, - Resilience: r, - }, - } -} - -func TestValidateLLMProvider_Resilience(t *testing.T) { - validator := NewLLMValidator() - - t.Run("valid timeout and idleTimeout", func(t *testing.T) { - errs := validator.Validate(validProviderWithResilience(&api.Resilience{ - Timeout: stringPtr("30s"), - IdleTimeout: stringPtr("0s"), - })) - assert.Empty(t, errs) - }) - - t.Run("nil resilience is fine", func(t *testing.T) { - errs := validator.Validate(validProviderWithResilience(nil)) - assert.Empty(t, errs) - }) - - t.Run("malformed timeout is rejected", func(t *testing.T) { - errs := validator.Validate(validProviderWithResilience(&api.Resilience{Timeout: stringPtr("30")})) - assertHasFieldError(t, errs, "spec.resilience.timeout") - }) - - t.Run("compound timeout is rejected (must match CRD pattern)", func(t *testing.T) { - errs := validator.Validate(validProviderWithResilience(&api.Resilience{Timeout: stringPtr("1h30m")})) - assertHasFieldError(t, errs, "spec.resilience.timeout") - }) - - t.Run("0s is accepted (disables)", func(t *testing.T) { - errs := validator.Validate(validProviderWithResilience(&api.Resilience{Timeout: stringPtr("0s")})) - assert.Empty(t, errs) - }) - - t.Run("negative timeout is rejected", func(t *testing.T) { - errs := validator.Validate(validProviderWithResilience(&api.Resilience{Timeout: stringPtr("-5s")})) - assertHasFieldError(t, errs, "spec.resilience.timeout") - }) - - t.Run("malformed idleTimeout is rejected", func(t *testing.T) { - errs := validator.Validate(validProviderWithResilience(&api.Resilience{IdleTimeout: stringPtr("abc")})) - assertHasFieldError(t, errs, "spec.resilience.idleTimeout") - }) -} - -func TestValidateLLMProxy_Resilience(t *testing.T) { - validator := NewLLMValidator() - - t.Run("valid timeout", func(t *testing.T) { - errs := validator.Validate(validProxyWithResilience(&api.Resilience{Timeout: stringPtr("75s")})) - assert.Empty(t, errs) - }) - - t.Run("nil resilience is fine", func(t *testing.T) { - errs := validator.Validate(validProxyWithResilience(nil)) - assert.Empty(t, errs) - }) - - t.Run("malformed timeout is rejected", func(t *testing.T) { - errs := validator.Validate(validProxyWithResilience(&api.Resilience{Timeout: stringPtr("fast")})) - assertHasFieldError(t, errs, "spec.resilience.timeout") - }) - - t.Run("negative idleTimeout is rejected", func(t *testing.T) { - errs := validator.Validate(validProxyWithResilience(&api.Resilience{IdleTimeout: stringPtr("-1s")})) - assertHasFieldError(t, errs, "spec.resilience.idleTimeout") - }) -} - -func assertHasFieldError(t *testing.T, errs []ValidationError, field string) { - t.Helper() - for _, e := range errs { - if e.Field == field { - return - } - } - t.Fatalf("expected a validation error on field %q, got %+v", field, errs) -} diff --git a/gateway/gateway-controller/pkg/config/llm_validator_test.go b/gateway/gateway-controller/pkg/config/llm_validator_test.go index 21c05f4475..286361391c 100644 --- a/gateway/gateway-controller/pkg/config/llm_validator_test.go +++ b/gateway/gateway-controller/pkg/config/llm_validator_test.go @@ -1902,3 +1902,190 @@ func TestValidate_UnsupportedConfigType(t *testing.T) { assert.Equal(t, "config", errors[0].Field) assert.Contains(t, errors[0].Message, "Unsupported configuration type") } + +// assertHasFieldError fails unless errs contains a validation error on the given field. Shared by +// the resilience and upstream-ref tests across the LLM and MCP validators (same package). +func assertHasFieldError(t *testing.T, errs []ValidationError, field string) { + t.Helper() + for _, e := range errs { + if e.Field == field { + return + } + } + t.Fatalf("expected a validation error on field %q, got %+v", field, errs) +} + +// upstreamDef builds a valid upstream definition (one host-only target) with an optional connect +// timeout. connect == "" leaves the timeout unset. Shared by the LLM and MCP upstream-ref tests. +func upstreamDef(name, connect string) api.UpstreamDefinition { + def := api.UpstreamDefinition{ + Name: name, + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://backend:8080"}, + }, + } + if connect != "" { + def.Timeout = &api.UpstreamTimeout{Connect: stringPtr(connect)} + } + return def +} + +// ============================================================================ +// Resilience validation +// ============================================================================ + +func validProviderWithResilience(r *api.Resilience) api.LLMProviderConfiguration { + return api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: api.LLMProviderConfigurationKindLlmProvider, + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "my-provider", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.openai.com")}, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + Resilience: r, + }, + } +} + +func validProxyWithResilience(r *api.Resilience) api.LLMProxyConfiguration { + return api.LLMProxyConfiguration{ + ApiVersion: api.LLMProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.LLMProxyConfigurationKindLlmProxy, + Metadata: api.Metadata{Name: "openai-proxy"}, + Spec: api.LLMProxyConfigData{ + DisplayName: "my-proxy", + Version: "v1.0", + Provider: api.LLMProxyProvider{Id: "openai"}, + Resilience: r, + }, + } +} + +func TestValidateLLMProvider_Resilience(t *testing.T) { + validator := NewLLMValidator() + + t.Run("valid timeout and idleTimeout", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(&api.Resilience{ + Timeout: stringPtr("30s"), + IdleTimeout: stringPtr("0s"), + })) + assert.Empty(t, errs) + }) + + t.Run("nil resilience is fine", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(nil)) + assert.Empty(t, errs) + }) + + t.Run("malformed timeout is rejected", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(&api.Resilience{Timeout: stringPtr("30")})) + assertHasFieldError(t, errs, "spec.resilience.timeout") + }) + + t.Run("compound timeout is rejected (must match CRD pattern)", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(&api.Resilience{Timeout: stringPtr("1h30m")})) + assertHasFieldError(t, errs, "spec.resilience.timeout") + }) + + t.Run("0s is accepted (disables)", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(&api.Resilience{Timeout: stringPtr("0s")})) + assert.Empty(t, errs) + }) + + t.Run("negative timeout is rejected", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(&api.Resilience{Timeout: stringPtr("-5s")})) + assertHasFieldError(t, errs, "spec.resilience.timeout") + }) + + t.Run("malformed idleTimeout is rejected", func(t *testing.T) { + errs := validator.Validate(validProviderWithResilience(&api.Resilience{IdleTimeout: stringPtr("abc")})) + assertHasFieldError(t, errs, "spec.resilience.idleTimeout") + }) +} + +func TestValidateLLMProxy_Resilience(t *testing.T) { + validator := NewLLMValidator() + + t.Run("valid timeout", func(t *testing.T) { + errs := validator.Validate(validProxyWithResilience(&api.Resilience{Timeout: stringPtr("75s")})) + assert.Empty(t, errs) + }) + + t.Run("nil resilience is fine", func(t *testing.T) { + errs := validator.Validate(validProxyWithResilience(nil)) + assert.Empty(t, errs) + }) + + t.Run("malformed timeout is rejected", func(t *testing.T) { + errs := validator.Validate(validProxyWithResilience(&api.Resilience{Timeout: stringPtr("fast")})) + assertHasFieldError(t, errs, "spec.resilience.timeout") + }) + + t.Run("negative idleTimeout is rejected", func(t *testing.T) { + errs := validator.Validate(validProxyWithResilience(&api.Resilience{IdleTimeout: stringPtr("-1s")})) + assertHasFieldError(t, errs, "spec.resilience.idleTimeout") + }) +} + +// ============================================================================ +// Upstream ref validation +// ============================================================================ + +func providerWithUpstream(defs *[]api.UpstreamDefinition, up api.LLMProviderConfigData_Upstream) api.LLMProviderConfiguration { + return api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: api.LLMProviderConfigurationKindLlmProvider, + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "my-provider", + Version: "v1.0", + Template: "openai", + UpstreamDefinitions: defs, + Upstream: up, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } +} + +func TestValidateLLMProvider_UpstreamRef(t *testing.T) { + validator := NewLLMValidator() + + t.Run("valid ref resolves to a definition", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "6s")} + errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) + assert.Empty(t, errs) + }) + + t.Run("ref not found in definitions", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("other", "6s")} + errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("missing")})) + assertHasFieldError(t, errs, "spec.upstream.ref") + }) + + t.Run("both url and ref rejected", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "6s")} + errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{ + Url: stringPtr("https://api.openai.com"), + Ref: stringPtr("openai-backend"), + })) + assertHasFieldError(t, errs, "spec.upstream") + }) + + t.Run("malformed connect timeout rejected (must match CRD pattern)", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "1h30m")} + errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) + assertHasFieldError(t, errs, "spec.upstreamDefinitions[0].timeout.connect") + }) + + t.Run("valid fractional connect timeout accepted", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "500ms")} + errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) + assert.Empty(t, errs) + }) +} diff --git a/gateway/gateway-controller/pkg/config/mcp_validator_resilience_test.go b/gateway/gateway-controller/pkg/config/mcp_validator_resilience_test.go deleted file mode 100644 index 759e6f68f6..0000000000 --- a/gateway/gateway-controller/pkg/config/mcp_validator_resilience_test.go +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. 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 config - -import ( - "testing" - - "github.com/stretchr/testify/assert" - api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" -) - -func mcpWithResilience(r *api.Resilience) api.MCPProxyConfiguration { - ctx := "/everything" - specVersion := constants.SPEC_VERSION_2025_JUNE - return api.MCPProxyConfiguration{ - ApiVersion: api.MCPProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, - Kind: api.MCPProxyConfigurationKindMcp, - Metadata: api.Metadata{Name: "everything"}, - Spec: api.MCPProxyConfigData{ - DisplayName: "Everything", - Version: "v1.0", - Context: &ctx, - SpecVersion: &specVersion, - Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:3001")}, - Resilience: r, - }, - } -} - -func TestValidateMCP_Resilience(t *testing.T) { - validator := NewMCPValidator() - - t.Run("valid timeout and idleTimeout", func(t *testing.T) { - errs := validator.Validate(mcpWithResilience(&api.Resilience{ - Timeout: stringPtr("30s"), - IdleTimeout: stringPtr("0s"), - })) - assert.Empty(t, errs) - }) - - t.Run("nil resilience is fine (defaults applied downstream)", func(t *testing.T) { - errs := validator.Validate(mcpWithResilience(nil)) - assert.Empty(t, errs) - }) - - t.Run("0s is accepted (explicit disable)", func(t *testing.T) { - errs := validator.Validate(mcpWithResilience(&api.Resilience{Timeout: stringPtr("0s")})) - assert.Empty(t, errs) - }) - - t.Run("malformed timeout is rejected", func(t *testing.T) { - errs := validator.Validate(mcpWithResilience(&api.Resilience{Timeout: stringPtr("30")})) - assertHasFieldError(t, errs, "spec.resilience.timeout") - }) - - t.Run("compound timeout is rejected (must match CRD pattern)", func(t *testing.T) { - errs := validator.Validate(mcpWithResilience(&api.Resilience{Timeout: stringPtr("1h30m")})) - assertHasFieldError(t, errs, "spec.resilience.timeout") - }) - - t.Run("negative idleTimeout is rejected", func(t *testing.T) { - errs := validator.Validate(mcpWithResilience(&api.Resilience{IdleTimeout: stringPtr("-5s")})) - assertHasFieldError(t, errs, "spec.resilience.idleTimeout") - }) -} diff --git a/gateway/gateway-controller/pkg/config/mcp_validator_test.go b/gateway/gateway-controller/pkg/config/mcp_validator_test.go index 107d14ee78..446f455118 100644 --- a/gateway/gateway-controller/pkg/config/mcp_validator_test.go +++ b/gateway/gateway-controller/pkg/config/mcp_validator_test.go @@ -22,6 +22,7 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" ) @@ -656,3 +657,121 @@ func TestMCPValidator_ValidateUpstreamAuth(t *testing.T) { }) } } + +// ============================================================================ +// Upstream ref validation +// ============================================================================ + +func mcpWithUpstream(defs *[]api.UpstreamDefinition, up api.MCPProxyConfigData_Upstream) api.MCPProxyConfiguration { + ctx := "/everything" + specVersion := constants.SPEC_VERSION_2025_JUNE + return api.MCPProxyConfiguration{ + ApiVersion: api.MCPProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.MCPProxyConfigurationKindMcp, + Metadata: api.Metadata{Name: "everything"}, + Spec: api.MCPProxyConfigData{ + DisplayName: "Everything", + Version: "v1.0", + Context: &ctx, + SpecVersion: &specVersion, + UpstreamDefinitions: defs, + Upstream: up, + }, + } +} + +func TestValidateMCP_UpstreamRef(t *testing.T) { + validator := NewMCPValidator() + + t.Run("valid ref resolves to a definition", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("mcp-backend", "6s")} + errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{Ref: stringPtr("mcp-backend")})) + assert.Empty(t, errs) + }) + + t.Run("ref not found in definitions", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("other", "6s")} + errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{Ref: stringPtr("missing")})) + assertHasFieldError(t, errs, "spec.upstream.ref") + }) + + t.Run("both url and ref rejected", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("mcp-backend", "6s")} + errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{ + Url: stringPtr("http://backend:8080"), + Ref: stringPtr("mcp-backend"), + })) + assertHasFieldError(t, errs, "spec.upstream") + }) + + t.Run("malformed connect timeout rejected (must match CRD pattern)", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("mcp-backend", "1h30m")} + errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{Ref: stringPtr("mcp-backend")})) + assertHasFieldError(t, errs, "spec.upstreamDefinitions[0].timeout.connect") + }) + + t.Run("valid connect timeout accepted", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("mcp-backend", "5s")} + errs := validator.Validate(mcpWithUpstream(defs, api.MCPProxyConfigData_Upstream{Ref: stringPtr("mcp-backend")})) + assert.Empty(t, errs) + }) +} + +// ============================================================================ +// Resilience validation +// ============================================================================ + +func mcpWithResilience(r *api.Resilience) api.MCPProxyConfiguration { + ctx := "/everything" + specVersion := constants.SPEC_VERSION_2025_JUNE + return api.MCPProxyConfiguration{ + ApiVersion: api.MCPProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.MCPProxyConfigurationKindMcp, + Metadata: api.Metadata{Name: "everything"}, + Spec: api.MCPProxyConfigData{ + DisplayName: "Everything", + Version: "v1.0", + Context: &ctx, + SpecVersion: &specVersion, + Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:3001")}, + Resilience: r, + }, + } +} + +func TestValidateMCP_Resilience(t *testing.T) { + validator := NewMCPValidator() + + t.Run("valid timeout and idleTimeout", func(t *testing.T) { + errs := validator.Validate(mcpWithResilience(&api.Resilience{ + Timeout: stringPtr("30s"), + IdleTimeout: stringPtr("0s"), + })) + assert.Empty(t, errs) + }) + + t.Run("nil resilience is fine (defaults applied downstream)", func(t *testing.T) { + errs := validator.Validate(mcpWithResilience(nil)) + assert.Empty(t, errs) + }) + + t.Run("0s is accepted (explicit disable)", func(t *testing.T) { + errs := validator.Validate(mcpWithResilience(&api.Resilience{Timeout: stringPtr("0s")})) + assert.Empty(t, errs) + }) + + t.Run("malformed timeout is rejected", func(t *testing.T) { + errs := validator.Validate(mcpWithResilience(&api.Resilience{Timeout: stringPtr("30")})) + assertHasFieldError(t, errs, "spec.resilience.timeout") + }) + + t.Run("compound timeout is rejected (must match CRD pattern)", func(t *testing.T) { + errs := validator.Validate(mcpWithResilience(&api.Resilience{Timeout: stringPtr("1h30m")})) + assertHasFieldError(t, errs, "spec.resilience.timeout") + }) + + t.Run("negative idleTimeout is rejected", func(t *testing.T) { + errs := validator.Validate(mcpWithResilience(&api.Resilience{IdleTimeout: stringPtr("-5s")})) + assertHasFieldError(t, errs, "spec.resilience.idleTimeout") + }) +} diff --git a/gateway/gateway-controller/pkg/config/upstream_validation.go b/gateway/gateway-controller/pkg/config/upstream_validation.go deleted file mode 100644 index 73267338c9..0000000000 --- a/gateway/gateway-controller/pkg/config/upstream_validation.go +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. 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 config - -import ( - "fmt" - "net/url" - "strings" - - api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" -) - -// validateUpstreamDefinitionsList validates an upstreamDefinitions array. fieldPrefix is the path -// to the array (e.g. "spec.upstreamDefinitions"). It is shared by the RestApi, LLM Provider, and -// MCP validators so the three kinds validate upstream definitions identically. The connect timeout -// is validated against the shared CRD duration pattern (constants.ResilienceDurationRegex) so -// gateway-side validation matches CRD admission (compound/unitless/negative values are rejected). -func validateUpstreamDefinitionsList(fieldPrefix string, definitions *[]api.UpstreamDefinition) []ValidationError { - var errors []ValidationError - - if definitions == nil { - return errors - } - - // Track definition names to check for duplicates - namesSeen := make(map[string]bool) - - for i, def := range *definitions { - // Validate name - if def.Name == "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s[%d].name", fieldPrefix, i), - Message: "Upstream definition name is required", - }) - continue - } - - // Check for duplicate names - if namesSeen[def.Name] { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s[%d].name", fieldPrefix, i), - Message: fmt.Sprintf("Duplicate upstream definition name '%s'", def.Name), - }) - continue - } - namesSeen[def.Name] = true - - // Validate upstreams array - if len(def.Upstreams) == 0 { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s[%d].upstreams", fieldPrefix, i), - Message: "At least one upstream target is required", - }) - } - - for j, upstream := range def.Upstreams { - if upstream.Url == "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), - Message: "URL is required", - }) - continue - } - - parsedURL, err := url.Parse(upstream.Url) - if err != nil { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), - Message: fmt.Sprintf("Invalid URL format: %v", err), - }) - } else { - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), - Message: "URL must use http or https scheme", - }) - } - - if parsedURL.Host == "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), - Message: "URL must include a host", - }) - } - - // upstreamDefinitions URLs must be host[:port] only — the base path is - // configured exclusively via upstreamDefinitions[].basePath. - if parsedURL.Path != "" && parsedURL.Path != "/" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), - Message: "URL must not include a path; set the base path in upstreamDefinitions[].basePath instead", - }) - } - - // A query string or fragment is not part of the upstream cluster - // (host[:port] only), so it would be silently dropped. Reject it. - if parsedURL.RawQuery != "" || parsedURL.ForceQuery { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), - Message: "URL must not include a query string; only host[:port] is used", - }) - } - - if parsedURL.Fragment != "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s[%d].upstreams[%d].url", fieldPrefix, i, j), - Message: "URL must not include a fragment; only host[:port] is used", - }) - } - } - - // Validate weight if present - if upstream.Weight != nil { - if *upstream.Weight < 0 || *upstream.Weight > 100 { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s[%d].upstreams[%d].weight", fieldPrefix, i, j), - Message: "Weight must be between 0 and 100", - }) - } - } - } - - // Timeout validation is limited to connect timeout. Enforce the same single-unit duration - // pattern as the CRD/OpenAPI so gateway validation cannot diverge from CRD admission. - if def.Timeout != nil && def.Timeout.Connect != nil { - timeoutStr := strings.TrimSpace(*def.Timeout.Connect) - if timeoutStr != "" && !constants.ResilienceDurationRegex.MatchString(timeoutStr) { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s[%d].timeout.connect", fieldPrefix, i), - Message: "Invalid timeout format (expected a single-unit duration like '30s', '1m', '500ms')", - }) - } - } - } - - return errors -} - -// upstreamRefResolves reports whether ref names one of the provided upstream definitions. -func upstreamRefResolves(ref string, definitions *[]api.UpstreamDefinition) bool { - if definitions == nil { - return false - } - refName := strings.TrimSpace(ref) - for _, def := range *definitions { - if def.Name == refName { - return true - } - } - return false -} diff --git a/gateway/gateway-controller/pkg/utils/llm_mcp_upstream_ref_test.go b/gateway/gateway-controller/pkg/utils/llm_mcp_upstream_ref_test.go deleted file mode 100644 index 0d9a2df2fe..0000000000 --- a/gateway/gateway-controller/pkg/utils/llm_mcp_upstream_ref_test.go +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. 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 utils - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" -) - -// These tests verify that the LLM Provider and MCP converters map an upstream `ref` onto the -// derived RestAPI (instead of a direct url) and thread the upstreamDefinitions through, so the -// per-upstream connect timeout resolves the same way it does for RestApi. - -func TestTransform_Provider_UpstreamRef_ThreadsDefinitions(t *testing.T) { - transformer, _ := setupTestTransformer(t) - - defs := &[]api.UpstreamDefinition{{ - Name: "openai-backend", - Timeout: &api.UpstreamTimeout{Connect: stringPtr("6s")}, - }} - provider := &api.LLMProviderConfiguration{ - ApiVersion: "gateway.api-platform.wso2.com/v1", - Kind: "LlmProvider", - Metadata: api.Metadata{Name: "openai-provider"}, - Spec: api.LLMProviderConfigData{ - DisplayName: "ref-provider", - Version: "v1.0", - Template: "openai", - UpstreamDefinitions: defs, - Upstream: api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")}, - AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, - }, - } - - res, err := transformer.Transform(provider, &api.RestAPI{}) - require.NoError(t, err) - - require.NotNil(t, res.Spec.Upstream.Main.Ref, "ref should be mapped onto the derived upstream") - assert.Equal(t, "openai-backend", *res.Spec.Upstream.Main.Ref) - assert.Nil(t, res.Spec.Upstream.Main.Url, "ref-based upstream must not also set url") - - require.NotNil(t, res.Spec.UpstreamDefinitions, "upstreamDefinitions must be threaded through") - require.Len(t, *res.Spec.UpstreamDefinitions, 1) - require.NotNil(t, (*res.Spec.UpstreamDefinitions)[0].Timeout) - assert.Equal(t, "6s", *(*res.Spec.UpstreamDefinitions)[0].Timeout.Connect) -} - -func TestTransform_Provider_UpstreamUrl_Unchanged(t *testing.T) { - transformer, _ := setupTestTransformer(t) - - provider := &api.LLMProviderConfiguration{ - ApiVersion: "gateway.api-platform.wso2.com/v1", - Kind: "LlmProvider", - Metadata: api.Metadata{Name: "openai-provider"}, - Spec: api.LLMProviderConfigData{ - DisplayName: "url-provider", - Version: "v1.0", - Template: "openai", - Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.openai.com")}, - AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, - }, - } - - res, err := transformer.Transform(provider, &api.RestAPI{}) - require.NoError(t, err) - - require.NotNil(t, res.Spec.Upstream.Main.Url) - assert.Equal(t, "https://api.openai.com", *res.Spec.Upstream.Main.Url) - assert.Nil(t, res.Spec.Upstream.Main.Ref) - assert.Nil(t, res.Spec.UpstreamDefinitions) -} - -func TestMCPTransform_UpstreamRef_ThreadsDefinitions(t *testing.T) { - context := "/everything" - latest := LATEST_SUPPORTED_MCP_SPEC_VERSION - defs := []api.UpstreamDefinition{{ - Name: "mcp-backend", - Timeout: &api.UpstreamTimeout{Connect: stringPtr("6s")}, - }} - in := &api.MCPProxyConfiguration{ - Spec: api.MCPProxyConfigData{ - DisplayName: "everything", - Version: "v1.0", - Context: &context, - SpecVersion: &latest, - UpstreamDefinitions: &defs, - Upstream: api.MCPProxyConfigData_Upstream{Ref: stringPtr("mcp-backend")}, - }, - } - - var out api.RestAPI - res, err := (&MCPTransformer{}).Transform(in, &out) - require.NoError(t, err) - - require.NotNil(t, res.Spec.Upstream.Main.Ref) - assert.Equal(t, "mcp-backend", *res.Spec.Upstream.Main.Ref) - assert.Nil(t, res.Spec.Upstream.Main.Url) - - require.NotNil(t, res.Spec.UpstreamDefinitions) - require.Len(t, *res.Spec.UpstreamDefinitions, 1) - require.NotNil(t, (*res.Spec.UpstreamDefinitions)[0].Timeout) - assert.Equal(t, "6s", *(*res.Spec.UpstreamDefinitions)[0].Timeout.Connect) -} - -func TestMCPTransform_UpstreamUrl_Unchanged(t *testing.T) { - context := "/everything" - latest := LATEST_SUPPORTED_MCP_SPEC_VERSION - in := &api.MCPProxyConfiguration{ - Spec: api.MCPProxyConfigData{ - DisplayName: "everything", - Version: "v1.0", - Context: &context, - SpecVersion: &latest, - Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:8080")}, - }, - } - - var out api.RestAPI - res, err := (&MCPTransformer{}).Transform(in, &out) - require.NoError(t, err) - - require.NotNil(t, res.Spec.Upstream.Main.Url) - assert.Equal(t, "http://backend:8080", *res.Spec.Upstream.Main.Url) - assert.Nil(t, res.Spec.Upstream.Main.Ref) - assert.Nil(t, res.Spec.UpstreamDefinitions) -} diff --git a/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go b/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go index f5ed2e5706..6bbd5037f6 100644 --- a/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go +++ b/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go @@ -19,7 +19,10 @@ package utils import ( + "io" + "log/slog" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -8220,3 +8223,276 @@ func findOperation(ops []api.Operation, path string, method string) *api.Operati } return nil } + +// ============================================================================ +// Resilience route-mapping tests +// ============================================================================ + +// These tests pin the LLM resilience route-mapping rule: API-level resilience is attached to every +// traffic-forwarding route and never to the access-control deny routes. + +// In allow_all mode, the catch-all (and any policy-derived routes) carry the resilience block while +// the exception/deny routes (which only return a 404) are left untouched. +func TestTransformProvider_AllowAll_ResilienceSkipsDenyRoutes(t *testing.T) { + transformer, _ := setupTestTransformer(t) + + exceptions := []api.RouteException{ + {Path: "/admin", Methods: []api.RouteExceptionMethods{api.RouteExceptionMethodsGET, api.RouteExceptionMethodsPOST}}, + } + timeout := "30s" + provider := &api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: "LlmProvider", + Metadata: api.Metadata{Name: "openai-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "test", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.example.com")}, + AccessControl: api.LLMAccessControl{ + Mode: api.AllowAll, + Exceptions: &exceptions, + }, + Resilience: &api.Resilience{Timeout: &timeout}, + }, + } + + result, err := transformer.Transform(provider, &api.RestAPI{}) + require.NoError(t, err) + + sawCatchAll := false + sawDeny := false + for _, op := range result.Spec.Operations { + if op.Path == "/admin" { // the deny/exception routes + sawDeny = true + assert.Nil(t, op.Resilience, "deny route %s %s must not carry resilience", op.Method, op.Path) + continue + } + // catch-all (traffic-forwarding) routes + sawCatchAll = true + require.NotNil(t, op.Resilience, "traffic route %s %s should carry resilience", op.Method, op.Path) + require.NotNil(t, op.Resilience.Timeout) + assert.Equal(t, "30s", *op.Resilience.Timeout) + } + assert.True(t, sawCatchAll, "expected at least one traffic-forwarding route") + assert.True(t, sawDeny, "expected at least one deny route") +} + +// In deny_all mode every created route is an allow-listed forwarding route, so all of them carry the +// resilience block. +func TestTransformProvider_DenyAll_ResilienceOnAllRoutes(t *testing.T) { + transformer, _ := setupTestTransformer(t) + + exceptions := []api.RouteException{ + {Path: "/chat/completions", Methods: []api.RouteExceptionMethods{api.RouteExceptionMethodsPOST}}, + {Path: "/models", Methods: []api.RouteExceptionMethods{api.RouteExceptionMethodsGET}}, + } + timeout := "45s" + idle := "0s" + provider := &api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: "LlmProvider", + Metadata: api.Metadata{Name: "openai-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "test", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.example.com")}, + AccessControl: api.LLMAccessControl{ + Mode: api.DenyAll, + Exceptions: &exceptions, + }, + Resilience: &api.Resilience{Timeout: &timeout, IdleTimeout: &idle}, + }, + } + + result, err := transformer.Transform(provider, &api.RestAPI{}) + require.NoError(t, err) + require.NotEmpty(t, result.Spec.Operations) + + for _, op := range result.Spec.Operations { + require.NotNil(t, op.Resilience, "route %s %s should carry resilience", op.Method, op.Path) + require.NotNil(t, op.Resilience.Timeout) + assert.Equal(t, "45s", *op.Resilience.Timeout) + require.NotNil(t, op.Resilience.IdleTimeout) + assert.Equal(t, "0s", *op.Resilience.IdleTimeout) + } +} + +// With no resilience block on the source provider, no operation gets a resilience block (unchanged +// behavior — routes fall back to the gateway's global default timeout). +func TestTransformProvider_NoResilience_NotAttached(t *testing.T) { + transformer, _ := setupTestTransformer(t) + + provider := &api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: "LlmProvider", + Metadata: api.Metadata{Name: "openai-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "test", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.example.com")}, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + + result, err := transformer.Transform(provider, &api.RestAPI{}) + require.NoError(t, err) + require.NotEmpty(t, result.Spec.Operations) + + for _, op := range result.Spec.Operations { + assert.Nil(t, op.Resilience, "route %s %s should not carry resilience", op.Method, op.Path) + } +} + +// A proxy is always allow-all with no access control, so every generated route carries the +// resilience block. +func TestTransformProxy_ResilienceOnAllRoutes(t *testing.T) { + store := storage.NewConfigStore() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + db := newTestSQLiteStorage(t, logger) + + template := &models.StoredLLMProviderTemplate{ + UUID: "0000-db-template-id-0000-000000000001", + Configuration: api.LLMProviderTemplate{ + ApiVersion: api.LLMProviderTemplateApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.LLMProviderTemplateKindLlmProviderTemplate, + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{DisplayName: "openai"}, + }, + } + require.NoError(t, db.SaveLLMProviderTemplate(template)) + + now := time.Now() + provider := &models.StoredConfig{ + UUID: "0000-db-provider-id-0000-000000000000", + Kind: string(api.LLMProviderConfigurationKindLlmProvider), + Handle: "db-provider", + DisplayName: "db-provider", + Version: "v1.0", + Configuration: api.RestAPI{ + ApiVersion: api.RestAPIApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "db-provider"}, + Spec: api.APIConfigData{ + DisplayName: "db-provider", + Version: "v1.0", + Context: "/db-provider", + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{Main: api.Upstream{Url: stringPtr("https://api.openai.com")}}, + }, + }, + SourceConfiguration: api.LLMProviderConfiguration{ + ApiVersion: api.LLMProviderConfigurationApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.LLMProviderConfigurationKindLlmProvider, + Metadata: api.Metadata{Name: "db-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "db-provider", + Version: "v1.0", + Context: stringPtr("/db-provider"), + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.openai.com")}, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + }, + DesiredState: models.StateDeployed, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, db.SaveConfig(provider)) + + routerConfig := &config.RouterConfig{ListenerPort: 8080} + transformer := NewLLMProviderTransformer(store, db, routerConfig, newTestPolicyVersionResolver()) + + timeout := "75s" + proxy := &api.LLMProxyConfiguration{ + ApiVersion: api.LLMProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.LLMProxyConfigurationKindLlmProxy, + Metadata: api.Metadata{Name: "db-proxy"}, + Spec: api.LLMProxyConfigData{ + DisplayName: "db-proxy", + Version: "v1.0", + Provider: api.LLMProxyProvider{Id: "db-provider"}, + Resilience: &api.Resilience{Timeout: &timeout}, + }, + } + + result, err := transformer.Transform(proxy, &api.RestAPI{}) + require.NoError(t, err) + require.NotEmpty(t, result.Spec.Operations) + + for _, op := range result.Spec.Operations { + require.NotNil(t, op.Resilience, "proxy route %s %s should carry resilience", op.Method, op.Path) + require.NotNil(t, op.Resilience.Timeout) + assert.Equal(t, "75s", *op.Resilience.Timeout) + } +} + +// ============================================================================ +// Upstream ref / connect-timeout threading tests +// ============================================================================ + +// The provider converter maps an upstream `ref` onto the derived RestAPI (instead of a direct url) +// and threads the upstreamDefinitions through, so the per-upstream connect timeout resolves the same +// way it does for RestApi. +func TestTransform_Provider_UpstreamRef_ThreadsDefinitions(t *testing.T) { + transformer, _ := setupTestTransformer(t) + + defs := &[]api.UpstreamDefinition{{ + Name: "openai-backend", + Timeout: &api.UpstreamTimeout{Connect: stringPtr("6s")}, + }} + provider := &api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: "LlmProvider", + Metadata: api.Metadata{Name: "openai-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "ref-provider", + Version: "v1.0", + Template: "openai", + UpstreamDefinitions: defs, + Upstream: api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")}, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + + res, err := transformer.Transform(provider, &api.RestAPI{}) + require.NoError(t, err) + + require.NotNil(t, res.Spec.Upstream.Main.Ref, "ref should be mapped onto the derived upstream") + assert.Equal(t, "openai-backend", *res.Spec.Upstream.Main.Ref) + assert.Nil(t, res.Spec.Upstream.Main.Url, "ref-based upstream must not also set url") + + require.NotNil(t, res.Spec.UpstreamDefinitions, "upstreamDefinitions must be threaded through") + require.Len(t, *res.Spec.UpstreamDefinitions, 1) + require.NotNil(t, (*res.Spec.UpstreamDefinitions)[0].Timeout) + assert.Equal(t, "6s", *(*res.Spec.UpstreamDefinitions)[0].Timeout.Connect) +} + +func TestTransform_Provider_UpstreamUrl_Unchanged(t *testing.T) { + transformer, _ := setupTestTransformer(t) + + provider := &api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: "LlmProvider", + Metadata: api.Metadata{Name: "openai-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "url-provider", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.openai.com")}, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + + res, err := transformer.Transform(provider, &api.RestAPI{}) + require.NoError(t, err) + + require.NotNil(t, res.Spec.Upstream.Main.Url) + assert.Equal(t, "https://api.openai.com", *res.Spec.Upstream.Main.Url) + assert.Nil(t, res.Spec.Upstream.Main.Ref) + assert.Nil(t, res.Spec.UpstreamDefinitions) +} diff --git a/gateway/gateway-controller/pkg/utils/llm_resilience_test.go b/gateway/gateway-controller/pkg/utils/llm_resilience_test.go deleted file mode 100644 index 38cb518f17..0000000000 --- a/gateway/gateway-controller/pkg/utils/llm_resilience_test.go +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. 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 utils - -import ( - "io" - "log/slog" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" -) - -// These tests pin the LLM resilience route-mapping rule: API-level resilience is attached to every -// traffic-forwarding route and never to the access-control deny routes. - -// In allow_all mode, the catch-all (and any policy-derived routes) carry the resilience block while -// the exception/deny routes (which only return a 404) are left untouched. -func TestTransformProvider_AllowAll_ResilienceSkipsDenyRoutes(t *testing.T) { - transformer, _ := setupTestTransformer(t) - - exceptions := []api.RouteException{ - {Path: "/admin", Methods: []api.RouteExceptionMethods{api.RouteExceptionMethodsGET, api.RouteExceptionMethodsPOST}}, - } - timeout := "30s" - provider := &api.LLMProviderConfiguration{ - ApiVersion: "gateway.api-platform.wso2.com/v1", - Kind: "LlmProvider", - Metadata: api.Metadata{Name: "openai-provider"}, - Spec: api.LLMProviderConfigData{ - DisplayName: "test", - Version: "v1.0", - Template: "openai", - Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.example.com")}, - AccessControl: api.LLMAccessControl{ - Mode: api.AllowAll, - Exceptions: &exceptions, - }, - Resilience: &api.Resilience{Timeout: &timeout}, - }, - } - - result, err := transformer.Transform(provider, &api.RestAPI{}) - require.NoError(t, err) - - sawCatchAll := false - sawDeny := false - for _, op := range result.Spec.Operations { - if op.Path == "/admin" { // the deny/exception routes - sawDeny = true - assert.Nil(t, op.Resilience, "deny route %s %s must not carry resilience", op.Method, op.Path) - continue - } - // catch-all (traffic-forwarding) routes - sawCatchAll = true - require.NotNil(t, op.Resilience, "traffic route %s %s should carry resilience", op.Method, op.Path) - require.NotNil(t, op.Resilience.Timeout) - assert.Equal(t, "30s", *op.Resilience.Timeout) - } - assert.True(t, sawCatchAll, "expected at least one traffic-forwarding route") - assert.True(t, sawDeny, "expected at least one deny route") -} - -// In deny_all mode every created route is an allow-listed forwarding route, so all of them carry the -// resilience block. -func TestTransformProvider_DenyAll_ResilienceOnAllRoutes(t *testing.T) { - transformer, _ := setupTestTransformer(t) - - exceptions := []api.RouteException{ - {Path: "/chat/completions", Methods: []api.RouteExceptionMethods{api.RouteExceptionMethodsPOST}}, - {Path: "/models", Methods: []api.RouteExceptionMethods{api.RouteExceptionMethodsGET}}, - } - timeout := "45s" - idle := "0s" - provider := &api.LLMProviderConfiguration{ - ApiVersion: "gateway.api-platform.wso2.com/v1", - Kind: "LlmProvider", - Metadata: api.Metadata{Name: "openai-provider"}, - Spec: api.LLMProviderConfigData{ - DisplayName: "test", - Version: "v1.0", - Template: "openai", - Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.example.com")}, - AccessControl: api.LLMAccessControl{ - Mode: api.DenyAll, - Exceptions: &exceptions, - }, - Resilience: &api.Resilience{Timeout: &timeout, IdleTimeout: &idle}, - }, - } - - result, err := transformer.Transform(provider, &api.RestAPI{}) - require.NoError(t, err) - require.NotEmpty(t, result.Spec.Operations) - - for _, op := range result.Spec.Operations { - require.NotNil(t, op.Resilience, "route %s %s should carry resilience", op.Method, op.Path) - require.NotNil(t, op.Resilience.Timeout) - assert.Equal(t, "45s", *op.Resilience.Timeout) - require.NotNil(t, op.Resilience.IdleTimeout) - assert.Equal(t, "0s", *op.Resilience.IdleTimeout) - } -} - -// With no resilience block on the source provider, no operation gets a resilience block (unchanged -// behavior — routes fall back to the gateway's global default timeout). -func TestTransformProvider_NoResilience_NotAttached(t *testing.T) { - transformer, _ := setupTestTransformer(t) - - provider := &api.LLMProviderConfiguration{ - ApiVersion: "gateway.api-platform.wso2.com/v1", - Kind: "LlmProvider", - Metadata: api.Metadata{Name: "openai-provider"}, - Spec: api.LLMProviderConfigData{ - DisplayName: "test", - Version: "v1.0", - Template: "openai", - Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.example.com")}, - AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, - }, - } - - result, err := transformer.Transform(provider, &api.RestAPI{}) - require.NoError(t, err) - require.NotEmpty(t, result.Spec.Operations) - - for _, op := range result.Spec.Operations { - assert.Nil(t, op.Resilience, "route %s %s should not carry resilience", op.Method, op.Path) - } -} - -// A proxy is always allow-all with no access control, so every generated route carries the -// resilience block. -func TestTransformProxy_ResilienceOnAllRoutes(t *testing.T) { - store := storage.NewConfigStore() - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - db := newTestSQLiteStorage(t, logger) - - template := &models.StoredLLMProviderTemplate{ - UUID: "0000-db-template-id-0000-000000000001", - Configuration: api.LLMProviderTemplate{ - ApiVersion: api.LLMProviderTemplateApiVersionGatewayApiPlatformWso2Comv1, - Kind: api.LLMProviderTemplateKindLlmProviderTemplate, - Metadata: api.Metadata{Name: "openai"}, - Spec: api.LLMProviderTemplateData{DisplayName: "openai"}, - }, - } - require.NoError(t, db.SaveLLMProviderTemplate(template)) - - now := time.Now() - provider := &models.StoredConfig{ - UUID: "0000-db-provider-id-0000-000000000000", - Kind: string(api.LLMProviderConfigurationKindLlmProvider), - Handle: "db-provider", - DisplayName: "db-provider", - Version: "v1.0", - Configuration: api.RestAPI{ - ApiVersion: api.RestAPIApiVersionGatewayApiPlatformWso2Comv1, - Kind: api.RestAPIKindRestApi, - Metadata: api.Metadata{Name: "db-provider"}, - Spec: api.APIConfigData{ - DisplayName: "db-provider", - Version: "v1.0", - Context: "/db-provider", - Upstream: struct { - Main api.Upstream `json:"main" yaml:"main"` - Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` - }{Main: api.Upstream{Url: stringPtr("https://api.openai.com")}}, - }, - }, - SourceConfiguration: api.LLMProviderConfiguration{ - ApiVersion: api.LLMProviderConfigurationApiVersionGatewayApiPlatformWso2Comv1, - Kind: api.LLMProviderConfigurationKindLlmProvider, - Metadata: api.Metadata{Name: "db-provider"}, - Spec: api.LLMProviderConfigData{ - DisplayName: "db-provider", - Version: "v1.0", - Context: stringPtr("/db-provider"), - Template: "openai", - Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://api.openai.com")}, - AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, - }, - }, - DesiredState: models.StateDeployed, - CreatedAt: now, - UpdatedAt: now, - } - require.NoError(t, db.SaveConfig(provider)) - - routerConfig := &config.RouterConfig{ListenerPort: 8080} - transformer := NewLLMProviderTransformer(store, db, routerConfig, newTestPolicyVersionResolver()) - - timeout := "75s" - proxy := &api.LLMProxyConfiguration{ - ApiVersion: api.LLMProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, - Kind: api.LLMProxyConfigurationKindLlmProxy, - Metadata: api.Metadata{Name: "db-proxy"}, - Spec: api.LLMProxyConfigData{ - DisplayName: "db-proxy", - Version: "v1.0", - Provider: api.LLMProxyProvider{Id: "db-provider"}, - Resilience: &api.Resilience{Timeout: &timeout}, - }, - } - - result, err := transformer.Transform(proxy, &api.RestAPI{}) - require.NoError(t, err) - require.NotEmpty(t, result.Spec.Operations) - - for _, op := range result.Spec.Operations { - require.NotNil(t, op.Resilience, "proxy route %s %s should carry resilience", op.Method, op.Path) - require.NotNil(t, op.Resilience.Timeout) - assert.Equal(t, "75s", *op.Resilience.Timeout) - } -} diff --git a/gateway/gateway-controller/pkg/utils/mcp_resilience_test.go b/gateway/gateway-controller/pkg/utils/mcp_resilience_test.go deleted file mode 100644 index 092d9935da..0000000000 --- a/gateway/gateway-controller/pkg/utils/mcp_resilience_test.go +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. 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 utils - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" -) - -// mcpOpResilience returns the resilience block attached to the operation matching method+path, -// or nil if the operation is absent or carries no resilience. -func mcpOpResilience(ops []api.Operation, method api.OperationMethod, path string) *api.Resilience { - for _, op := range ops { - if op.Method == method && op.Path == path { - return op.Resilience - } - } - return nil -} - -// When no resilience is set, the MCP forwarding routes (GET/POST/DELETE /mcp) default the route -// timeout to "0s" (disabled) — MCP is streaming, so a finite total cap would sever a live stream. -// The local-response routes (OPTIONS, PRM) must carry no resilience. See mcp-timeout-divergence.md. -func TestMCPTransform_Resilience_DefaultsRouteTimeoutDisabled(t *testing.T) { - context := "/everything" - latest := LATEST_SUPPORTED_MCP_SPEC_VERSION - in := &api.MCPProxyConfiguration{ - Spec: api.MCPProxyConfigData{ - DisplayName: "everything", - Version: "v1.0", - Context: &context, - SpecVersion: &latest, - Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:8080")}, - // A cors policy makes the transformer emit the OPTIONS routes so we can assert they are skipped. - Policies: &[]api.Policy{{Name: "cors", Version: "v1"}}, - }, - } - - res, err := (&MCPTransformer{}).Transform(in, &api.RestAPI{}) - require.NoError(t, err) - ops := res.Spec.Operations - - for _, m := range []api.OperationMethod{api.OperationMethodGET, api.OperationMethodPOST, api.OperationMethodDELETE} { - r := mcpOpResilience(ops, m, constants.MCP_RESOURCE_PATH) - require.NotNil(t, r, "forwarding %s %s should carry resilience", m, constants.MCP_RESOURCE_PATH) - require.NotNil(t, r.Timeout) - assert.Equal(t, "0s", *r.Timeout, "route timeout should default to disabled for MCP") - assert.Nil(t, r.IdleTimeout, "idle timeout should be left to the global default when unset") - } - - // Local-response routes must not carry a route timeout. - assert.Nil(t, mcpOpResilience(ops, api.OperationMethodOPTIONS, constants.MCP_RESOURCE_PATH), "OPTIONS /mcp is a local reply") - assert.Nil(t, mcpOpResilience(ops, api.OperationMethodGET, constants.MCP_PRM_RESOURCE_PATH), "PRM route is a local reply") - assert.Nil(t, mcpOpResilience(ops, api.OperationMethodOPTIONS, constants.MCP_PRM_RESOURCE_PATH), "OPTIONS PRM is a local reply") -} - -// An explicit resilience block overrides the disabled default and its idleTimeout is carried through, -// on the forwarding routes only. -func TestMCPTransform_Resilience_UserOverride(t *testing.T) { - context := "/everything" - latest := LATEST_SUPPORTED_MCP_SPEC_VERSION - in := &api.MCPProxyConfiguration{ - Spec: api.MCPProxyConfigData{ - DisplayName: "everything", - Version: "v1.0", - Context: &context, - SpecVersion: &latest, - Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:8080")}, - Resilience: &api.Resilience{Timeout: stringPtr("30s"), IdleTimeout: stringPtr("120s")}, - }, - } - - res, err := (&MCPTransformer{}).Transform(in, &api.RestAPI{}) - require.NoError(t, err) - ops := res.Spec.Operations - - r := mcpOpResilience(ops, api.OperationMethodGET, constants.MCP_RESOURCE_PATH) - require.NotNil(t, r) - require.NotNil(t, r.Timeout) - assert.Equal(t, "30s", *r.Timeout) - require.NotNil(t, r.IdleTimeout) - assert.Equal(t, "120s", *r.IdleTimeout) - - // The PRM route (present for spec >= 2025-06-18) is still skipped. - assert.Nil(t, mcpOpResilience(ops, api.OperationMethodGET, constants.MCP_PRM_RESOURCE_PATH)) -} - -// A user timeout with no idle override still disables nothing extra: timeout is the user value, -// idle stays unset (global default). -func TestMCPTransform_Resilience_TimeoutOnly(t *testing.T) { - context := "/everything" - latest := LATEST_SUPPORTED_MCP_SPEC_VERSION - in := &api.MCPProxyConfiguration{ - Spec: api.MCPProxyConfigData{ - DisplayName: "everything", - Version: "v1.0", - Context: &context, - SpecVersion: &latest, - Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:8080")}, - Resilience: &api.Resilience{Timeout: stringPtr("45s")}, - }, - } - - res, err := (&MCPTransformer{}).Transform(in, &api.RestAPI{}) - require.NoError(t, err) - - r := mcpOpResilience(res.Spec.Operations, api.OperationMethodPOST, constants.MCP_RESOURCE_PATH) - require.NotNil(t, r) - require.NotNil(t, r.Timeout) - assert.Equal(t, "45s", *r.Timeout) - assert.Nil(t, r.IdleTimeout) -} diff --git a/gateway/gateway-controller/pkg/utils/mcp_transformer_test.go b/gateway/gateway-controller/pkg/utils/mcp_transformer_test.go index 2580a02c94..01eb7b9dc2 100644 --- a/gateway/gateway-controller/pkg/utils/mcp_transformer_test.go +++ b/gateway/gateway-controller/pkg/utils/mcp_transformer_test.go @@ -21,6 +21,8 @@ package utils import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" ) @@ -478,3 +480,171 @@ func TestGetParamsOfPolicy_MCP(t *testing.T) { t.Error("Expected request.headers in params") } } + +// ============================================================================ +// Resilience route-mapping tests +// ============================================================================ + +// mcpOpResilience returns the resilience block attached to the operation matching method+path, +// or nil if the operation is absent or carries no resilience. +func mcpOpResilience(ops []api.Operation, method api.OperationMethod, path string) *api.Resilience { + for _, op := range ops { + if op.Method == method && op.Path == path { + return op.Resilience + } + } + return nil +} + +// When no resilience is set, the MCP forwarding routes (GET/POST/DELETE /mcp) default the route +// timeout to "0s" (disabled) — MCP is streaming, so a finite total cap would sever a live stream. +// The local-response routes (OPTIONS, PRM) must carry no resilience. See mcp-timeout-divergence.md. +func TestMCPTransform_Resilience_DefaultsRouteTimeoutDisabled(t *testing.T) { + context := "/everything" + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "everything", + Version: "v1.0", + Context: &context, + SpecVersion: &latest, + Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:8080")}, + // A cors policy makes the transformer emit the OPTIONS routes so we can assert they are skipped. + Policies: &[]api.Policy{{Name: "cors", Version: "v1"}}, + }, + } + + res, err := (&MCPTransformer{}).Transform(in, &api.RestAPI{}) + require.NoError(t, err) + ops := res.Spec.Operations + + for _, m := range []api.OperationMethod{api.OperationMethodGET, api.OperationMethodPOST, api.OperationMethodDELETE} { + r := mcpOpResilience(ops, m, constants.MCP_RESOURCE_PATH) + require.NotNil(t, r, "forwarding %s %s should carry resilience", m, constants.MCP_RESOURCE_PATH) + require.NotNil(t, r.Timeout) + assert.Equal(t, "0s", *r.Timeout, "route timeout should default to disabled for MCP") + assert.Nil(t, r.IdleTimeout, "idle timeout should be left to the global default when unset") + } + + // Local-response routes must not carry a route timeout. + assert.Nil(t, mcpOpResilience(ops, api.OperationMethodOPTIONS, constants.MCP_RESOURCE_PATH), "OPTIONS /mcp is a local reply") + assert.Nil(t, mcpOpResilience(ops, api.OperationMethodGET, constants.MCP_PRM_RESOURCE_PATH), "PRM route is a local reply") + assert.Nil(t, mcpOpResilience(ops, api.OperationMethodOPTIONS, constants.MCP_PRM_RESOURCE_PATH), "OPTIONS PRM is a local reply") +} + +// An explicit resilience block overrides the disabled default and its idleTimeout is carried through, +// on the forwarding routes only. +func TestMCPTransform_Resilience_UserOverride(t *testing.T) { + context := "/everything" + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "everything", + Version: "v1.0", + Context: &context, + SpecVersion: &latest, + Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:8080")}, + Resilience: &api.Resilience{Timeout: stringPtr("30s"), IdleTimeout: stringPtr("120s")}, + }, + } + + res, err := (&MCPTransformer{}).Transform(in, &api.RestAPI{}) + require.NoError(t, err) + ops := res.Spec.Operations + + r := mcpOpResilience(ops, api.OperationMethodGET, constants.MCP_RESOURCE_PATH) + require.NotNil(t, r) + require.NotNil(t, r.Timeout) + assert.Equal(t, "30s", *r.Timeout) + require.NotNil(t, r.IdleTimeout) + assert.Equal(t, "120s", *r.IdleTimeout) + + // The PRM route (present for spec >= 2025-06-18) is still skipped. + assert.Nil(t, mcpOpResilience(ops, api.OperationMethodGET, constants.MCP_PRM_RESOURCE_PATH)) +} + +// A user timeout with no idle override: timeout is the user value, idle stays unset (global default). +func TestMCPTransform_Resilience_TimeoutOnly(t *testing.T) { + context := "/everything" + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "everything", + Version: "v1.0", + Context: &context, + SpecVersion: &latest, + Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:8080")}, + Resilience: &api.Resilience{Timeout: stringPtr("45s")}, + }, + } + + res, err := (&MCPTransformer{}).Transform(in, &api.RestAPI{}) + require.NoError(t, err) + + r := mcpOpResilience(res.Spec.Operations, api.OperationMethodPOST, constants.MCP_RESOURCE_PATH) + require.NotNil(t, r) + require.NotNil(t, r.Timeout) + assert.Equal(t, "45s", *r.Timeout) + assert.Nil(t, r.IdleTimeout) +} + +// ============================================================================ +// Upstream ref / connect-timeout threading tests +// ============================================================================ + +// The MCP converter maps an upstream `ref` onto the derived RestAPI and threads the +// upstreamDefinitions through, so the per-upstream connect timeout resolves like it does for RestApi. +func TestMCPTransform_UpstreamRef_ThreadsDefinitions(t *testing.T) { + context := "/everything" + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + defs := []api.UpstreamDefinition{{ + Name: "mcp-backend", + Timeout: &api.UpstreamTimeout{Connect: stringPtr("6s")}, + }} + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "everything", + Version: "v1.0", + Context: &context, + SpecVersion: &latest, + UpstreamDefinitions: &defs, + Upstream: api.MCPProxyConfigData_Upstream{Ref: stringPtr("mcp-backend")}, + }, + } + + var out api.RestAPI + res, err := (&MCPTransformer{}).Transform(in, &out) + require.NoError(t, err) + + require.NotNil(t, res.Spec.Upstream.Main.Ref) + assert.Equal(t, "mcp-backend", *res.Spec.Upstream.Main.Ref) + assert.Nil(t, res.Spec.Upstream.Main.Url) + + require.NotNil(t, res.Spec.UpstreamDefinitions) + require.Len(t, *res.Spec.UpstreamDefinitions, 1) + require.NotNil(t, (*res.Spec.UpstreamDefinitions)[0].Timeout) + assert.Equal(t, "6s", *(*res.Spec.UpstreamDefinitions)[0].Timeout.Connect) +} + +func TestMCPTransform_UpstreamUrl_Unchanged(t *testing.T) { + context := "/everything" + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "everything", + Version: "v1.0", + Context: &context, + SpecVersion: &latest, + Upstream: api.MCPProxyConfigData_Upstream{Url: stringPtr("http://backend:8080")}, + }, + } + + var out api.RestAPI + res, err := (&MCPTransformer{}).Transform(in, &out) + require.NoError(t, err) + + require.NotNil(t, res.Spec.Upstream.Main.Url) + assert.Equal(t, "http://backend:8080", *res.Spec.Upstream.Main.Url) + assert.Nil(t, res.Spec.Upstream.Main.Ref) + assert.Nil(t, res.Spec.UpstreamDefinitions) +} diff --git a/gateway/gateway-controller/pkg/xds/resilience_duration_test.go b/gateway/gateway-controller/pkg/xds/resilience_duration_test.go deleted file mode 100644 index 714c99d83b..0000000000 --- a/gateway/gateway-controller/pkg/xds/resilience_duration_test.go +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. 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 xds - -import "testing" - -// parseDurationAllowZero must accept exactly what the CRD admission controller accepts -// (constants.ResilienceDurationPattern): single-unit durations including "0s" to disable, while -// rejecting compound, negative, and unitless values. -func TestParseDurationAllowZero_MatchesCRDPattern(t *testing.T) { - ptr := func(s string) *string { return &s } - - t.Run("accepts single-unit and zero", func(t *testing.T) { - for _, in := range []string{"30s", "500ms", "1m", "2h", "1.5s", "0s", "0ms"} { - d, err := parseDurationAllowZero(ptr(in)) - if err != nil { - t.Errorf("expected %q to be accepted, got error: %v", in, err) - continue - } - if d == nil { - t.Errorf("expected %q to yield a non-nil duration", in) - } - } - }) - - t.Run("nil and empty yield nil without error", func(t *testing.T) { - for _, in := range []*string{nil, ptr(""), ptr(" ")} { - d, err := parseDurationAllowZero(in) - if err != nil || d != nil { - t.Errorf("expected nil,nil for empty input, got %v,%v", d, err) - } - } - }) - - t.Run("rejects compound, negative, and unitless", func(t *testing.T) { - for _, in := range []string{"1h30m", "1m30s", "-30s", "-5s", "30", "0", "15seconds", "abc"} { - if _, err := parseDurationAllowZero(ptr(in)); err == nil { - t.Errorf("expected %q to be rejected, but it was accepted", in) - } - } - }) -} diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index babf2a8500..428ea8adf7 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -2624,3 +2624,40 @@ func TestTranslator_CreateDynamicFwdListenerForWebSubHub(t *testing.T) { assert.Equal(t, core.SocketAddress_TCP, listener.GetAddress().GetSocketAddress().GetProtocol()) }) } + +// parseDurationAllowZero must accept exactly what the CRD admission controller accepts +// (constants.ResilienceDurationPattern): single-unit durations including "0s" to disable, while +// rejecting compound, negative, and unitless values. +func TestParseDurationAllowZero_MatchesCRDPattern(t *testing.T) { + ptr := func(s string) *string { return &s } + + t.Run("accepts single-unit and zero", func(t *testing.T) { + for _, in := range []string{"30s", "500ms", "1m", "2h", "1.5s", "0s", "0ms"} { + d, err := parseDurationAllowZero(ptr(in)) + if err != nil { + t.Errorf("expected %q to be accepted, got error: %v", in, err) + continue + } + if d == nil { + t.Errorf("expected %q to yield a non-nil duration", in) + } + } + }) + + t.Run("nil and empty yield nil without error", func(t *testing.T) { + for _, in := range []*string{nil, ptr(""), ptr(" ")} { + d, err := parseDurationAllowZero(in) + if err != nil || d != nil { + t.Errorf("expected nil,nil for empty input, got %v,%v", d, err) + } + } + }) + + t.Run("rejects compound, negative, and unitless", func(t *testing.T) { + for _, in := range []string{"1h30m", "1m30s", "-30s", "-5s", "30", "0", "15seconds", "abc"} { + if _, err := parseDurationAllowZero(ptr(in)); err == nil { + t.Errorf("expected %q to be rejected, but it was accepted", in) + } + } + }) +} From 12f11ced25a98525336177bc7c7e2cb0716aa9e3 Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Wed, 8 Jul 2026 09:04:57 +0530 Subject: [PATCH 12/14] Address review comments and add missing upstreamDefinition support via operator --- .../pkg/config/api_validator.go | 41 +++++++-- .../pkg/config/llm_validator.go | 7 +- .../pkg/config/llm_validator_test.go | 30 ++++++- .../pkg/config/mcp_validator.go | 9 +- .../pkg/config/mcp_validator_test.go | 4 +- .../api/v1alpha1/restapi_types.go | 63 +++++++++++++- .../api/v1alpha1/upstream_definition_types.go | 67 --------------- .../api/v1alpha1/zz_generated.deepcopy.go | 21 ++++- ...ay.api-platform.wso2.com_llmproviders.yaml | 6 +- .../gateway.api-platform.wso2.com_mcps.yaml | 6 +- ...ateway.api-platform.wso2.com_restapis.yaml | 85 +++++++++++++++++-- .../config/samples/api_v1_restapi.yaml | 28 +++++- .../internal/controller/httproute_mapper.go | 2 +- .../controller/httproute_mapper_test.go | 8 +- .../gatewayclient/yaml_payload_test.go | 49 ++++++++++- ...ay.api-platform.wso2.com_llmproviders.yaml | 6 +- .../gateway.api-platform.wso2.com_mcps.yaml | 6 +- ...ateway.api-platform.wso2.com_restapis.yaml | 85 +++++++++++++++++-- 18 files changed, 398 insertions(+), 125 deletions(-) delete mode 100644 kubernetes/gateway-operator/api/v1alpha1/upstream_definition_types.go diff --git a/gateway/gateway-controller/pkg/config/api_validator.go b/gateway/gateway-controller/pkg/config/api_validator.go index 5f6b754abc..f47b284178 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -270,6 +270,11 @@ func (v *APIValidator) validateUpstreamDefinitions(definitions *[]api.UpstreamDe return validateUpstreamDefinitionsList("spec.upstreamDefinitions", definitions) } +// upstreamDefinitionNameRegex enforces the same name constraint as the CRD/OpenAPI +// (UpstreamDefinition.name pattern), so a definition accepted over the management API cannot carry a +// name that CRD admission would reject. The name is also used for Envoy cluster naming. +var upstreamDefinitionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`) + // validateUpstreamDefinitionsList validates an upstreamDefinitions array. fieldPrefix is the path // to the array (e.g. "spec.upstreamDefinitions"). It is shared by the RestApi, LLM Provider, and // MCP validators so the three kinds validate upstream definitions identically. The connect timeout @@ -286,7 +291,7 @@ func validateUpstreamDefinitionsList(fieldPrefix string, definitions *[]api.Upst namesSeen := make(map[string]bool) for i, def := range *definitions { - // Validate name + // Validate name (must match the CRD/OpenAPI constraint: 1-100 chars, ^[a-zA-Z0-9\-_]+$). if def.Name == "" { errors = append(errors, ValidationError{ Field: fmt.Sprintf("%s[%d].name", fieldPrefix, i), @@ -294,6 +299,18 @@ func validateUpstreamDefinitionsList(fieldPrefix string, definitions *[]api.Upst }) continue } + if len(def.Name) > 100 { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].name", fieldPrefix, i), + Message: "Upstream definition name must be 1-100 characters", + }) + } + if !upstreamDefinitionNameRegex.MatchString(def.Name) { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].name", fieldPrefix, i), + Message: "Upstream definition name must match ^[a-zA-Z0-9\\-_]+$ (letters, numbers, hyphens, underscores)", + }) + } // Check for duplicate names if namesSeen[def.Name] { @@ -381,14 +398,24 @@ func validateUpstreamDefinitionsList(fieldPrefix string, definitions *[]api.Upst } // Timeout validation is limited to connect timeout. Enforce the same single-unit duration - // pattern as the CRD/OpenAPI so gateway validation cannot diverge from CRD admission. + // pattern as the CRD/OpenAPI so gateway validation cannot diverge from CRD admission, then + // a ParseDuration guard for pathological overflow — mirroring validateResilienceTimeouts. if def.Timeout != nil && def.Timeout.Connect != nil { timeoutStr := strings.TrimSpace(*def.Timeout.Connect) - if timeoutStr != "" && !constants.ResilienceDurationRegex.MatchString(timeoutStr) { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s[%d].timeout.connect", fieldPrefix, i), - Message: "Invalid timeout format (expected a single-unit duration like '30s', '1m', '500ms')", - }) + if timeoutStr != "" { + if !constants.ResilienceDurationRegex.MatchString(timeoutStr) { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].timeout.connect", fieldPrefix, i), + Message: "Invalid timeout format (expected a single-unit duration like '30s', '1m', '500ms')", + }) + } else if _, err := time.ParseDuration(timeoutStr); err != nil { + // The pattern guarantees a single-unit value; ParseDuration is a final guard + // against pathological overflow (e.g. "99999999999999999999s"). + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].timeout.connect", fieldPrefix, i), + Message: fmt.Sprintf("Invalid timeout format: %v", err), + }) + } } } } diff --git a/gateway/gateway-controller/pkg/config/llm_validator.go b/gateway/gateway-controller/pkg/config/llm_validator.go index 8ea26767eb..a33c909524 100644 --- a/gateway/gateway-controller/pkg/config/llm_validator.go +++ b/gateway/gateway-controller/pkg/config/llm_validator.go @@ -443,11 +443,12 @@ func (v *LLMValidator) validateUpstreamWithAuth(fieldPrefix string, }) case !hasURL && !hasRef: errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s.url", fieldPrefix), - Message: "Upstream URL is required", + Field: fieldPrefix, + Message: "Must specify either 'url' or 'ref'", }) case hasURL: - parsedURL, err := url.Parse(*upstream.Url) + // Trim first, consistent with the hasURL check, so surrounding whitespace does not fail parsing. + parsedURL, err := url.Parse(strings.TrimSpace(*upstream.Url)) if err != nil { errors = append(errors, ValidationError{ Field: fmt.Sprintf("%s.url", fieldPrefix), diff --git a/gateway/gateway-controller/pkg/config/llm_validator_test.go b/gateway/gateway-controller/pkg/config/llm_validator_test.go index 286361391c..ec233ce42d 100644 --- a/gateway/gateway-controller/pkg/config/llm_validator_test.go +++ b/gateway/gateway-controller/pkg/config/llm_validator_test.go @@ -1288,8 +1288,8 @@ func TestValidateLLMProvider_Upstream(t *testing.T) { Url: nil, }, expectError: true, - errorField: "spec.upstream.url", - errorPart: "required", + errorField: "spec.upstream", + errorPart: "either 'url' or 'ref'", }, { name: "empty URL", @@ -1297,7 +1297,7 @@ func TestValidateLLMProvider_Upstream(t *testing.T) { Url: stringPtr(""), }, expectError: true, - errorField: "spec.upstream.url", + errorField: "spec.upstream", }, { name: "invalid URL - no protocol", @@ -2088,4 +2088,28 @@ func TestValidateLLMProvider_UpstreamRef(t *testing.T) { errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) assert.Empty(t, errs) }) + + t.Run("connect timeout that overflows time.Duration rejected", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "99999999999999999999s")} + errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) + assertHasFieldError(t, errs, "spec.upstreamDefinitions[0].timeout.connect") + }) + + t.Run("definition name with invalid characters rejected (CRD pattern)", func(t *testing.T) { + defs := &[]api.UpstreamDefinition{upstreamDef("bad name!", "6s")} + errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("bad name!")})) + assertHasFieldError(t, errs, "spec.upstreamDefinitions[0].name") + }) + + t.Run("definition name over 100 chars rejected", func(t *testing.T) { + long := strings.Repeat("a", 101) + defs := &[]api.UpstreamDefinition{upstreamDef(long, "6s")} + errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr(long)})) + assertHasFieldError(t, errs, "spec.upstreamDefinitions[0].name") + }) + + t.Run("url with surrounding whitespace is accepted", func(t *testing.T) { + errs := validator.Validate(providerWithUpstream(nil, api.LLMProviderConfigData_Upstream{Url: stringPtr(" https://api.openai.com ")})) + assert.Empty(t, errs) + }) } diff --git a/gateway/gateway-controller/pkg/config/mcp_validator.go b/gateway/gateway-controller/pkg/config/mcp_validator.go index 8a4ed0a5b2..2cc5ad24ee 100644 --- a/gateway/gateway-controller/pkg/config/mcp_validator.go +++ b/gateway/gateway-controller/pkg/config/mcp_validator.go @@ -222,8 +222,8 @@ func (v *MCPValidator) validateUpstream(fieldPrefix string, upstream *api.MCPPro }) case !hasURL && !hasRef: errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s.url", fieldPrefix), - Message: "Upstream URL is required", + Field: fieldPrefix, + Message: "Must specify either 'url' or 'ref'", }) case hasRef: if !upstreamRefResolves(*upstream.Ref, definitions) { @@ -233,8 +233,9 @@ func (v *MCPValidator) validateUpstream(fieldPrefix string, upstream *api.MCPPro }) } case hasURL: - // Validate URL format - parsedURL, err := url.Parse(*upstream.Url) + // Validate URL format (trim first, consistent with the hasURL check so surrounding + // whitespace does not fail parsing). + parsedURL, err := url.Parse(strings.TrimSpace(*upstream.Url)) if err != nil { errors = append(errors, ValidationError{ Field: fmt.Sprintf("%s.url", fieldPrefix), diff --git a/gateway/gateway-controller/pkg/config/mcp_validator_test.go b/gateway/gateway-controller/pkg/config/mcp_validator_test.go index 446f455118..7a5b31c6fb 100644 --- a/gateway/gateway-controller/pkg/config/mcp_validator_test.go +++ b/gateway/gateway-controller/pkg/config/mcp_validator_test.go @@ -473,13 +473,13 @@ func TestMCPValidator_ValidateUpstream(t *testing.T) { name: "Nil URL", upstream: &api.MCPProxyConfigData_Upstream{Url: nil}, wantError: true, - errField: "spec.upstream.url", + errField: "spec.upstream", }, { name: "Empty URL", upstream: &api.MCPProxyConfigData_Upstream{Url: stringPtr("")}, wantError: true, - errField: "spec.upstream.url", + errField: "spec.upstream", }, { name: "Invalid URL scheme", diff --git a/kubernetes/gateway-operator/api/v1alpha1/restapi_types.go b/kubernetes/gateway-operator/api/v1alpha1/restapi_types.go index a24bf15b78..b2edd6c0ef 100644 --- a/kubernetes/gateway-operator/api/v1alpha1/restapi_types.go +++ b/kubernetes/gateway-operator/api/v1alpha1/restapi_types.go @@ -79,6 +79,11 @@ type APIConfigData struct { // +optional Resilience *Resilience `json:"resilience,omitempty"` + // UpstreamDefinitions is the list of reusable upstream definitions (with optional connect + // timeout) that upstream.ref can reference. + // +optional + UpstreamDefinitions []UpstreamDefinition `json:"upstreamDefinitions,omitempty"` + // Upstream API-level upstream configuration // +kubebuilder:validation:Required Upstream UpstreamConfig `json:"upstream"` @@ -194,12 +199,66 @@ type Policy struct { Version string `json:"version"` } -// Upstream defines model for Upstream. +// Upstream defines model for Upstream. Exactly one of url or ref must be set: a direct backend URL, +// or a reference to a predefined upstreamDefinition (which can carry a per-upstream connect timeout). +// +kubebuilder:validation:XValidation:rule="has(self.url) != has(self.ref)",message="exactly one of url or ref must be set" type Upstream struct { - // Url Backend service URL (may include path prefix like /api/v2) + // Url Direct backend service URL (may include path prefix like /api/v2) + // +optional + // +kubebuilder:validation:Pattern=`^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$` + Url *string `json:"url,omitempty"` + + // Ref Name of a predefined upstreamDefinition to route through. + // +optional + Ref *string `json:"ref,omitempty"` +} + +// UpstreamDefinition is a reusable upstream configuration with an optional connect timeout and +// load-balancing targets. Referenced from an upstream via its `ref` field. Shared by RestApi, +// LLM Provider, and MCP; mirrors the management-API UpstreamDefinition schema. +type UpstreamDefinition struct { + // Name Unique identifier for this upstream definition (referenced by upstream.ref). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=100 + // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9\-_]+$` + Name string `json:"name"` + + // BasePath Base path prefix prepended to all requests routed through this upstream (e.g. /api/v2). + // +optional + BasePath *string `json:"basePath,omitempty"` + + // Timeout Optional timeout configuration for this upstream (connect timeout). + // +optional + Timeout *UpstreamTimeout `json:"timeout,omitempty"` + + // Upstreams List of backend targets with optional weights for load balancing. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + Upstreams []UpstreamTarget `json:"upstreams"` +} + +// UpstreamTimeout carries the per-upstream timeout configuration. Only the connect timeout is +// supported at the upstream-definition level. +type UpstreamTimeout struct { + // Connect Connection-establishment timeout duration (e.g. "5s", "500ms"). "0s" disables. + // +optional + // +kubebuilder:validation:Pattern=`^\d+(\.\d+)?(ms|s|m|h)$` + Connect *string `json:"connect,omitempty"` +} + +// UpstreamTarget is a single backend target within an UpstreamDefinition. +type UpstreamTarget struct { + // Url Backend URL (host and port only; path comes from the definition's basePath). // +kubebuilder:validation:Required // +kubebuilder:validation:Pattern=`^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$` Url string `json:"url"` + + // Weight Weight for load balancing (optional, default 100). + // +optional + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=100 + Weight *int `json:"weight,omitempty"` } // Condition Types for RestApi diff --git a/kubernetes/gateway-operator/api/v1alpha1/upstream_definition_types.go b/kubernetes/gateway-operator/api/v1alpha1/upstream_definition_types.go deleted file mode 100644 index ebf79c8f89..0000000000 --- a/kubernetes/gateway-operator/api/v1alpha1/upstream_definition_types.go +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. 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 - -// UpstreamDefinition is a reusable upstream configuration with an optional connect -// timeout and load-balancing targets. Referenced from an upstream via its `ref` field. -// Mirrors the management-API UpstreamDefinition schema. -type UpstreamDefinition struct { - // Name Unique identifier for this upstream definition (referenced by upstream.ref). - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=100 - // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9\-_]+$` - Name string `json:"name"` - - // BasePath Base path prefix prepended to all requests routed through this upstream (e.g. /api/v2). - // +optional - BasePath *string `json:"basePath,omitempty"` - - // Timeout Optional timeout configuration for this upstream (connect timeout). - // +optional - Timeout *UpstreamTimeout `json:"timeout,omitempty"` - - // Upstreams List of backend targets with optional weights for load balancing. - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinItems=1 - Upstreams []UpstreamTarget `json:"upstreams"` -} - -// UpstreamTimeout carries the per-upstream timeout configuration. Only the connect -// timeout is supported at the upstream-definition level. -type UpstreamTimeout struct { - // Connect Connection-establishment timeout duration (e.g. "5s", "500ms"). "0s" disables. - // +optional - // +kubebuilder:validation:Pattern=`^\d+(\.\d+)?(ms|s|m|h)$` - Connect *string `json:"connect,omitempty"` -} - -// UpstreamTarget is a single backend target within an UpstreamDefinition. -type UpstreamTarget struct { - // Url Backend URL (host and port only; path comes from the definition's basePath). - // +kubebuilder:validation:Required - // +kubebuilder:validation:Pattern=`^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$` - Url string `json:"url"` - - // Weight Weight for load balancing (optional, default 100). - // +optional - // +kubebuilder:validation:Minimum=0 - // +kubebuilder:validation:Maximum=100 - Weight *int `json:"weight,omitempty"` -} diff --git a/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go b/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go index 77b0dd5914..62ce0b9bd5 100644 --- a/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/kubernetes/gateway-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -48,6 +48,13 @@ func (in *APIConfigData) DeepCopyInto(out *APIConfigData) { *out = new(Resilience) (*in).DeepCopyInto(*out) } + if in.UpstreamDefinitions != nil { + in, out := &in.UpstreamDefinitions, &out.UpstreamDefinitions + *out = make([]UpstreamDefinition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } in.Upstream.DeepCopyInto(&out.Upstream) if in.Vhosts != nil { in, out := &in.Vhosts, &out.Vhosts @@ -2071,6 +2078,16 @@ func (in *SubscriptionSpec) DeepCopy() *SubscriptionSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Upstream) DeepCopyInto(out *Upstream) { *out = *in + if in.Url != nil { + in, out := &in.Url, &out.Url + *out = new(string) + **out = **in + } + if in.Ref != nil { + in, out := &in.Ref, &out.Ref + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Upstream. @@ -2086,11 +2103,11 @@ func (in *Upstream) DeepCopy() *Upstream { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UpstreamConfig) DeepCopyInto(out *UpstreamConfig) { *out = *in - out.Main = in.Main + in.Main.DeepCopyInto(&out.Main) if in.Sandbox != nil { in, out := &in.Sandbox, &out.Sandbox *out = new(Upstream) - **out = **in + (*in).DeepCopyInto(*out) } } diff --git a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml index c38f002ed5..7d00ac6dc9 100644 --- a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml +++ b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml @@ -252,9 +252,9 @@ spec: connect timeout) that upstream.ref can reference. items: description: |- - UpstreamDefinition is a reusable upstream configuration with an optional connect - timeout and load-balancing targets. Referenced from an upstream via its `ref` field. - Mirrors the management-API UpstreamDefinition schema. + UpstreamDefinition is a reusable upstream configuration with an optional connect timeout and + load-balancing targets. Referenced from an upstream via its `ref` field. Shared by RestApi, + LLM Provider, and MCP; mirrors the management-API UpstreamDefinition schema. properties: basePath: description: BasePath Base path prefix prepended to all requests diff --git a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml index 6b9c546506..94d647bb16 100644 --- a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml +++ b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml @@ -276,9 +276,9 @@ spec: connect timeout) that upstream.ref can reference. items: description: |- - UpstreamDefinition is a reusable upstream configuration with an optional connect - timeout and load-balancing targets. Referenced from an upstream via its `ref` field. - Mirrors the management-API UpstreamDefinition schema. + UpstreamDefinition is a reusable upstream configuration with an optional connect timeout and + load-balancing targets. Referenced from an upstream via its `ref` field. Shared by RestApi, + LLM Provider, and MCP; mirrors the management-API UpstreamDefinition schema. properties: basePath: description: BasePath Base path prefix prepended to all requests diff --git a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_restapis.yaml b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_restapis.yaml index 4e62b044a0..c51908a9af 100644 --- a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_restapis.yaml +++ b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_restapis.yaml @@ -169,29 +169,98 @@ spec: description: Main Upstream backend configuration for production traffic properties: + ref: + description: Ref Name of a predefined upstreamDefinition to + route through. + type: string url: - description: Url Backend service URL (may include path prefix - like /api/v2) + description: Url Direct backend service URL (may include path + prefix like /api/v2) pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ type: string - required: - - url type: object + x-kubernetes-validations: + - message: exactly one of url or ref must be set + rule: has(self.url) != has(self.ref) sandbox: description: Sandbox Upstream backend configuration for sandbox/testing traffic properties: + ref: + description: Ref Name of a predefined upstreamDefinition to + route through. + type: string url: - description: Url Backend service URL (may include path prefix - like /api/v2) + description: Url Direct backend service URL (may include path + prefix like /api/v2) pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ type: string - required: - - url type: object + x-kubernetes-validations: + - message: exactly one of url or ref must be set + rule: has(self.url) != has(self.ref) required: - main type: object + upstreamDefinitions: + description: |- + UpstreamDefinitions is the list of reusable upstream definitions (with optional connect + timeout) that upstream.ref can reference. + items: + description: |- + UpstreamDefinition is a reusable upstream configuration with an optional connect timeout and + load-balancing targets. Referenced from an upstream via its `ref` field. Shared by RestApi, + LLM Provider, and MCP; mirrors the management-API UpstreamDefinition schema. + properties: + basePath: + description: BasePath Base path prefix prepended to all requests + routed through this upstream (e.g. /api/v2). + type: string + name: + description: Name Unique identifier for this upstream definition + (referenced by upstream.ref). + maxLength: 100 + minLength: 1 + pattern: ^[a-zA-Z0-9\-_]+$ + type: string + timeout: + description: Timeout Optional timeout configuration for this + upstream (connect timeout). + properties: + connect: + description: Connect Connection-establishment timeout duration + (e.g. "5s", "500ms"). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object + upstreams: + description: Upstreams List of backend targets with optional + weights for load balancing. + items: + description: UpstreamTarget is a single backend target within + an UpstreamDefinition. + properties: + url: + description: Url Backend URL (host and port only; path + comes from the definition's basePath). + pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ + type: string + weight: + description: Weight Weight for load balancing (optional, + default 100). + maximum: 100 + minimum: 0 + type: integer + required: + - url + type: object + minItems: 1 + type: array + required: + - name + - upstreams + type: object + type: array version: description: Version Semantic version of the API pattern: ^v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ diff --git a/kubernetes/gateway-operator/config/samples/api_v1_restapi.yaml b/kubernetes/gateway-operator/config/samples/api_v1_restapi.yaml index 74ce33658d..29286a9dbf 100644 --- a/kubernetes/gateway-operator/config/samples/api_v1_restapi.yaml +++ b/kubernetes/gateway-operator/config/samples/api_v1_restapi.yaml @@ -70,4 +70,30 @@ spec: - method: GET path: /helloworld - method: GET - path: /heyhelloworld \ No newline at end of file + path: /heyhelloworld + + +--- +# Example 3: API routing through a reusable upstreamDefinition with a per-upstream connect timeout. +# The upstream references the definition by name (url XOR ref); the connect timeout bounds TCP +# connection establishment to the backend. +apiVersion: gateway.api-platform.wso2.com/v1alpha1 +kind: RestApi +metadata: + name: api-with-upstream-definition +spec: + displayName: test-api-upstreamdef + version: v1.0 + context: /orders + upstreamDefinitions: + - name: orders-backend + timeout: + connect: 6s + upstreams: + - url: http://orders.default.svc.cluster.local:8080 + upstream: + main: + ref: orders-backend + operations: + - method: GET + path: /info \ No newline at end of file diff --git a/kubernetes/gateway-operator/internal/controller/httproute_mapper.go b/kubernetes/gateway-operator/internal/controller/httproute_mapper.go index e760f3f97a..218142a131 100644 --- a/kubernetes/gateway-operator/internal/controller/httproute_mapper.go +++ b/kubernetes/gateway-operator/internal/controller/httproute_mapper.go @@ -257,7 +257,7 @@ func BuildAPIConfigFromHTTPRoute(ctx context.Context, c client.Client, route *ga DisplayName: displayName, Operations: ops, Upstream: apiv1.UpstreamConfig{ - Main: apiv1.Upstream{Url: backendURL}, + Main: apiv1.Upstream{Url: &backendURL}, }, Version: version, Policies: apiPolicies, diff --git a/kubernetes/gateway-operator/internal/controller/httproute_mapper_test.go b/kubernetes/gateway-operator/internal/controller/httproute_mapper_test.go index a2de552ecd..a82ad7d2c8 100644 --- a/kubernetes/gateway-operator/internal/controller/httproute_mapper_test.go +++ b/kubernetes/gateway-operator/internal/controller/httproute_mapper_test.go @@ -89,7 +89,7 @@ func TestBuildAPIConfigFromHTTPRoute(t *testing.T) { require.Len(t, spec.Operations, 1) // PathPrefix "/api/hello" must emit a prefix route ("/*" suffix), not an Exact path. require.Equal(t, "/api/hello/*", spec.Operations[0].Path) - require.Equal(t, "http://backend.default.svc.cluster.local:8080", spec.Upstream.Main.Url) + require.Equal(t, strPtr("http://backend.default.svc.cluster.local:8080"), spec.Upstream.Main.Url) } // TestBuildAPIConfigFromHTTPRoute_PathMatchType pins the issue #2021 fix: the mapper must honor @@ -563,7 +563,7 @@ func TestBuildAPIConfigFromHTTPRoute_CrossNamespaceReferenceGrant(t *testing.T) cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(svc, route, grant).Build() spec, err := BuildAPIConfigFromHTTPRoute(context.Background(), cl, route, "cluster.local", nil) require.NoError(t, err) - require.Equal(t, "http://backend.data.svc.cluster.local:8080", spec.Upstream.Main.Url) + require.Equal(t, strPtr("http://backend.data.svc.cluster.local:8080"), spec.Upstream.Main.Url) }) t.Run("name-scoped grant must match service", func(t *testing.T) { @@ -818,11 +818,11 @@ func TestBuildAPIConfigFromHTTPRoute_CustomClusterDomain(t *testing.T) { spec, err := BuildAPIConfigFromHTTPRoute(context.Background(), cl, route, "example.k8s.local", nil) require.NoError(t, err) - require.Equal(t, "http://backend.default.svc.example.k8s.local:8080", spec.Upstream.Main.Url) + require.Equal(t, strPtr("http://backend.default.svc.example.k8s.local:8080"), spec.Upstream.Main.Url) spec2, err := BuildAPIConfigFromHTTPRoute(context.Background(), cl, route, ".cluster.local.", nil) require.NoError(t, err) - require.Equal(t, "http://backend.default.svc.cluster.local:8080", spec2.Upstream.Main.Url) + require.Equal(t, strPtr("http://backend.default.svc.cluster.local:8080"), spec2.Upstream.Main.Url) } func TestDefaultHTTPRouteAPIHandle(t *testing.T) { diff --git a/kubernetes/gateway-operator/internal/gatewayclient/yaml_payload_test.go b/kubernetes/gateway-operator/internal/gatewayclient/yaml_payload_test.go index 40d3a915f8..53f9899ea8 100644 --- a/kubernetes/gateway-operator/internal/gatewayclient/yaml_payload_test.go +++ b/kubernetes/gateway-operator/internal/gatewayclient/yaml_payload_test.go @@ -17,7 +17,7 @@ func TestBuildRestAPIYAML_IncludesMetadataAnnotationsAndLabels(t *testing.T) { {Method: apiv1.OperationMethodGET, Path: "/"}, }, Upstream: apiv1.UpstreamConfig{ - Main: apiv1.Upstream{Url: "http://hello.default.svc.cluster.local:9080"}, + Main: apiv1.Upstream{Url: stringPtr("http://hello.default.svc.cluster.local:9080")}, }, } md := RestAPIPayloadMetadata{ @@ -43,3 +43,50 @@ func TestBuildRestAPIYAML_IncludesMetadataAnnotationsAndLabels(t *testing.T) { require.True(t, ok) require.Equal(t, "1234567890", annotations["project-id"]) } + +// A RestApi spec using upstreamDefinitions + upstream.main.ref must survive the verbatim +// marshal to the management-API payload, so the operator reaches parity with a direct +// management-API deploy (the connect timeout is carried on the definition). +func TestBuildRestAPIYAML_CarriesUpstreamDefinitionsAndRef(t *testing.T) { + spec := apiv1.APIConfigData{ + Context: "/hello", + DisplayName: "hello", + Version: "v1.0", + Operations: []apiv1.Operation{ + {Method: apiv1.OperationMethodGET, Path: "/"}, + }, + UpstreamDefinitions: []apiv1.UpstreamDefinition{{ + Name: "hello-backend", + Timeout: &apiv1.UpstreamTimeout{Connect: stringPtr("6s")}, + Upstreams: []apiv1.UpstreamTarget{{Url: "http://hello.default.svc.cluster.local:9080"}}, + }}, + Upstream: apiv1.UpstreamConfig{ + Main: apiv1.Upstream{Ref: stringPtr("hello-backend")}, + }, + } + md := RestAPIPayloadMetadata{Name: "api-handle"} + + b, err := BuildRestAPIYAML(apiv1.GroupVersion.String(), "RestApi", md, spec) + require.NoError(t, err) + + var got map[string]interface{} + require.NoError(t, yamlv3.Unmarshal(b, &got)) + specMap, ok := got["spec"].(map[string]interface{}) + require.True(t, ok) + + // upstream.main.ref survives; url is omitted when ref is used. + upstream := specMap["upstream"].(map[string]interface{}) + main := upstream["main"].(map[string]interface{}) + require.Equal(t, "hello-backend", main["ref"]) + _, hasURL := main["url"] + require.False(t, hasURL, "url should be omitted when ref is set") + + // upstreamDefinitions survive with the connect timeout. + defs, ok := specMap["upstreamDefinitions"].([]interface{}) + require.True(t, ok) + require.Len(t, defs, 1) + def0 := defs[0].(map[string]interface{}) + require.Equal(t, "hello-backend", def0["name"]) + timeout := def0["timeout"].(map[string]interface{}) + require.Equal(t, "6s", timeout["connect"]) +} diff --git a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml index c38f002ed5..7d00ac6dc9 100644 --- a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml +++ b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml @@ -252,9 +252,9 @@ spec: connect timeout) that upstream.ref can reference. items: description: |- - UpstreamDefinition is a reusable upstream configuration with an optional connect - timeout and load-balancing targets. Referenced from an upstream via its `ref` field. - Mirrors the management-API UpstreamDefinition schema. + UpstreamDefinition is a reusable upstream configuration with an optional connect timeout and + load-balancing targets. Referenced from an upstream via its `ref` field. Shared by RestApi, + LLM Provider, and MCP; mirrors the management-API UpstreamDefinition schema. properties: basePath: description: BasePath Base path prefix prepended to all requests diff --git a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml index 6b9c546506..94d647bb16 100644 --- a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml +++ b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml @@ -276,9 +276,9 @@ spec: connect timeout) that upstream.ref can reference. items: description: |- - UpstreamDefinition is a reusable upstream configuration with an optional connect - timeout and load-balancing targets. Referenced from an upstream via its `ref` field. - Mirrors the management-API UpstreamDefinition schema. + UpstreamDefinition is a reusable upstream configuration with an optional connect timeout and + load-balancing targets. Referenced from an upstream via its `ref` field. Shared by RestApi, + LLM Provider, and MCP; mirrors the management-API UpstreamDefinition schema. properties: basePath: description: BasePath Base path prefix prepended to all requests diff --git a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_restapis.yaml b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_restapis.yaml index 4e62b044a0..c51908a9af 100644 --- a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_restapis.yaml +++ b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_restapis.yaml @@ -169,29 +169,98 @@ spec: description: Main Upstream backend configuration for production traffic properties: + ref: + description: Ref Name of a predefined upstreamDefinition to + route through. + type: string url: - description: Url Backend service URL (may include path prefix - like /api/v2) + description: Url Direct backend service URL (may include path + prefix like /api/v2) pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ type: string - required: - - url type: object + x-kubernetes-validations: + - message: exactly one of url or ref must be set + rule: has(self.url) != has(self.ref) sandbox: description: Sandbox Upstream backend configuration for sandbox/testing traffic properties: + ref: + description: Ref Name of a predefined upstreamDefinition to + route through. + type: string url: - description: Url Backend service URL (may include path prefix - like /api/v2) + description: Url Direct backend service URL (may include path + prefix like /api/v2) pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ type: string - required: - - url type: object + x-kubernetes-validations: + - message: exactly one of url or ref must be set + rule: has(self.url) != has(self.ref) required: - main type: object + upstreamDefinitions: + description: |- + UpstreamDefinitions is the list of reusable upstream definitions (with optional connect + timeout) that upstream.ref can reference. + items: + description: |- + UpstreamDefinition is a reusable upstream configuration with an optional connect timeout and + load-balancing targets. Referenced from an upstream via its `ref` field. Shared by RestApi, + LLM Provider, and MCP; mirrors the management-API UpstreamDefinition schema. + properties: + basePath: + description: BasePath Base path prefix prepended to all requests + routed through this upstream (e.g. /api/v2). + type: string + name: + description: Name Unique identifier for this upstream definition + (referenced by upstream.ref). + maxLength: 100 + minLength: 1 + pattern: ^[a-zA-Z0-9\-_]+$ + type: string + timeout: + description: Timeout Optional timeout configuration for this + upstream (connect timeout). + properties: + connect: + description: Connect Connection-establishment timeout duration + (e.g. "5s", "500ms"). "0s" disables. + pattern: ^\d+(\.\d+)?(ms|s|m|h)$ + type: string + type: object + upstreams: + description: Upstreams List of backend targets with optional + weights for load balancing. + items: + description: UpstreamTarget is a single backend target within + an UpstreamDefinition. + properties: + url: + description: Url Backend URL (host and port only; path + comes from the definition's basePath). + pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ + type: string + weight: + description: Weight Weight for load balancing (optional, + default 100). + maximum: 100 + minimum: 0 + type: integer + required: + - url + type: object + minItems: 1 + type: array + required: + - name + - upstreams + type: object + type: array version: description: Version Semantic version of the API pattern: ^v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ From 5921025b2ca7a43315ecd7759dba961571754636 Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Wed, 8 Jul 2026 09:43:54 +0530 Subject: [PATCH 13/14] Address coderabbit issues --- .../api/management-openapi.yaml | 5 +- .../pkg/api/management/generated.go | 565 +++++++++--------- .../pkg/config/api_validator.go | 15 + .../pkg/config/llm_validator_test.go | 21 + .../api/v1alpha1/restapi_types.go | 5 +- ...ay.api-platform.wso2.com_llmproviders.yaml | 11 +- .../gateway.api-platform.wso2.com_mcps.yaml | 11 +- ...ateway.api-platform.wso2.com_restapis.yaml | 11 +- ...ay.api-platform.wso2.com_llmproviders.yaml | 11 +- .../gateway.api-platform.wso2.com_mcps.yaml | 11 +- ...ateway.api-platform.wso2.com_restapis.yaml | 11 +- 11 files changed, 368 insertions(+), 309 deletions(-) diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index f062f7a3fb..d2ac71b56d 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -4225,7 +4225,8 @@ components: example: my-upstream-1 basePath: type: string - description: Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. + description: Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. Must start with '/' and must not end with '/'; omit for root. + pattern: '^/[a-zA-Z0-9\-._~!$&''()*+,;=:@%/]*[^/]$' example: /api/v2 timeout: $ref: "#/components/schemas/UpstreamTimeout" @@ -4245,7 +4246,7 @@ components: example: http://prod-backend-1:5000 weight: type: integer - description: Weight for load balancing (optional, default 100) + description: Relative weight for load balancing across multiple upstream targets. Reserved for future multi-target load balancing; not applied yet (only the first target is currently used). minimum: 0 maximum: 100 example: 80 diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index 6d973b27e1..e0f188b1a3 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -1642,7 +1642,7 @@ type UpstreamAuthAuthType string // UpstreamDefinition Reusable upstream configuration with optional timeout and load balancing settings type UpstreamDefinition struct { - // BasePath Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. + // BasePath Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. Must start with '/' and must not end with '/'; omit for root. BasePath *string `json:"basePath,omitempty" yaml:"basePath,omitempty"` // Name Unique identifier for this upstream definition @@ -1656,7 +1656,7 @@ type UpstreamDefinition struct { // Url Backend URL (host and port only, path comes from basePath) Url string `json:"url" yaml:"url"` - // Weight Weight for load balancing (optional, default 100) + // Weight Relative weight for load balancing across multiple upstream targets. Reserved for future multi-target load balancing; not applied yet (only the first target is currently used). Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` } `json:"upstreams" yaml:"upstreams"` } @@ -5792,286 +5792,287 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "AA==", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/config/api_validator.go b/gateway/gateway-controller/pkg/config/api_validator.go index f47b284178..7b11f5c184 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -275,6 +275,12 @@ func (v *APIValidator) validateUpstreamDefinitions(definitions *[]api.UpstreamDe // name that CRD admission would reject. The name is also used for Envoy cluster naming. var upstreamDefinitionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`) +// upstreamBasePathRegex enforces the same basePath constraint as the CRD/OpenAPI: it must start with +// "/" and must not end with "/" (root is expressed by omitting the field). basePath is prepended to +// the upstream path during routing, so a malformed value (missing leading slash, trailing slash) +// would silently produce a bad upstream request path — this rejects it at deploy time instead. +var upstreamBasePathRegex = regexp.MustCompile(`^/[a-zA-Z0-9\-._~!$&'()*+,;=:@%/]*[^/]$`) + // validateUpstreamDefinitionsList validates an upstreamDefinitions array. fieldPrefix is the path // to the array (e.g. "spec.upstreamDefinitions"). It is shared by the RestApi, LLM Provider, and // MCP validators so the three kinds validate upstream definitions identically. The connect timeout @@ -322,6 +328,15 @@ func validateUpstreamDefinitionsList(fieldPrefix string, definitions *[]api.Upst } namesSeen[def.Name] = true + // Validate basePath (when set) against the CRD/OpenAPI pattern. Validated raw (no trim) so it + // matches CRD admission exactly; omit the field for root. + if def.BasePath != nil && !upstreamBasePathRegex.MatchString(*def.BasePath) { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s[%d].basePath", fieldPrefix, i), + Message: "Invalid basePath (must start with '/' and must not end with '/'; omit for root)", + }) + } + // Validate upstreams array if len(def.Upstreams) == 0 { errors = append(errors, ValidationError{ diff --git a/gateway/gateway-controller/pkg/config/llm_validator_test.go b/gateway/gateway-controller/pkg/config/llm_validator_test.go index ec233ce42d..86b5d9f2c1 100644 --- a/gateway/gateway-controller/pkg/config/llm_validator_test.go +++ b/gateway/gateway-controller/pkg/config/llm_validator_test.go @@ -2089,6 +2089,27 @@ func TestValidateLLMProvider_UpstreamRef(t *testing.T) { assert.Empty(t, errs) }) + t.Run("valid basePath accepted", func(t *testing.T) { + def := upstreamDef("openai-backend", "6s") + def.BasePath = stringPtr("/api/v2") + errs := validator.Validate(providerWithUpstream(&[]api.UpstreamDefinition{def}, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) + assert.Empty(t, errs) + }) + + t.Run("basePath without leading slash rejected", func(t *testing.T) { + def := upstreamDef("openai-backend", "6s") + def.BasePath = stringPtr("api/v2") + errs := validator.Validate(providerWithUpstream(&[]api.UpstreamDefinition{def}, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) + assertHasFieldError(t, errs, "spec.upstreamDefinitions[0].basePath") + }) + + t.Run("basePath with trailing slash rejected", func(t *testing.T) { + def := upstreamDef("openai-backend", "6s") + def.BasePath = stringPtr("/api/v2/") + errs := validator.Validate(providerWithUpstream(&[]api.UpstreamDefinition{def}, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) + assertHasFieldError(t, errs, "spec.upstreamDefinitions[0].basePath") + }) + t.Run("connect timeout that overflows time.Duration rejected", func(t *testing.T) { defs := &[]api.UpstreamDefinition{upstreamDef("openai-backend", "99999999999999999999s")} errs := validator.Validate(providerWithUpstream(defs, api.LLMProviderConfigData_Upstream{Ref: stringPtr("openai-backend")})) diff --git a/kubernetes/gateway-operator/api/v1alpha1/restapi_types.go b/kubernetes/gateway-operator/api/v1alpha1/restapi_types.go index b2edd6c0ef..220d2f97c1 100644 --- a/kubernetes/gateway-operator/api/v1alpha1/restapi_types.go +++ b/kubernetes/gateway-operator/api/v1alpha1/restapi_types.go @@ -225,7 +225,9 @@ type UpstreamDefinition struct { Name string `json:"name"` // BasePath Base path prefix prepended to all requests routed through this upstream (e.g. /api/v2). + // Must start with "/" and must not end with "/". Omit for root ("/"). // +optional + // +kubebuilder:validation:Pattern=`^/[a-zA-Z0-9\-._~!$&'()*+,;=:@%/]*[^/]$` BasePath *string `json:"basePath,omitempty"` // Timeout Optional timeout configuration for this upstream (connect timeout). @@ -254,7 +256,8 @@ type UpstreamTarget struct { // +kubebuilder:validation:Pattern=`^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$` Url string `json:"url"` - // Weight Weight for load balancing (optional, default 100). + // Weight Relative weight for load balancing across multiple upstream targets. Reserved for + // future multi-target load balancing; not applied yet (only the first target is currently used). // +optional // +kubebuilder:validation:Minimum=0 // +kubebuilder:validation:Maximum=100 diff --git a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml index 7d00ac6dc9..3cc7e1366c 100644 --- a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml +++ b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_llmproviders.yaml @@ -257,8 +257,10 @@ spec: LLM Provider, and MCP; mirrors the management-API UpstreamDefinition schema. properties: basePath: - description: BasePath Base path prefix prepended to all requests - routed through this upstream (e.g. /api/v2). + description: |- + BasePath Base path prefix prepended to all requests routed through this upstream (e.g. /api/v2). + Must start with "/" and must not end with "/". Omit for root ("/"). + pattern: ^/[a-zA-Z0-9\-._~!$&'()*+,;=:@%/]*[^/]$ type: string name: description: Name Unique identifier for this upstream definition @@ -290,8 +292,9 @@ spec: pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ type: string weight: - description: Weight Weight for load balancing (optional, - default 100). + description: |- + Weight Relative weight for load balancing across multiple upstream targets. Reserved for + future multi-target load balancing; not applied yet (only the first target is currently used). maximum: 100 minimum: 0 type: integer diff --git a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml index 94d647bb16..096fac435a 100644 --- a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml +++ b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_mcps.yaml @@ -281,8 +281,10 @@ spec: LLM Provider, and MCP; mirrors the management-API UpstreamDefinition schema. properties: basePath: - description: BasePath Base path prefix prepended to all requests - routed through this upstream (e.g. /api/v2). + description: |- + BasePath Base path prefix prepended to all requests routed through this upstream (e.g. /api/v2). + Must start with "/" and must not end with "/". Omit for root ("/"). + pattern: ^/[a-zA-Z0-9\-._~!$&'()*+,;=:@%/]*[^/]$ type: string name: description: Name Unique identifier for this upstream definition @@ -314,8 +316,9 @@ spec: pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ type: string weight: - description: Weight Weight for load balancing (optional, - default 100). + description: |- + Weight Relative weight for load balancing across multiple upstream targets. Reserved for + future multi-target load balancing; not applied yet (only the first target is currently used). maximum: 100 minimum: 0 type: integer diff --git a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_restapis.yaml b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_restapis.yaml index c51908a9af..1193084898 100644 --- a/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_restapis.yaml +++ b/kubernetes/gateway-operator/config/crd/bases/gateway.api-platform.wso2.com_restapis.yaml @@ -213,8 +213,10 @@ spec: LLM Provider, and MCP; mirrors the management-API UpstreamDefinition schema. properties: basePath: - description: BasePath Base path prefix prepended to all requests - routed through this upstream (e.g. /api/v2). + description: |- + BasePath Base path prefix prepended to all requests routed through this upstream (e.g. /api/v2). + Must start with "/" and must not end with "/". Omit for root ("/"). + pattern: ^/[a-zA-Z0-9\-._~!$&'()*+,;=:@%/]*[^/]$ type: string name: description: Name Unique identifier for this upstream definition @@ -246,8 +248,9 @@ spec: pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ type: string weight: - description: Weight Weight for load balancing (optional, - default 100). + description: |- + Weight Relative weight for load balancing across multiple upstream targets. Reserved for + future multi-target load balancing; not applied yet (only the first target is currently used). maximum: 100 minimum: 0 type: integer diff --git a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml index 7d00ac6dc9..3cc7e1366c 100644 --- a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml +++ b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_llmproviders.yaml @@ -257,8 +257,10 @@ spec: LLM Provider, and MCP; mirrors the management-API UpstreamDefinition schema. properties: basePath: - description: BasePath Base path prefix prepended to all requests - routed through this upstream (e.g. /api/v2). + description: |- + BasePath Base path prefix prepended to all requests routed through this upstream (e.g. /api/v2). + Must start with "/" and must not end with "/". Omit for root ("/"). + pattern: ^/[a-zA-Z0-9\-._~!$&'()*+,;=:@%/]*[^/]$ type: string name: description: Name Unique identifier for this upstream definition @@ -290,8 +292,9 @@ spec: pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ type: string weight: - description: Weight Weight for load balancing (optional, - default 100). + description: |- + Weight Relative weight for load balancing across multiple upstream targets. Reserved for + future multi-target load balancing; not applied yet (only the first target is currently used). maximum: 100 minimum: 0 type: integer diff --git a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml index 94d647bb16..096fac435a 100644 --- a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml +++ b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_mcps.yaml @@ -281,8 +281,10 @@ spec: LLM Provider, and MCP; mirrors the management-API UpstreamDefinition schema. properties: basePath: - description: BasePath Base path prefix prepended to all requests - routed through this upstream (e.g. /api/v2). + description: |- + BasePath Base path prefix prepended to all requests routed through this upstream (e.g. /api/v2). + Must start with "/" and must not end with "/". Omit for root ("/"). + pattern: ^/[a-zA-Z0-9\-._~!$&'()*+,;=:@%/]*[^/]$ type: string name: description: Name Unique identifier for this upstream definition @@ -314,8 +316,9 @@ spec: pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ type: string weight: - description: Weight Weight for load balancing (optional, - default 100). + description: |- + Weight Relative weight for load balancing across multiple upstream targets. Reserved for + future multi-target load balancing; not applied yet (only the first target is currently used). maximum: 100 minimum: 0 type: integer diff --git a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_restapis.yaml b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_restapis.yaml index c51908a9af..1193084898 100644 --- a/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_restapis.yaml +++ b/kubernetes/helm/operator-helm-chart/crds/gateway.api-platform.wso2.com_restapis.yaml @@ -213,8 +213,10 @@ spec: LLM Provider, and MCP; mirrors the management-API UpstreamDefinition schema. properties: basePath: - description: BasePath Base path prefix prepended to all requests - routed through this upstream (e.g. /api/v2). + description: |- + BasePath Base path prefix prepended to all requests routed through this upstream (e.g. /api/v2). + Must start with "/" and must not end with "/". Omit for root ("/"). + pattern: ^/[a-zA-Z0-9\-._~!$&'()*+,;=:@%/]*[^/]$ type: string name: description: Name Unique identifier for this upstream definition @@ -246,8 +248,9 @@ spec: pattern: ^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$ type: string weight: - description: Weight Weight for load balancing (optional, - default 100). + description: |- + Weight Relative weight for load balancing across multiple upstream targets. Reserved for + future multi-target load balancing; not applied yet (only the first target is currently used). maximum: 100 minimum: 0 type: integer From 86b72180743f8eef57a515728177cb1cc7582a8b Mon Sep 17 00:00:00 2001 From: Osura Viduranga Date: Fri, 10 Jul 2026 07:35:15 +0530 Subject: [PATCH 14/14] Adding new timeout and missing config blocks to helm chart --- gateway/build-manifest.yaml | 2 +- .../templates/gateway/gateway-config.yaml | 10 ++++++++++ .../helm/gateway-helm-chart/values.yaml | 20 ++++++++++++++++++- .../helm/operator-helm-chart/values.yaml | 18 +++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/gateway/build-manifest.yaml b/gateway/build-manifest.yaml index 257736d24f..3865a3d92d 100644 --- a/gateway/build-manifest.yaml +++ b/gateway/build-manifest.yaml @@ -61,7 +61,7 @@ policies: version: v1.0.2 gomodule: github.com/wso2/gateway-controllers/policies/mcp-acl-list@v1 - name: mcp-auth - version: v1.0.3 + version: v1.1.0 gomodule: github.com/wso2/gateway-controllers/policies/mcp-auth@v1 - name: mcp-authz version: v1.0.1 diff --git a/kubernetes/helm/gateway-helm-chart/templates/gateway/gateway-config.yaml b/kubernetes/helm/gateway-helm-chart/templates/gateway/gateway-config.yaml index 1827bcdf94..52e0f0f898 100644 --- a/kubernetes/helm/gateway-helm-chart/templates/gateway/gateway-config.yaml +++ b/kubernetes/helm/gateway-helm-chart/templates/gateway/gateway-config.yaml @@ -143,6 +143,16 @@ data: route_idle_timeout_ms = {{ $router.upstream.timeouts.route_idle_timeout_ms }} connect_timeout_ms = {{ $router.upstream.timeouts.connect_timeout_ms }} + [router.http_listener] + server_header_transformation = {{ $router.http_listener.server_header_transformation | quote }} + server_header_value = {{ $router.http_listener.server_header_value | quote }} + + [router.http_listener.timeouts] + request_timeout = {{ $router.http_listener.timeouts.request_timeout | quote }} + request_headers_timeout = {{ $router.http_listener.timeouts.request_headers_timeout | quote }} + stream_idle_timeout = {{ $router.http_listener.timeouts.stream_idle_timeout | quote }} + idle_timeout = {{ $router.http_listener.timeouts.idle_timeout | quote }} + [router.policy_engine] mode = {{ $router.policy_engine.mode | quote }} host = {{ $router.policy_engine.host | default "" | quote }} diff --git a/kubernetes/helm/gateway-helm-chart/values.yaml b/kubernetes/helm/gateway-helm-chart/values.yaml index 467ad69fea..7666fcb89f 100644 --- a/kubernetes/helm/gateway-helm-chart/values.yaml +++ b/kubernetes/helm/gateway-helm-chart/values.yaml @@ -313,7 +313,25 @@ gateway: route_timeout_ms: 60000 route_idle_timeout_ms: 300000 connect_timeout_ms: 5000 - + + # HTTP Connection Manager (downstream) configuration: server-header handling and timeouts + http_listener: + # Server header handling: APPEND_IF_ABSENT | OVERWRITE | PASS_THROUGH + server_header_transformation: OVERWRITE + # Value written for the Server response header + server_header_value: "WSO2 API Platform" + + # Downstream (client-facing) timeouts. A value of "0s" disables the timeout. + timeouts: + # Max duration for the entire downstream request (0s = disabled) + request_timeout: "0s" + # Max duration to receive the complete request headers (0s = disabled) + request_headers_timeout: "0s" + # Idle timeout for a single HTTP stream/request + stream_idle_timeout: "5m" + # Idle timeout for the downstream connection + idle_timeout: "1h" + tracing_service_name: router # Policy Engine ext_proc filter configuration diff --git a/kubernetes/helm/operator-helm-chart/values.yaml b/kubernetes/helm/operator-helm-chart/values.yaml index c0cf8f6689..9c132a2814 100644 --- a/kubernetes/helm/operator-helm-chart/values.yaml +++ b/kubernetes/helm/operator-helm-chart/values.yaml @@ -294,6 +294,24 @@ gateway: route_idle_timeout_ms: 300000 connect_timeout_ms: 5000 + # HTTP Connection Manager (downstream) configuration: server-header handling and timeouts + http_listener: + # Server header handling: APPEND_IF_ABSENT | OVERWRITE | PASS_THROUGH + server_header_transformation: OVERWRITE + # Value written for the Server response header + server_header_value: "WSO2 API Platform" + + # Downstream (client-facing) timeouts. A value of "0s" disables the timeout. + timeouts: + # Max duration for the entire downstream request (0s = disabled) + request_timeout: "0s" + # Max duration to receive the complete request headers (0s = disabled) + request_headers_timeout: "0s" + # Idle timeout for a single HTTP stream/request + stream_idle_timeout: "5m" + # Idle timeout for the downstream connection + idle_timeout: "1h" + # Policy Engine ext_proc filter configuration policy_engine: # Connection mode: "uds" (Unix domain socket, default) or "tcp"