diff --git a/gateway/gateway-controller/go.mod b/gateway/gateway-controller/go.mod index 0b106c49ea..502a8d6bbd 100644 --- a/gateway/gateway-controller/go.mod +++ b/gateway/gateway-controller/go.mod @@ -23,7 +23,7 @@ require ( github.com/prometheus/client_model v0.6.2 github.com/stretchr/testify v1.11.1 github.com/wso2/api-platform/common v0.0.0 - github.com/wso2/api-platform/sdk/core v0.2.12 + github.com/wso2/api-platform/sdk/core v0.2.18 github.com/wso2/go-httpkit v0.0.0-local github.com/xeipuuv/gojsonschema v1.2.0 google.golang.org/grpc v1.79.3 diff --git a/gateway/gateway-controller/go.sum b/gateway/gateway-controller/go.sum index 14477f3742..00ef3bd0dc 100644 --- a/gateway/gateway-controller/go.sum +++ b/gateway/gateway-controller/go.sum @@ -160,8 +160,8 @@ github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4d github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= -github.com/wso2/api-platform/sdk/core v0.2.12 h1:todO77VOlxw8bWniFK/GyEbuM1R5ELnULgR+37Xdrak= -github.com/wso2/api-platform/sdk/core v0.2.12/go.mod h1:vgNVzR16g9k5cun3VXZ7wDg8UGbPxsVU2TW8EbRCv0o= +github.com/wso2/api-platform/sdk/core v0.2.18 h1:j6UhHjGBa9qCxqbT4p8cwLUKkQVtpvoMTMsjs1DfYR0= +github.com/wso2/api-platform/sdk/core v0.2.18/go.mod h1:NZMDIQadQDpbynlCLYdZT6duiHwnf7emr7SbLHdCjaE= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= diff --git a/gateway/gateway-controller/pkg/models/runtime_deploy_config.go b/gateway/gateway-controller/pkg/models/runtime_deploy_config.go index 24c2ea1355..0b2640ecfe 100644 --- a/gateway/gateway-controller/pkg/models/runtime_deploy_config.go +++ b/gateway/gateway-controller/pkg/models/runtime_deploy_config.go @@ -18,7 +18,11 @@ package models -import "time" +import ( + "time" + + policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" +) // RuntimeDeployConfig is the kind-agnostic intermediate representation produced by // each transformer (RestAPI, LLM Provider, LLM Proxy). Both the Envoy xDS translator @@ -90,6 +94,12 @@ type RouteUpstream struct { ClusterKey string // key into UpstreamClusters map UseClusterHeader bool // if true, policy selects upstream dynamically DefaultCluster string // default cluster name when UseClusterHeader is true + + // Default is this route's own compiled-in upstream (cluster name, URL, base + // path) — whichever slot this route belongs to (main or sandbox). Exposed to + // the policy engine as the route's single default upstream field, regardless + // of which slot it is. + Default *policyenginev1.UpstreamInfo } // PolicyChain is an ordered list of policies for a route. diff --git a/gateway/gateway-controller/pkg/policyxds/snapshot.go b/gateway/gateway-controller/pkg/policyxds/snapshot.go index 3aa5fc8759..b3b0b92b3f 100644 --- a/gateway/gateway-controller/pkg/policyxds/snapshot.go +++ b/gateway/gateway-controller/pkg/policyxds/snapshot.go @@ -349,6 +349,12 @@ func (t *Translator) createRouteConfigResource( data["default_upstream_cluster"] = route.Upstream.DefaultCluster } + // This route's own compiled-in upstream (whichever slot it belongs to) — a single, + // always-present field for the policy engine, regardless of main/sandbox. + if route.Upstream.Default != nil { + data["default_upstream"] = route.Upstream.Default.ToMap() + } + return toAnyResource(data, RouteConfigTypeURL) } diff --git a/gateway/gateway-controller/pkg/transform/restapi.go b/gateway/gateway-controller/pkg/transform/restapi.go index 4e4efdee8c..c2e75bace8 100644 --- a/gateway/gateway-controller/pkg/transform/restapi.go +++ b/gateway/gateway-controller/pkg/transform/restapi.go @@ -128,9 +128,22 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim if err != nil { return nil, fmt.Errorf("failed to resolve main upstream: %w", err) } + mainUpstreamInfo := mainUpstream.UpstreamInfo() - // Check if dynamic cluster selection should be used - useClusterHeader := apiData.UpstreamDefinitions != nil && len(*apiData.UpstreamDefinitions) > 0 + // Determine vhosts to create routes for. + // Sandbox is active when a sandbox upstream is configured via either url or ref. + hasSandbox := apiData.Upstream.Sandbox != nil && + ((apiData.Upstream.Sandbox.Url != nil && strings.TrimSpace(*apiData.Upstream.Sandbox.Url) != "") || + (apiData.Upstream.Sandbox.Ref != nil && strings.TrimSpace(*apiData.Upstream.Sandbox.Ref) != "")) + + // Check if dynamic cluster selection should be used. Enabled whenever the API has named + // upstream definitions (so a policy can select one) OR a sandbox upstream (so a policy can + // redirect between the API's own main/sandbox slots). Must mirror pkg/xds/translator.go's + // useClusterHeader computation exactly — that's what determines whether Envoy's route uses + // cluster_header routing; if this RDC (which feeds the policy engine's default-cluster + // fallback) disagrees, Envoy expects a header the policy engine never sets. + hasUpstreamDefinitions := apiData.UpstreamDefinitions != nil && len(*apiData.UpstreamDefinitions) > 0 + useClusterHeader := hasUpstreamDefinitions || hasSandbox defaultCluster := "" if useClusterHeader { // The default cluster must be the name Envoy actually knows the cluster by. @@ -147,12 +160,6 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim mainAutoHostRewrite = false } - // Determine vhosts to create routes for. - // Sandbox is active when a sandbox upstream is configured via either url or ref. - hasSandbox := apiData.Upstream.Sandbox != nil && - ((apiData.Upstream.Sandbox.Url != nil && strings.TrimSpace(*apiData.Upstream.Sandbox.Url) != "") || - (apiData.Upstream.Sandbox.Ref != nil && strings.TrimSpace(*apiData.Upstream.Sandbox.Ref) != "")) - // Guard: sandbox and main vhosts must differ, otherwise sandbox routes would // overwrite main routes (same route key) and the sandbox patch would leave only // a sandbox-cluster route with no main-cluster route at all. @@ -199,6 +206,10 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim for _, vhost := range vhosts { routeKey := xds.GenerateRouteNameWithDiscriminator(method, apiData.Context, apiData.Version, opPath, vhost, discriminator) + // Build route. Default is this route's own upstream (main's, until the sandbox + // patch below overwrites it for sandbox-vhost routes) — the single field exposed + // to the policy engine as the route's compiled-in upstream, regardless of slot. + routeMainInfo := mainUpstreamInfo rdcRoute := &models.Route{ Method: method, Path: xds.ConstructFullPath(apiData.Context, apiData.Version, opPath), @@ -213,6 +224,7 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim ClusterKey: mainUpstream.ClusterKey, UseClusterHeader: useClusterHeader, DefaultCluster: defaultCluster, + Default: &routeMainInfo, }, } rdc.Routes[routeKey] = rdcRoute @@ -280,6 +292,7 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim if err != nil { return nil, fmt.Errorf("failed to resolve sandbox upstream: %w", err) } + sbUpstreamInfo := sbUpstream.UpstreamInfo() sbAutoHostRewrite := true if apiData.Upstream.Sandbox.HostRewrite != nil && *apiData.Upstream.Sandbox.HostRewrite == api.Manual { @@ -304,6 +317,10 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim } else { r.Upstream.DefaultCluster = "" } + // This route belongs to the sandbox slot — its own default upstream is + // the sandbox's, not main's. + routeSbInfo := sbUpstreamInfo + r.Upstream.Default = &routeSbInfo r.AutoHostRewrite = sbAutoHostRewrite } } @@ -434,6 +451,18 @@ type upstreamClusterResult struct { EnvoyClusterName string // BasePath is the URL path component of the upstream (e.g. "/anything/foo"). BasePath string + // URL is the resolved upstream origin (scheme://host[:port], no path — see BasePath). + URL string +} + +// UpstreamInfo converts the resolved cluster result into the shared wire shape +// carried to the policy engine (sdk/core/policyengine.UpstreamInfo). +func (r *upstreamClusterResult) UpstreamInfo() policyenginev1.UpstreamInfo { + return policyenginev1.UpstreamInfo{ + ClusterName: r.EnvoyClusterName, + URL: r.URL, + BasePath: r.BasePath, + } } // addUpstreamCluster resolves an upstream and adds it to the RuntimeDeployConfig. @@ -482,6 +511,7 @@ func (t *RestAPITransformer) addUpstreamCluster( ClusterKey: clusterKey, EnvoyClusterName: sanitizeEnvoyClusterName(parsedURL.Host, parsedURL.Scheme), BasePath: basePath, + URL: fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host), }, nil } diff --git a/gateway/gateway-controller/pkg/transform/restapi_test.go b/gateway/gateway-controller/pkg/transform/restapi_test.go index 1cec8dc230..47156f6305 100644 --- a/gateway/gateway-controller/pkg/transform/restapi_test.go +++ b/gateway/gateway-controller/pkg/transform/restapi_test.go @@ -421,9 +421,12 @@ func TestResolvePort(t *testing.T) { } // TestRestAPITransformer_SandboxRouteClusterHeader pins the sandbox route's dynamic -// cluster selection: with upstreamDefinitions the sandbox route uses cluster_header -// routing (so a dynamic-endpoint policy can divert sandbox traffic) and defaults to the -// sandbox cluster; without them it stays a static sandbox route. +// cluster selection: whenever a sandbox upstream is configured — with or without +// upstreamDefinitions — the sandbox route uses cluster_header routing (so a +// dynamic-endpoint policy can divert sandbox traffic) and defaults to the sandbox +// cluster. This must mirror pkg/xds/translator.go's useClusterHeader computation, +// which Envoy's actual route uses; a mismatch leaves Envoy expecting a +// x-target-upstream header the policy engine never sets. func TestRestAPITransformer_SandboxRouteClusterHeader(t *testing.T) { defs := map[string]models.PolicyDefinition{} const sandboxURL = "http://sandbox-backend:9080/sandbox" @@ -433,7 +436,7 @@ func TestRestAPITransformer_SandboxRouteClusterHeader(t *testing.T) { // "upstream_sandbox__" — not the sanitized "cluster__" form. const expectedSandboxCluster = "upstream_sandbox_sandbox-backend_9080" - t.Run("without upstreamDefinitions the sandbox route is static", func(t *testing.T) { + t.Run("without upstreamDefinitions the sandbox route still uses cluster_header defaulting to the sandbox cluster", func(t *testing.T) { transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, defs) cfg := makeRestAPIStoredConfig(nil, nil) restAPI := cfg.Configuration.(api.RestAPI) @@ -444,8 +447,9 @@ func TestRestAPITransformer_SandboxRouteClusterHeader(t *testing.T) { require.NoError(t, err) r, exists := rdc.Routes[sandboxRouteKey] require.True(t, exists, "sandbox route should exist") - assert.False(t, r.Upstream.UseClusterHeader) - assert.Equal(t, "", r.Upstream.DefaultCluster) + assert.True(t, r.Upstream.UseClusterHeader) + assert.Equal(t, expectedSandboxCluster, r.Upstream.DefaultCluster, + "sandbox route must default to the sandbox cluster, not main") }) t.Run("with upstreamDefinitions the sandbox route uses cluster_header defaulting to the sandbox cluster", func(t *testing.T) { diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index 72b4e74bfb..abcb475f7c 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -1088,11 +1088,18 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* 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 + // Determine if dynamic cluster selection should be used. Enabled whenever the API has + // named upstream definitions (so a policy can select one) OR a sandbox upstream (so a + // policy can redirect between the API's own main/sandbox slots). Sandbox activity here + // mirrors restapi.go's Url/Ref-aware hasSandbox check, not the looser nil check below + // (which only gates whether the sandbox cluster itself gets built). + hasSandboxForClusterHeader := apiData.Upstream.Sandbox != nil && + ((apiData.Upstream.Sandbox.Url != nil && strings.TrimSpace(*apiData.Upstream.Sandbox.Url) != "") || + (apiData.Upstream.Sandbox.Ref != nil && strings.TrimSpace(*apiData.Upstream.Sandbox.Ref) != "")) + hasUpstreamDefinitions := apiData.UpstreamDefinitions != nil && len(*apiData.UpstreamDefinitions) > 0 + useClusterHeader := hasUpstreamDefinitions || hasSandboxForClusterHeader + 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.EffectiveMethod(), op.EffectivePath(), err) @@ -1121,10 +1128,9 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* sandboxCluster := t.createCluster(sbClusterName, parsedSbURL, nil, sbUpstreamClusterConnectTimeout) clusters = append(clusters, sandboxCluster) - // Create sandbox routes. When upstreamDefinitions exist, enable dynamic cluster - // selection (mirrors main). + // Create sandbox routes. Mirrors main's useClusterHeader (dynamic cluster selection + // is on whenever upstreamDefinitions exist or a sandbox upstream is configured). 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 { @@ -1133,7 +1139,7 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* opTimeoutCfg := combineRouteResilience(sbTimeout, apiTimeout, apiIdleTimeout, opTimeout, opIdleTimeout) r := t.createRoute(cfg.UUID, apiData.DisplayName, apiData.Version, apiData.Context, op.EffectiveMethod(), op.EffectivePath(), - sbClusterName, parsedSbURL.Path, effectiveSandboxVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Sandbox.HostRewrite, apiProjectID, opTimeoutCfg, sbUseClusterHeader, upstreamDefPaths) + sbClusterName, parsedSbURL.Path, effectiveSandboxVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Sandbox.HostRewrite, apiProjectID, opTimeoutCfg, useClusterHeader, upstreamDefPaths) sbRoutesList = append(sbRoutesList, r) } routesList = append(routesList, sbRoutesList...) diff --git a/gateway/gateway-runtime/policy-engine/go.mod b/gateway/gateway-runtime/policy-engine/go.mod index f194b731e8..27b8cdb5a8 100644 --- a/gateway/gateway-runtime/policy-engine/go.mod +++ b/gateway/gateway-runtime/policy-engine/go.mod @@ -17,7 +17,7 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 github.com/wso2/api-platform/common v0.0.0-20260326194347-3d85c50eae71 - github.com/wso2/api-platform/sdk/core v0.2.15 + github.com/wso2/api-platform/sdk/core v0.2.18 go.opentelemetry.io/otel v1.41.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 go.opentelemetry.io/otel/sdk v1.41.0 diff --git a/gateway/gateway-runtime/policy-engine/go.sum b/gateway/gateway-runtime/policy-engine/go.sum index 0687af7ab9..daec49dcbc 100644 --- a/gateway/gateway-runtime/policy-engine/go.sum +++ b/gateway/gateway-runtime/policy-engine/go.sum @@ -94,8 +94,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/wso2/api-platform/sdk/core v0.2.15 h1:CfI4+uUuHsCU4d0FVnavVpORvL1WZEtREb/ZEktIsw0= -github.com/wso2/api-platform/sdk/core v0.2.15/go.mod h1:TgjpOk3QBPc7xEQC+NctWcNq5dSPBkn7wSKh+hULTj8= +github.com/wso2/api-platform/sdk/core v0.2.18 h1:j6UhHjGBa9qCxqbT4p8cwLUKkQVtpvoMTMsjs1DfYR0= +github.com/wso2/api-platform/sdk/core v0.2.18/go.mod h1:NZMDIQadQDpbynlCLYdZT6duiHwnf7emr7SbLHdCjaE= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/gateway/gateway-runtime/policy-engine/internal/admin/dumper.go b/gateway/gateway-runtime/policy-engine/internal/admin/dumper.go index 34ca2e1a1d..84c65bd486 100644 --- a/gateway/gateway-runtime/policy-engine/internal/admin/dumper.go +++ b/gateway/gateway-runtime/policy-engine/internal/admin/dumper.go @@ -101,6 +101,7 @@ func dumpRouteMetadata(k *kernel.Kernel) RouteMetadataDump { DefaultUpstreamCluster: cfg.Metadata.DefaultUpstreamCluster, UpstreamBasePath: cfg.Metadata.UpstreamBasePath, UpstreamDefinitionPaths: cfg.Metadata.UpstreamDefinitionPaths, + DefaultUpstream: cfg.Metadata.DefaultUpstream, }) } diff --git a/gateway/gateway-runtime/policy-engine/internal/admin/types.go b/gateway/gateway-runtime/policy-engine/internal/admin/types.go index 364218968e..b643c1f8b4 100644 --- a/gateway/gateway-runtime/policy-engine/internal/admin/types.go +++ b/gateway/gateway-runtime/policy-engine/internal/admin/types.go @@ -18,7 +18,11 @@ package admin -import "time" +import ( + "time" + + policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" +) // ConfigDumpResponse is the top-level response structure for the config_dump endpoint type ConfigDumpResponse struct { @@ -111,6 +115,10 @@ type RouteMetadataEntry struct { DefaultUpstreamCluster string `json:"default_upstream_cluster"` UpstreamBasePath string `json:"upstream_base_path"` UpstreamDefinitionPaths map[string]string `json:"upstream_definition_paths"` + + // DefaultUpstream is this route's own compiled-in upstream (whichever slot it + // belongs to). + DefaultUpstream *policyenginev1.UpstreamInfo `json:"default_upstream,omitempty"` } // PolicySpec contains specification for a policy instance diff --git a/gateway/gateway-runtime/policy-engine/internal/constants/constants.go b/gateway/gateway-runtime/policy-engine/internal/constants/constants.go index e05a04ff4e..5614f42ae3 100644 --- a/gateway/gateway-runtime/policy-engine/internal/constants/constants.go +++ b/gateway/gateway-runtime/policy-engine/internal/constants/constants.go @@ -39,6 +39,14 @@ const ( // Must match the gateway-controller constant UpstreamDefinitionClusterPrefix = "upstream_" + // CurrentUpstreamKey is the dynamic metadata key for the request's current effective + // upstream (an UpstreamInfo object: cluster_name/url/base_path). Always present — + // starts as the route's compiled-in default and is overwritten in place whenever a + // dynamic-routing policy (UpstreamName) redirects the request, so + // downstream consumers (Lua filter, analytics, response-phase processing) always see + // the request's actual destination. + CurrentUpstreamKey = "current_upstream" + // Policy Engine Socket Path (matches gateway-controller constant) DefaultPolicyEngineSocketPath = "/var/run/api-platform/policy-engine.sock" diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/execution_context.go b/gateway/gateway-runtime/policy-engine/internal/kernel/execution_context.go index 3870f08b06..6e109c5958 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/execution_context.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/execution_context.go @@ -32,6 +32,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/executor" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" policy "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" + policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" ) // maxStreamAccumulatorSize caps the amount of data accumulated before forcing @@ -94,6 +95,11 @@ type PolicyExecutionContext struct { // Used when UpstreamName is set to compute the correct path transformation. upstreamDefinitionPaths map[string]string + // defaultUpstream is this route's own compiled-in upstream (cluster name, URL, base + // path) — whichever slot it belongs to. Always present; surfaced to policies via the + // "current_upstream" dynamic metadata object when no dynamic override is in effect. + defaultUpstream *policyenginev1.UpstreamInfo + // requestContentEncoding stores the Content-Encoding of the incoming request (e.g. "gzip", "br"). // The body is decompressed before being passed to policies, and re-compressed using this value // before being forwarded to the upstream. @@ -1010,6 +1016,7 @@ func (ec *PolicyExecutionContext) buildRequestContexts(headers *extprocv3.HttpHe Authority: authority, Scheme: scheme, Vhost: routeMetadata.Vhost, + UpstreamInfo: ec.defaultUpstream, } // Build the streaming context once; reused across all chunks for this request. diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/extproc.go b/gateway/gateway-runtime/policy-engine/internal/kernel/extproc.go index 5d75ea6351..2c48aa8d16 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/extproc.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/extproc.go @@ -44,6 +44,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/executor" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/metrics" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/tracing" + policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" ) // ExternalProcessorServer implements the Envoy external processor service @@ -379,6 +380,7 @@ func (s *ExternalProcessorServer) initializeExecutionContext(ctx context.Context (*execCtx).upstreamBasePath = routeMetadata.UpstreamBasePath (*execCtx).apiContext = routeMetadata.Context (*execCtx).upstreamDefinitionPaths = routeMetadata.UpstreamDefinitionPaths + (*execCtx).defaultUpstream = routeMetadata.DefaultUpstream (*execCtx).buildRequestContexts(req.GetRequestHeaders(), routeMetadata) return &routeMetadata } @@ -456,6 +458,11 @@ type RouteMetadata struct { DefaultUpstreamCluster string // Default cluster for dynamic cluster routing UpstreamBasePath string // Base path for the upstream (e.g., /anything) UpstreamDefinitionPaths map[string]string // Maps upstream definition names to their URL base paths + + // DefaultUpstream is this route's own compiled-in upstream (cluster name, URL, base + // path) — whichever slot it belongs to (main or sandbox). Always present; surfaced + // to the policy engine as the route's single default upstream. + DefaultUpstream *policyenginev1.UpstreamInfo } // generateRequestID generates a unique request identifier diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/translator.go b/gateway/gateway-runtime/policy-engine/internal/kernel/translator.go index 94c751ae90..e6dd5d3976 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/translator.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/translator.go @@ -35,6 +35,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/executor" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" policy "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" + policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" ) // headerOp represents a single header operation (set, append, or remove) @@ -62,6 +63,138 @@ type Mutations struct { BodyMutation *extprocv3.BodyMutation } +// upstreamRedirectDirective captures the last dynamic-routing directive (a named upstream +// definition) set by any policy in a chain — last-write-wins across the chain. +type upstreamRedirectDirective struct { + name *string +} + +// set records name as the new live directive. Call once per policy result; a nil/empty name +// leaves it unchanged. +func (d *upstreamRedirectDirective) set(name *string) { + if name != nil && *name != "" { + d.name = name + } +} + +func (d upstreamRedirectDirective) isSet() bool { + return d.name != nil +} + +// resolveUpstreamRedirect resolves a directive into a concrete UpstreamInfo. ok=false means +// the directive could not be resolved — callers must leave routing untouched and fall back to +// the route's default, exactly as if no directive had been set. +func resolveUpstreamRedirect(execCtx *PolicyExecutionContext, d upstreamRedirectDirective) (info policyenginev1.UpstreamInfo, label string, basePathKnown bool, ok bool) { + if d.name != nil { + apiKind := execCtx.sharedCtx.APIKind + apiId := execCtx.sharedCtx.APIId + sanitizedDefName := sanitizeUpstreamDefinitionName(*d.name) + clusterName := constants.UpstreamDefinitionClusterPrefix + string(apiKind) + "_" + apiId + "_" + sanitizedDefName + info = policyenginev1.UpstreamInfo{ClusterName: clusterName} + if execCtx.upstreamDefinitionPaths != nil { + if bp, found := execCtx.upstreamDefinitionPaths[*d.name]; found { + info.BasePath = bp + basePathKnown = true + } + } + if !basePathKnown { + slog.Warn("UpstreamName: target upstream base path not found in upstreamDefinitionPaths", + "target_upstream", *d.name) + } + return info, *d.name, basePathKnown, true + } + + return policyenginev1.UpstreamInfo{}, "", false, false +} + +// applyUpstreamRedirect writes a resolved dynamic-routing override to header ops and dynamic +// metadata: the existing x-target-upstream / TargetUpstreamClusterKey / TargetUpstreamNameKey +// mechanism Envoy's cluster_header routing consumes, plus the CurrentUpstreamKey object that +// always reflects the request's actual current destination for downstream consumers. +func applyUpstreamRedirect( + execCtx *PolicyExecutionContext, + headerOps map[string][]*headerOp, + localDynamicMetadata map[string]map[string]interface{}, + pathMutation **string, + info policyenginev1.UpstreamInfo, + label string, + basePathKnown bool, +) { + extProcNS := constants.ExtProcFilterName + + headerOps[constants.TargetUpstreamHeader] = append(headerOps[constants.TargetUpstreamHeader], &headerOp{ + opType: "set", + value: info.ClusterName, + }) + + if execCtx.dynamicMetadata[extProcNS] == nil { + execCtx.dynamicMetadata[extProcNS] = make(map[string]interface{}) + } + if localDynamicMetadata[extProcNS] == nil { + localDynamicMetadata[extProcNS] = make(map[string]interface{}) + } + + execCtx.dynamicMetadata[extProcNS][constants.TargetUpstreamClusterKey] = info.ClusterName + execCtx.dynamicMetadata[extProcNS][constants.TargetUpstreamNameKey] = label + execCtx.dynamicMetadata[extProcNS][constants.CurrentUpstreamKey] = info.ToMap() + + localDynamicMetadata[extProcNS]["api_context"] = execCtx.apiContext + localDynamicMetadata[extProcNS]["upstream_base_path"] = execCtx.upstreamBasePath + localDynamicMetadata[extProcNS][constants.CurrentUpstreamKey] = info.ToMap() + + if basePathKnown { + localDynamicMetadata[extProcNS]["target_upstream_base_path"] = info.BasePath + execCtx.dynamicMetadata[extProcNS]["target_upstream_base_path"] = info.BasePath + + // Surface the raw request path as metadata["path"] so Lua rewrites :path once; + // guard so an earlier path-changing policy (e.g. request-rewrite) isn't clobbered. + if *pathMutation == nil { + rawPath := execCtx.requestBodyCtx.Path + *pathMutation = &rawPath + } + } +} + +// applyDefaultUpstream writes the route's compiled-in default upstream to header ops and +// dynamic metadata when no dynamic-routing policy overrode it — mirrors applyUpstreamRedirect +// for the no-override path, so CurrentUpstreamKey is always present either way. +func applyDefaultUpstream( + execCtx *PolicyExecutionContext, + headerOps map[string][]*headerOp, + localDynamicMetadata map[string]map[string]interface{}, +) { + extProcNS := constants.ExtProcFilterName + + headerOps[constants.TargetUpstreamHeader] = append(headerOps[constants.TargetUpstreamHeader], &headerOp{ + opType: "set", + value: execCtx.defaultUpstreamCluster, + }) + + if execCtx.dynamicMetadata[extProcNS] == nil { + execCtx.dynamicMetadata[extProcNS] = make(map[string]interface{}) + } + if localDynamicMetadata[extProcNS] == nil { + localDynamicMetadata[extProcNS] = make(map[string]interface{}) + } + + localDynamicMetadata[extProcNS]["api_context"] = execCtx.apiContext + localDynamicMetadata[extProcNS]["upstream_base_path"] = execCtx.upstreamBasePath + + // execCtx.defaultUpstream should always be set alongside defaultUpstreamCluster, but fall + // back to a partial UpstreamInfo built from the cluster name so CurrentUpstreamKey is never + // silently dropped (e.g. during a rolling upgrade against an older gateway-controller that + // doesn't yet send the full "default_upstream" metadata field). + info := execCtx.defaultUpstream + if info == nil { + info = &policyenginev1.UpstreamInfo{ + ClusterName: execCtx.defaultUpstreamCluster, + BasePath: execCtx.upstreamBasePath, + } + } + execCtx.dynamicMetadata[extProcNS][constants.CurrentUpstreamKey] = info.ToMap() + localDynamicMetadata[extProcNS][constants.CurrentUpstreamKey] = info.ToMap() +} + // RequestTranslationResult holds the output of translateRequestActionsCore. type RequestTranslationResult struct { HeaderMutation *extprocv3.HeaderMutation @@ -142,7 +275,7 @@ func translateRequestActionsCore(result *executor.RequestExecutionResult, execCt headerOps := make(map[string][]*headerOp) var finalBodyLength int bodyModified := false - var targetUpstreamName *string // Track the target upstream for cluster routing + var directive upstreamRedirectDirective // Track the last dynamic-routing directive // Seed analyticsData from execution context so data set in a previous phase // (e.g. request_headers captured during the request-headers phase) is carried @@ -237,86 +370,24 @@ func translateRequestActionsCore(result *executor.RequestExecutionResult, execCt } // Handle UpstreamName for dynamic cluster routing (last one wins) - if mods.UpstreamName != nil && *mods.UpstreamName != "" { - targetUpstreamName = mods.UpstreamName - } + directive.set(mods.UpstreamName) } } } - // Handle dynamic cluster routing via header. - // When a policy sets UpstreamName, we set the x-target-upstream header directly. - // ClearRouteCache is always enabled so Envoy can re-evaluate routing. - if targetUpstreamName != nil { - // Policy explicitly set the upstream - add the prefix, kind, and API ID for scoped cluster name - // Format: upstream___ - // Sanitize the definition name (replace dots and colons for valid Envoy cluster name) - apiKind := execCtx.sharedCtx.APIKind - apiId := execCtx.sharedCtx.APIId - sanitizedDefName := sanitizeUpstreamDefinitionName(*targetUpstreamName) - clusterName := constants.UpstreamDefinitionClusterPrefix + string(apiKind) + "_" + apiId + "_" + sanitizedDefName - - // Set the x-target-upstream header directly for cluster_header routing - headerOps[constants.TargetUpstreamHeader] = append(headerOps[constants.TargetUpstreamHeader], &headerOp{ - opType: "set", - value: clusterName, - }) - - // Store in execution context for potential response phase use - extProcNS := constants.ExtProcFilterName - if execCtx.dynamicMetadata[extProcNS] == nil { - execCtx.dynamicMetadata[extProcNS] = make(map[string]interface{}) - } - if out.DynamicMetadata[extProcNS] == nil { - out.DynamicMetadata[extProcNS] = make(map[string]interface{}) - } - execCtx.dynamicMetadata[extProcNS][constants.TargetUpstreamClusterKey] = clusterName - execCtx.dynamicMetadata[extProcNS][constants.TargetUpstreamNameKey] = *targetUpstreamName - - // Pass api_context and upstream_base_path in dynamic metadata for Lua filter - // Lua filter's handle:metadata() only works for envoy.filters.http.lua namespace, - // so we must pass these via dynamic metadata - out.DynamicMetadata[extProcNS]["api_context"] = execCtx.apiContext - out.DynamicMetadata[extProcNS]["upstream_base_path"] = execCtx.upstreamBasePath - - // When UpstreamName is used, provide the target upstream's base path for Lua filter - // The Lua filter handles path transformation and needs to know which upstream path to use - slog.Info("UpstreamName: checking upstreamDefinitionPaths", - "targetUpstream", *targetUpstreamName, - "hasUpstreamDefPaths", execCtx.upstreamDefinitionPaths != nil, - "upstreamDefPaths", execCtx.upstreamDefinitionPaths, - "apiContext", execCtx.apiContext) - if execCtx.upstreamDefinitionPaths != nil { - if targetUpstreamPath, ok := execCtx.upstreamDefinitionPaths[*targetUpstreamName]; ok { - // Set in both local DynamicMetadata (for response to Envoy) and execCtx (for response phase) - out.DynamicMetadata[extProcNS]["target_upstream_base_path"] = targetUpstreamPath - execCtx.dynamicMetadata[extProcNS]["target_upstream_base_path"] = targetUpstreamPath - slog.Info("UpstreamName: set target upstream base path", - "targetUpstream", *targetUpstreamName, - "targetUpstreamPath", targetUpstreamPath) - - // Surface the raw request path as metadata["path"] so Lua rewrites :path once; - // guard so an earlier path-changing policy (e.g. request-rewrite) isn't clobbered. - if out.Mutations.Path == nil { - rawPath := execCtx.requestBodyCtx.Path - out.Mutations.Path = &rawPath - slog.Info("UpstreamName: set mutations.Path", "path", rawPath) - } else { - slog.Info("UpstreamName: mutations.Path already set by policy") - } - } else { - slog.Warn("UpstreamName: target upstream not found in upstreamDefinitionPaths", - "targetUpstream", *targetUpstreamName, - "availableUpstreams", execCtx.upstreamDefinitionPaths) - } + // Handle dynamic cluster routing via header. When a policy sets UpstreamName, resolve it + // and set the x-target-upstream header directly; if it can't be resolved, fall back to the + // route's default exactly as if no directive had been set. ClearRouteCache is always + // enabled so Envoy can re-evaluate routing. + applied := false + if directive.isSet() { + if info, label, basePathKnown, ok := resolveUpstreamRedirect(execCtx, directive); ok { + applyUpstreamRedirect(execCtx, headerOps, out.DynamicMetadata, &out.Mutations.Path, info, label, basePathKnown) + applied = true } - } else if execCtx.defaultUpstreamCluster != "" { - // No policy set upstream, but route uses cluster_header routing - // Set the default cluster header so Envoy knows which cluster to use - headerOps[constants.TargetUpstreamHeader] = append(headerOps[constants.TargetUpstreamHeader], &headerOp{ - opType: "set", - value: execCtx.defaultUpstreamCluster, - }) + } + if !applied && execCtx.defaultUpstreamCluster != "" { + applyDefaultUpstream(execCtx, headerOps, out.DynamicMetadata) } // Always pass api_context and upstream_base_path in dynamic metadata when path rewrite is requested @@ -440,7 +511,7 @@ func TranslateRequestHeaderActions(result *executor.RequestHeaderExecutionResult path := execCtx.requestBodyCtx.Path var mutations RequestMutations - var targetUpstreamName *string + var directive upstreamRedirectDirective for _, pr := range result.Results { if pr.Skipped || pr.Action == nil { @@ -474,9 +545,7 @@ func TranslateRequestHeaderActions(result *executor.RequestHeaderExecutionResult if mods.Method != nil { mutations.Method = mods.Method } - if mods.UpstreamName != nil && *mods.UpstreamName != "" { - targetUpstreamName = mods.UpstreamName - } + directive.set(mods.UpstreamName) if mods.AnalyticsMetadata != nil { for key, value := range mods.AnalyticsMetadata { analyticsData[key] = value @@ -497,46 +566,15 @@ func TranslateRequestHeaderActions(result *executor.RequestHeaderExecutionResult } // Handle dynamic cluster routing - if targetUpstreamName != nil { - apiKind := execCtx.sharedCtx.APIKind - apiId := execCtx.sharedCtx.APIId - sanitizedDefName := sanitizeUpstreamDefinitionName(*targetUpstreamName) - clusterName := constants.UpstreamDefinitionClusterPrefix + string(apiKind) + "_" + apiId + "_" + sanitizedDefName - headerOps[constants.TargetUpstreamHeader] = append(headerOps[constants.TargetUpstreamHeader], &headerOp{opType: "set", value: clusterName}) - extProcNS := constants.ExtProcFilterName - if execCtx.dynamicMetadata[extProcNS] == nil { - execCtx.dynamicMetadata[extProcNS] = make(map[string]interface{}) - } - execCtx.dynamicMetadata[extProcNS][constants.TargetUpstreamClusterKey] = clusterName - execCtx.dynamicMetadata[extProcNS][constants.TargetUpstreamNameKey] = *targetUpstreamName - if dynamicMetadata[extProcNS] == nil { - dynamicMetadata[extProcNS] = make(map[string]interface{}) - } - dynamicMetadata[extProcNS]["api_context"] = execCtx.apiContext - dynamicMetadata[extProcNS]["upstream_base_path"] = execCtx.upstreamBasePath - if execCtx.upstreamDefinitionPaths != nil { - if targetUpstreamPath, ok := execCtx.upstreamDefinitionPaths[*targetUpstreamName]; ok { - dynamicMetadata[extProcNS]["target_upstream_base_path"] = targetUpstreamPath - execCtx.dynamicMetadata[extProcNS]["target_upstream_base_path"] = targetUpstreamPath - // Surface the raw request path as metadata["path"] so Lua rewrites :path once; - // guard so an earlier path-changing policy (e.g. request-rewrite) isn't clobbered. - if mutations.Path == nil { - rawPath := execCtx.requestBodyCtx.Path - mutations.Path = &rawPath - } - } - } - } else if execCtx.defaultUpstreamCluster != "" { - headerOps[constants.TargetUpstreamHeader] = append(headerOps[constants.TargetUpstreamHeader], &headerOp{opType: "set", value: execCtx.defaultUpstreamCluster}) - extProcNS := constants.ExtProcFilterName - if execCtx.dynamicMetadata[extProcNS] == nil { - execCtx.dynamicMetadata[extProcNS] = make(map[string]interface{}) - } - if dynamicMetadata[extProcNS] == nil { - dynamicMetadata[extProcNS] = make(map[string]interface{}) + applied := false + if directive.isSet() { + if info, label, basePathKnown, ok := resolveUpstreamRedirect(execCtx, directive); ok { + applyUpstreamRedirect(execCtx, headerOps, dynamicMetadata, &mutations.Path, info, label, basePathKnown) + applied = true } - dynamicMetadata[extProcNS]["api_context"] = execCtx.apiContext - dynamicMetadata[extProcNS]["upstream_base_path"] = execCtx.upstreamBasePath + } + if !applied && execCtx.defaultUpstreamCluster != "" { + applyDefaultUpstream(execCtx, headerOps, dynamicMetadata) } mergeHeaderMutations(headerMutation, headerOps) @@ -604,7 +642,7 @@ func TranslateRequestHeaderActionsWithBodyMerge( var bodyMutation *extprocv3.BodyMutation var finalBodyLength int bodyModified := false - var targetUpstreamName *string + var directive upstreamRedirectDirective path := execCtx.requestBodyCtx.Path @@ -641,9 +679,7 @@ func TranslateRequestHeaderActionsWithBodyMerge( if mods.Method != nil { mutations.Method = mods.Method } - if mods.UpstreamName != nil && *mods.UpstreamName != "" { - targetUpstreamName = mods.UpstreamName - } + directive.set(mods.UpstreamName) if mods.AnalyticsMetadata != nil { for key, value := range mods.AnalyticsMetadata { analyticsData[key] = value @@ -703,9 +739,7 @@ func TranslateRequestHeaderActionsWithBodyMerge( if mods.Method != nil { mutations.Method = mods.Method } - if mods.UpstreamName != nil && *mods.UpstreamName != "" { - targetUpstreamName = mods.UpstreamName - } + directive.set(mods.UpstreamName) if mods.AnalyticsMetadata != nil { for key, value := range mods.AnalyticsMetadata { analyticsData[key] = value @@ -725,47 +759,16 @@ func TranslateRequestHeaderActionsWithBodyMerge( } } - // Handle dynamic cluster routing (last UpstreamName wins across both phases). - if targetUpstreamName != nil { - apiKind := execCtx.sharedCtx.APIKind - apiId := execCtx.sharedCtx.APIId - sanitizedDefName := sanitizeUpstreamDefinitionName(*targetUpstreamName) - clusterName := constants.UpstreamDefinitionClusterPrefix + string(apiKind) + "_" + apiId + "_" + sanitizedDefName - headerOps[constants.TargetUpstreamHeader] = append(headerOps[constants.TargetUpstreamHeader], &headerOp{opType: "set", value: clusterName}) - extProcNS := constants.ExtProcFilterName - if execCtx.dynamicMetadata[extProcNS] == nil { - execCtx.dynamicMetadata[extProcNS] = make(map[string]interface{}) - } - if dynamicMetadata[extProcNS] == nil { - dynamicMetadata[extProcNS] = make(map[string]interface{}) + // Handle dynamic cluster routing (last directive wins across both phases). + applied := false + if directive.isSet() { + if info, label, basePathKnown, ok := resolveUpstreamRedirect(execCtx, directive); ok { + applyUpstreamRedirect(execCtx, headerOps, dynamicMetadata, &mutations.Path, info, label, basePathKnown) + applied = true } - execCtx.dynamicMetadata[extProcNS][constants.TargetUpstreamClusterKey] = clusterName - execCtx.dynamicMetadata[extProcNS][constants.TargetUpstreamNameKey] = *targetUpstreamName - dynamicMetadata[extProcNS]["api_context"] = execCtx.apiContext - dynamicMetadata[extProcNS]["upstream_base_path"] = execCtx.upstreamBasePath - if execCtx.upstreamDefinitionPaths != nil { - if targetUpstreamPath, ok := execCtx.upstreamDefinitionPaths[*targetUpstreamName]; ok { - dynamicMetadata[extProcNS]["target_upstream_base_path"] = targetUpstreamPath - execCtx.dynamicMetadata[extProcNS]["target_upstream_base_path"] = targetUpstreamPath - // Surface the raw request path as metadata["path"] so Lua rewrites :path once; - // guard so an earlier path-changing policy (e.g. request-rewrite) isn't clobbered. - if mutations.Path == nil { - rawPath := execCtx.requestBodyCtx.Path - mutations.Path = &rawPath - } - } - } - } else if execCtx.defaultUpstreamCluster != "" { - headerOps[constants.TargetUpstreamHeader] = append(headerOps[constants.TargetUpstreamHeader], &headerOp{opType: "set", value: execCtx.defaultUpstreamCluster}) - extProcNS := constants.ExtProcFilterName - if execCtx.dynamicMetadata[extProcNS] == nil { - execCtx.dynamicMetadata[extProcNS] = make(map[string]interface{}) - } - if dynamicMetadata[extProcNS] == nil { - dynamicMetadata[extProcNS] = make(map[string]interface{}) - } - dynamicMetadata[extProcNS]["api_context"] = execCtx.apiContext - dynamicMetadata[extProcNS]["upstream_base_path"] = execCtx.upstreamBasePath + } + if !applied && execCtx.defaultUpstreamCluster != "" { + applyDefaultUpstream(execCtx, headerOps, dynamicMetadata) } if bodyModified { diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/handler.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/handler.go index 9b3ce2cc43..f38cb01677 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/handler.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/handler.go @@ -281,6 +281,11 @@ func (h *ResourceHandler) HandleRouteConfigUpdate(ctx context.Context, resources rc.Metadata.DefaultUpstreamCluster = getStringFromMap(data, "default_upstream_cluster") rc.Metadata.UpstreamBasePath = getStringFromMap(data, "upstream_base_path") + if m, ok := data["default_upstream"].(map[string]interface{}); ok { + info := policyenginev1.UpstreamInfoFromMap(m) + rc.Metadata.DefaultUpstream = &info + } + if pathsRaw, ok := data["upstream_definition_paths"].(map[string]interface{}); ok { paths := make(map[string]string, len(pathsRaw)) for k, v := range pathsRaw {