diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 199b76602..b01baafa2 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -38,10 +38,10 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/policyxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/service/restapi" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/webhooksecretxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/transform" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/version" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/webhooksecretxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" ) @@ -407,6 +407,9 @@ func main() { llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver) transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer) policyManager.SetTransformers(transformerRegistry) + // In this controller wiring, only policy xDS receives the transformer + // registry. Main Envoy xDS still translates RestAPI configs directly, so + // both paths must keep cluster-name derivation in sync. // Load runtime configs from existing API configurations on startup. // We write directly to runtimeStore to avoid triggering N separate snapshot updates; diff --git a/gateway/gateway-controller/pkg/policyxds/policyxds_test.go b/gateway/gateway-controller/pkg/policyxds/policyxds_test.go index 599891062..3f261dc31 100644 --- a/gateway/gateway-controller/pkg/policyxds/policyxds_test.go +++ b/gateway/gateway-controller/pkg/policyxds/policyxds_test.go @@ -117,7 +117,7 @@ func TestTranslator_TranslateRuntimeConfigs(t *testing.T) { OperationPath: "/users", Vhost: "localhost", Upstream: models.RouteUpstream{ - ClusterKey: "upstream_main_localhost_8080", + ClusterKey: "main_fixture", }, }, }, @@ -129,7 +129,7 @@ func TestTranslator_TranslateRuntimeConfigs(t *testing.T) { }, }, UpstreamClusters: map[string]*models.UpstreamCluster{ - "upstream_main_localhost_8080": { + "main_fixture": { BasePath: "/", Endpoints: []models.Endpoint{{Host: "localhost", Port: 8080}}, }, diff --git a/gateway/gateway-controller/pkg/transform/restapi.go b/gateway/gateway-controller/pkg/transform/restapi.go index 8d404dca3..e17d654dc 100644 --- a/gateway/gateway-controller/pkg/transform/restapi.go +++ b/gateway/gateway-controller/pkg/transform/restapi.go @@ -31,6 +31,7 @@ import ( "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/utils" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" policyv1alpha "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" @@ -298,9 +299,10 @@ func (t *RestAPITransformer) buildPolicyChain( type upstreamClusterResult struct { // ClusterKey is the internal key used in rdc.UpstreamClusters. ClusterKey string - // EnvoyClusterName is the Envoy cluster name matching pkg/xds/translator.go's - // sanitizeClusterName format ("cluster__"). - // This is the value Envoy knows the cluster by, so PE must use it for x-target-upstream. + // EnvoyClusterName is the Envoy cluster name. For API-level upstreams it is + // the URL-stable hashed name "_<24-hex>" (matching ClusterKey). This is + // the value Envoy knows the cluster by, so the policy engine must use it for + // the x-target-upstream header. EnvoyClusterName string // BasePath is the URL path component of the upstream (e.g. "/anything/foo"). BasePath string @@ -337,7 +339,12 @@ func (t *RestAPITransformer) addUpstreamCluster( basePath = "/" } - clusterKey := fmt.Sprintf("upstream_%s_%s_%d", upstreamName, parsedURL.Hostname(), port) + // URL-stable cluster naming: "_" so a URL edit + // updates the same named cluster instead of renaming it (routes and stats + // keys stay continuous). ClusterKey and EnvoyClusterName are intentionally + // the same string so the policy engine's `default_upstream_cluster` metadata + // points at the actual Envoy cluster. + clusterKey := clusterkey.APILevelName(upstreamName, rdc.Metadata.UUID) rdc.UpstreamClusters[clusterKey] = &models.UpstreamCluster{ BasePath: basePath, @@ -350,19 +357,11 @@ func (t *RestAPITransformer) addUpstreamCluster( return &upstreamClusterResult{ ClusterKey: clusterKey, - EnvoyClusterName: sanitizeEnvoyClusterName(parsedURL.Host, parsedURL.Scheme), + EnvoyClusterName: clusterKey, BasePath: basePath, }, nil } -// sanitizeEnvoyClusterName computes the Envoy cluster name from a URL host and scheme, -// matching the sanitizeClusterName logic in pkg/xds/translator.go. -func sanitizeEnvoyClusterName(host, scheme string) string { - name := strings.ReplaceAll(host, ".", "_") - name = strings.ReplaceAll(name, ":", "_") - return "cluster_" + scheme + "_" + name -} - // resolveUpstreamURL resolves the URL from an upstream (direct URL or ref). For a ref it // also returns the referenced definition's base path (from basePath, never the URL); for a // direct URL the returned base-path pointer is nil, signalling the caller to use the URL path. diff --git a/gateway/gateway-controller/pkg/transform/restapi_test.go b/gateway/gateway-controller/pkg/transform/restapi_test.go index 5edf451df..f1b700848 100644 --- a/gateway/gateway-controller/pkg/transform/restapi_test.go +++ b/gateway/gateway-controller/pkg/transform/restapi_test.go @@ -20,6 +20,7 @@ package transform import ( "net/url" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -27,6 +28,7 @@ import ( 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/utils/clusterkey" ) // ptrStr is a helper to get a pointer to a string literal. @@ -353,7 +355,6 @@ func TestRestAPITransformer_SandboxRouteClusterHeader(t *testing.T) { defs := map[string]models.PolicyDefinition{} const sandboxURL = "http://sandbox-backend:9080/sandbox" const sandboxRouteKey = "GET|/test/hello|sandbox.local" - expectedSandboxCluster := sanitizeEnvoyClusterName("sandbox-backend:9080", "http") t.Run("without upstreamDefinitions the sandbox route is static", func(t *testing.T) { transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, defs) @@ -383,7 +384,190 @@ func TestRestAPITransformer_SandboxRouteClusterHeader(t *testing.T) { r, exists := rdc.Routes[sandboxRouteKey] require.True(t, exists, "sandbox route should exist") assert.True(t, r.Upstream.UseClusterHeader) - assert.Equal(t, expectedSandboxCluster, r.Upstream.DefaultCluster, - "sandbox route must default to the sandbox cluster, not main") + assert.True(t, strings.HasPrefix(r.Upstream.DefaultCluster, "sandbox_"), + "sandbox route must default to the URL-stable sandbox cluster (sandbox_), not main; got %q", r.Upstream.DefaultCluster) }) } + +// makeRestAPIWithOps builds a RestAPI StoredConfig with caller-supplied operations +// and both API-level main and sandbox upstreams configured. +func makeRestAPIWithOps(ops []api.Operation) *models.StoredConfig { + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "1.0.0", + Operations: ops, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: ptrStr("http://api-main:8080")}, + Sandbox: &api.Upstream{Url: ptrStr("http://api-sandbox:8080")}, + }, + } + restAPI := api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "test-api"}, + Spec: apiData, + } + return &models.StoredConfig{ + UUID: "test-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: restAPI, + } +} + +// TestRestAPITransformer_APILevelClusterNameShape asserts the URL-stable cluster +// naming contract for API-level main and sandbox upstreams: +// - cluster names are "_<24-hex>" derived from sha256(apiID), shared by main and sandbox +// - ClusterKey and EnvoyClusterName are the SAME string (so the policy engine's +// default_upstream_cluster metadata resolves to a real Envoy cluster) +func TestRestAPITransformer_APILevelClusterNameShape(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: "GET", Path: "/users"}, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + expectedMain := "main_" + clusterkey.APILevel(cfg.UUID) + expectedSandbox := "sandbox_" + clusterkey.APILevel(cfg.UUID) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute, "main route must exist") + assert.Equal(t, expectedMain, mainRoute.Upstream.ClusterKey, + "main cluster name should be _ derived from sha256(apiID)") + + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, sandboxRoute, "sandbox route must exist") + assert.Equal(t, expectedSandbox, sandboxRoute.Upstream.ClusterKey, + "sandbox cluster name should be _ derived from sha256(apiID)") + + _, mainExists := rdc.UpstreamClusters[expectedMain] + require.True(t, mainExists, "main cluster %q must be registered in UpstreamClusters", expectedMain) + _, sandboxExists := rdc.UpstreamClusters[expectedSandbox] + require.True(t, sandboxExists, "sandbox cluster %q must be registered in UpstreamClusters", expectedSandbox) +} + +// TestRestAPITransformer_APILevelDefaultClusterMatchesRealCluster verifies that +// route.Upstream.DefaultCluster matches a cluster registered in +// rdc.UpstreamClusters whenever UseClusterHeader is enabled. The policy engine +// writes DefaultCluster into the x-target-upstream header and Envoy looks up +// the cluster by that value; if the name does not match a registered cluster, +// Envoy returns 503 NoRoute. +func TestRestAPITransformer_APILevelDefaultClusterMatchesRealCluster(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: "GET", Path: "/users"}, + }) + // Add an upstreamDefinition so UseClusterHeader becomes true and + // DefaultCluster is actually populated. + spec := cfg.Configuration.(api.RestAPI) + spec.Spec.UpstreamDefinitions = &[]api.UpstreamDefinition{ + { + Name: "stub-def", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://stub-def-svc:8080"}, + }, + }, + } + cfg.Configuration = spec + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute) + require.True(t, mainRoute.Upstream.UseClusterHeader, + "upstreamDefinitions present, UseClusterHeader should be true so DefaultCluster is meaningful") + require.NotEmpty(t, mainRoute.Upstream.DefaultCluster, + "DefaultCluster must be populated when UseClusterHeader is true") + + _, exists := rdc.UpstreamClusters[mainRoute.Upstream.DefaultCluster] + assert.True(t, exists, + "DefaultCluster %q must reference a real registered cluster in UpstreamClusters "+ + "(prevents 503 NoRoute when policy engine writes x-target-upstream)", + mainRoute.Upstream.DefaultCluster) + assert.Equal(t, mainRoute.Upstream.ClusterKey, mainRoute.Upstream.DefaultCluster, + "DefaultCluster and ClusterKey must be the same string") +} + +// TestRestAPITransformer_APILevelURLStableAcrossURLEdit asserts that editing the +// API-level main upstream URL does NOT change the cluster name. This is the +// URL-stable contract: the route keeps pointing at the same named cluster and +// name-keyed stats stay continuous across URL edits. +func TestRestAPITransformer_APILevelURLStableAcrossURLEdit(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + cfgA := makeRestAPIWithOps([]api.Operation{{Method: "GET", Path: "/users"}}) + rdcA, err := transformer.Transform(cfgA) + require.NoError(t, err) + + cfgB := makeRestAPIWithOps([]api.Operation{{Method: "GET", Path: "/users"}}) + specB := cfgB.Configuration.(api.RestAPI) + specB.Spec.Upstream.Main.Url = ptrStr("http://api-main-v2:9090") + cfgB.Configuration = specB + rdcB, err := transformer.Transform(cfgB) + require.NoError(t, err) + + nameA := rdcA.Routes["GET|/test/users|main.local"].Upstream.ClusterKey + nameB := rdcB.Routes["GET|/test/users|main.local"].Upstream.ClusterKey + assert.Equal(t, nameA, nameB, + "API-level main cluster name must not depend on URL "+ + "(URL-stable contract: the name must survive URL edits)") +} + +// TestRestAPITransformer_APILevelMainOnlyHasNoSandboxCluster verifies that an +// API with no sandbox upstream registers no sandbox_ cluster and creates +// no sandbox route. The optional env must not leave a route pointing at a +// cluster absent from UpstreamClusters (which would surface as 503 NoRoute). +func TestRestAPITransformer_APILevelMainOnlyHasNoSandboxCluster(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: "GET", Path: "/users"}, + }) + spec := cfg.Configuration.(api.RestAPI) + spec.Spec.Upstream.Sandbox = nil // main-only API + cfg.Configuration = spec + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + expectedMain := "main_" + clusterkey.APILevel(cfg.UUID) + expectedSandbox := "sandbox_" + clusterkey.APILevel(cfg.UUID) + + _, mainExists := rdc.UpstreamClusters[expectedMain] + require.True(t, mainExists, "main cluster %q must still be registered", expectedMain) + + _, sandboxExists := rdc.UpstreamClusters[expectedSandbox] + assert.False(t, sandboxExists, + "sandbox cluster %q must not be registered when no sandbox upstream is configured", expectedSandbox) + + _, sandboxRouteExists := rdc.Routes["GET|/test/users|sandbox.local"] + assert.False(t, sandboxRouteExists, + "no sandbox route should exist for a main-only API") +} + +// TestRestAPITransformer_ClusterNameUsesSharedHelper locks the cross-builder +// naming contract: the transform path names the cluster exactly +// clusterkey.APILevelName(env, cfg.UUID), the same helper and argument the xDS +// translator uses (pinned on that side in pkg/xds tests), so the two builders +// cannot drift to different names for the same API. +func TestRestAPITransformer_ClusterNameUsesSharedHelper(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: "GET", Path: "/users"}, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + assert.Equal(t, clusterkey.APILevelName("main", cfg.UUID), + rdc.Routes["GET|/test/users|main.local"].Upstream.ClusterKey) + assert.Equal(t, clusterkey.APILevelName("sandbox", cfg.UUID), + rdc.Routes["GET|/test/users|sandbox.local"].Upstream.ClusterKey) +} diff --git a/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go new file mode 100644 index 000000000..5f6aabda3 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go @@ -0,0 +1,40 @@ +/* + * 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 clusterkey produces deterministic Envoy cluster-key fragments for the +// gateway-controller, shared by both xDS builders so they name clusters identically. +package clusterkey + +import ( + "crypto/sha256" + "encoding/hex" +) + +// APILevel returns the 24-hex cluster-key fragment for an API-level upstream, +// the first 12 bytes of SHA-256(apiID). The URL is excluded so the name is stable +// across URL edits; main and sandbox share it, set apart by the caller's env prefix. +func APILevel(apiID string) string { + sum := sha256.Sum256([]byte(apiID)) + return hex.EncodeToString(sum[:12]) +} + +// APILevelName joins the env prefix ("main"/"sandbox") to the APILevel fragment +// to form the full Envoy cluster name. +func APILevelName(env, apiID string) string { + return env + "_" + APILevel(apiID) +} diff --git a/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey_test.go b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey_test.go new file mode 100644 index 000000000..c5bfe1af7 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey_test.go @@ -0,0 +1,81 @@ +/* + * 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 clusterkey + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +// hexShape24 matches exactly 24 lowercase hex characters - the cluster-key +// fragment shape produced by APILevel. +var hexShape24 = regexp.MustCompile("^[a-f0-9]{24}$") + +// TestAPILevel validates the API-level cluster-key fragment: deterministic, +// distinct per apiID, and pinned to SHA-256[:12] (24 hex chars). +func TestAPILevel(t *testing.T) { + t.Run("deterministic for identical input", func(t *testing.T) { + a := APILevel("api-1") + b := APILevel("api-1") + assert.Equal(t, a, b, "same input must produce same hash") + assert.Regexp(t, hexShape24, a, "hash must be exactly 24 lowercase hex characters") + }) + + t.Run("different apiID produces different hash", func(t *testing.T) { + a := APILevel("api-1") + b := APILevel("api-2") + assert.NotEqual(t, a, b) + }) + + // Known-answer vectors pin the algorithm to SHA-256[:12]. Without these, any + // deterministic 24-hex function would satisfy the shape checks above. + t.Run("known-answer vectors", func(t *testing.T) { + assert.Equal(t, "f9811b73ac5d1a8db842634f", APILevel("api-1")) + assert.Equal(t, "2a28373e2cacc6ea903d8c7e", APILevel("test-api")) + // A realistic UUIDv7-shaped apiID, the form used in production. + assert.Equal(t, "54a9b3e5ce2b6ccb97168e59", APILevel("0190b3e2-7b1c-7c2a-9b3d-1a2b3c4d5e6f")) + }) + + // Empty input is deterministic (the SHA-256 of the empty string), documenting + // that APILevel itself does not reject empty apiIDs; non-emptiness is enforced + // upstream at deploy time. + t.Run("empty input is deterministic", func(t *testing.T) { + assert.Equal(t, "e3b0c44298fc1c149afbf4c8", APILevel("")) + }) +} + +// TestAPILevelName validates the full cluster-name contract: the env prefix +// joined to the APILevel fragment. Both xDS builders go through this helper, so +// the two paths cannot drift. +func TestAPILevelName(t *testing.T) { + t.Run("joins env prefix to fragment", func(t *testing.T) { + assert.Equal(t, "main_"+APILevel("api-1"), APILevelName("main", "api-1")) + assert.Equal(t, "sandbox_"+APILevel("api-1"), APILevelName("sandbox", "api-1")) + }) + + t.Run("main and sandbox share the fragment, differ by prefix", func(t *testing.T) { + main := APILevelName("main", "api-1") + sandbox := APILevelName("sandbox", "api-1") + assert.NotEqual(t, main, sandbox) + assert.Equal(t, "main_f9811b73ac5d1a8db842634f", main) + assert.Equal(t, "sandbox_f9811b73ac5d1a8db842634f", sandbox) + }) +} diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index 408a6dda7..07e97cb8e 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -32,6 +32,7 @@ import ( "time" commonconstants "github.com/wso2/api-platform/common/constants" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" accesslog "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3" cluster "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" @@ -767,7 +768,7 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* clusters := []*cluster.Cluster{} // -------- MAIN UPSTREAM -------- - mainClusterName, parsedMainURL, mainTimeout, err := t.resolveUpstreamCluster("main", &apiData.Upstream.Main, apiData.UpstreamDefinitions) + mainClusterName, parsedMainURL, mainTimeout, err := t.resolveUpstreamCluster(cfg.UUID, "main", &apiData.Upstream.Main, apiData.UpstreamDefinitions) if err != nil { return nil, nil, err } @@ -831,7 +832,7 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* // -------- SANDBOX UPSTREAM -------- if apiData.Upstream.Sandbox != nil { - sbClusterName, parsedSbURL, sbTimeout, err := t.resolveUpstreamCluster("sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) + sbClusterName, parsedSbURL, sbTimeout, err := t.resolveUpstreamCluster(cfg.UUID, "sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) if err != nil { return nil, nil, err } @@ -913,8 +914,9 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* } // resolveUpstreamCluster validates an upstream (main or sandbox) and creates its cluster. -// Returns clusterName, parsedURL, timeout (can be nil), and error. -func (t *Translator) resolveUpstreamCluster(upstreamName string, up *api.Upstream, upstreamDefinitions *[]api.UpstreamDefinition) (string, *url.URL, *resolvedTimeout, error) { +// Returns clusterName, parsedURL, timeout (can be nil), and error. The cluster name is +// "_", URL-stable for the API's lifetime. +func (t *Translator) resolveUpstreamCluster(apiID, upstreamName string, up *api.Upstream, upstreamDefinitions *[]api.UpstreamDefinition) (string, *url.URL, *resolvedTimeout, error) { var rawURL string var timeout *resolvedTimeout var refBasePath *string @@ -974,8 +976,8 @@ func (t *Translator) resolveUpstreamCluster(upstreamName string, up *api.Upstrea parsedURL.Path = *refBasePath } - // Generate cluster name - clusterName := t.sanitizeClusterName(parsedURL.Host, parsedURL.Scheme) + // Generate cluster name from URL-stable hash (URL intentionally excluded). + clusterName := clusterkey.APILevelName(upstreamName, apiID) return clusterName, parsedURL, timeout, nil } @@ -2704,14 +2706,6 @@ func (t *Translator) pathToRegex(path string) string { return "^" + regex + "$" } -// sanitizeClusterName creates a valid cluster name from a hostname and scheme -func (t *Translator) sanitizeClusterName(hostname, scheme string) string { - name := strings.ReplaceAll(hostname, ".", "_") - name = strings.ReplaceAll(name, ":", "_") - // Include scheme to differentiate HTTP and HTTPS clusters for the same host - return "cluster_" + scheme + "_" + name -} - // sanitizeUpstreamDefinitionName sanitizes an upstream definition name for use in Envoy cluster names. // Envoy cluster names cannot contain dots or colons. func sanitizeUpstreamDefinitionName(name string) string { diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index 0be6f3968..740532add 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -40,6 +40,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" ) func TestResolveUpstreamDefinition_Found(t *testing.T) { @@ -164,10 +165,11 @@ func TestResolveUpstreamCluster_WithDirectURL(t *testing.T) { Url: &url, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("main", upstream, nil) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "main", upstream, nil) require.NoError(t, err) - assert.Equal(t, "cluster_http_backend_8080", clusterName) + assert.Equal(t, "main_"+clusterkey.APILevel("test-api"), clusterName, + "cluster name should be the URL-stable hash of the apiID, independent of URL") assert.NotNil(t, parsedURL) assert.Equal(t, "http", parsedURL.Scheme) assert.Equal(t, "backend:8080", parsedURL.Host) @@ -201,10 +203,11 @@ func TestResolveUpstreamCluster_WithRef_WithTimeout(t *testing.T) { }, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("main", upstream, definitions) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) require.NoError(t, err) - assert.Equal(t, "cluster_http_backend-1_9000", clusterName) + assert.Equal(t, "main_"+clusterkey.APILevel("test-api"), clusterName, + "cluster name should be the URL-stable hash of the apiID, independent of URL") assert.NotNil(t, parsedURL) assert.Equal(t, "http", parsedURL.Scheme) assert.Equal(t, "backend-1:9000", parsedURL.Host) @@ -234,10 +237,11 @@ func TestResolveUpstreamCluster_WithRef_NoTimeout(t *testing.T) { }, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("main", upstream, definitions) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) require.NoError(t, err) - assert.Equal(t, "cluster_http_backend_8080", clusterName) + assert.Equal(t, "main_"+clusterkey.APILevel("test-api"), clusterName, + "cluster name should be the URL-stable hash of the apiID, independent of URL") assert.NotNil(t, parsedURL) assert.Nil(t, timeout, "No timeout in definition should result in nil timeout") } @@ -262,7 +266,7 @@ func TestResolveUpstreamCluster_WithRef_NotFound(t *testing.T) { }, } - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, definitions) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to resolve main upstream ref") @@ -293,7 +297,7 @@ func TestResolveUpstreamCluster_WithRef_InvalidTimeout(t *testing.T) { }, } - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, definitions) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid timeout in upstream definition") @@ -315,7 +319,7 @@ func TestResolveUpstreamCluster_WithRef_NoURLs(t *testing.T) { }, } - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, definitions) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) assert.Error(t, err) assert.Contains(t, err.Error(), "has no URLs configured") @@ -325,7 +329,7 @@ func TestResolveUpstreamCluster_NoURLOrRef(t *testing.T) { translator := &Translator{} upstream := &api.Upstream{} - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, nil) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, nil) assert.Error(t, err) assert.Contains(t, err.Error(), "no main upstream configured") @@ -338,7 +342,7 @@ func TestResolveUpstreamCluster_InvalidURL(t *testing.T) { Url: &invalidURL, } - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, nil) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, nil) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid main upstream URL") @@ -738,52 +742,6 @@ func TestTranslator_WildcardUpstreamRewriteFromRDC(t *testing.T) { } } -func TestTranslator_SanitizeClusterName(t *testing.T) { - logger := createTestLogger() - routerCfg := testRouterConfig() - cfg := testConfig() - translator := NewTranslator(logger, routerCfg, nil, cfg) - - tests := []struct { - name string - hostname string - scheme string - expected string - }{ - { - name: "Simple hostname HTTP", - hostname: "localhost", - scheme: "http", - expected: "cluster_http_localhost", - }, - { - name: "Dotted hostname HTTPS", - hostname: "api.example.com", - scheme: "https", - expected: "cluster_https_api_example_com", - }, - { - name: "Hostname with port", - hostname: "localhost:8080", - scheme: "http", - expected: "cluster_http_localhost_8080", - }, - { - name: "Complex hostname", - hostname: "api.v1.prod.example.com:443", - scheme: "https", - expected: "cluster_https_api_v1_prod_example_com_443", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := translator.sanitizeClusterName(tt.hostname, tt.scheme) - assert.Equal(t, tt.expected, result) - }) - } -} - func TestGetValueFromSourceConfig(t *testing.T) { tests := []struct { name string @@ -1499,7 +1457,7 @@ func TestTranslator_ResolveUpstreamCluster_SimpleURL(t *testing.T) { Url: &urlStr, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-upstream", upstream, nil) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "test-upstream", upstream, nil) assert.NoError(t, err) assert.NotEmpty(t, clusterName) assert.NotNil(t, parsedURL) @@ -1518,7 +1476,7 @@ func TestTranslator_ResolveUpstreamCluster_HTTPSUrl(t *testing.T) { Url: &urlStr, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("secure-upstream", upstream, nil) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "secure-upstream", upstream, nil) assert.NoError(t, err) assert.NotEmpty(t, clusterName) assert.NotNil(t, parsedURL) @@ -1536,7 +1494,7 @@ func TestTranslator_ResolveUpstreamCluster_MissingURL(t *testing.T) { Url: nil, // No URL } - _, _, _, err := translator.resolveUpstreamCluster("no-url-upstream", upstream, nil) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "no-url-upstream", upstream, nil) assert.Error(t, err) assert.Contains(t, err.Error(), "no no-url-upstream upstream configured") } @@ -2163,3 +2121,60 @@ func TestTranslator_CreateDynamicFwdListenerForWebSubHub(t *testing.T) { assert.Equal(t, core.SocketAddress_TCP, listener.GetAddress().GetSocketAddress().GetProtocol()) }) } + +// TestResolveUpstreamCluster_DedupSameAPIDifferentURLs asserts the URL-stable +// contract at the API level. Two distinct URLs that share the same apiID and +// env must resolve to the same cluster name, so a URL edit updates the same +// named cluster instead of removing one cluster name and adding another. +func TestResolveUpstreamCluster_DedupSameAPIDifferentURLs(t *testing.T) { + translator := &Translator{} + a := &api.Upstream{Url: strPtr("http://api-main:8080")} + b := &api.Upstream{Url: strPtr("http://api-main:9090")} + + nameA, _, _, err := translator.resolveUpstreamCluster("test-api", "main", a, nil) + require.NoError(t, err) + nameB, _, _, err := translator.resolveUpstreamCluster("test-api", "main", b, nil) + require.NoError(t, err) + + assert.Equal(t, nameA, nameB, + "API-level cluster name must not depend on URL - same API and env must produce the same cluster") +} + +// TestResolveUpstreamCluster_MainSandboxNeverCollide proves env separation: +// the same apiID with env=main vs env=sandbox must produce distinct cluster +// names so both vhosts can coexist. The names share the hash fragment (same +// API, so an operator can pair them at a glance); the env prefix provides +// the distinction. +func TestResolveUpstreamCluster_MainSandboxNeverCollide(t *testing.T) { + translator := &Translator{} + up := &api.Upstream{Url: strPtr("http://api-main:8080")} + + mainName, _, _, err := translator.resolveUpstreamCluster("test-api", "main", up, nil) + require.NoError(t, err) + sandboxName, _, _, err := translator.resolveUpstreamCluster("test-api", "sandbox", up, nil) + require.NoError(t, err) + + assert.NotEqual(t, mainName, sandboxName, + "main and sandbox cluster names must differ (the env prefix distinguishes them)") + assert.Equal(t, strings.TrimPrefix(mainName, "main_"), strings.TrimPrefix(sandboxName, "sandbox_"), + "main and sandbox must share the hash fragment so an API's cluster pair is correlatable") +} + +// TestResolveUpstreamCluster_NameNotURLDerived locks the move off the old +// URL-sanitized scheme: the cluster name must carry no URL information (no +// "cluster_" prefix, no host), only the env-prefixed identity hash. A +// regression to URL-derived naming would reintroduce connection draining on +// URL edits. +func TestResolveUpstreamCluster_NameNotURLDerived(t *testing.T) { + translator := &Translator{} + upstream := &api.Upstream{Url: strPtr("http://api.example.com:8080/v1")} + + name, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, nil) + require.NoError(t, err) + + assert.Equal(t, "main_"+clusterkey.APILevel("test-api"), name) + assert.False(t, strings.HasPrefix(name, "cluster_"), + "cluster name must not use the old URL-derived scheme") + assert.NotContains(t, name, "api.example.com", + "cluster name must not contain the backend host") +} diff --git a/gateway/it/features/api-level-url-stable.feature b/gateway/it/features/api-level-url-stable.feature new file mode 100644 index 000000000..b1e264103 --- /dev/null +++ b/gateway/it/features/api-level-url-stable.feature @@ -0,0 +1,408 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +@api-level-url-stable +Feature: API-Level Upstream URL-Stable Cluster Naming + As an API developer + I want API-level main and sandbox cluster names to stay stable across + upstream URL edits + So that routes, name-keyed stats, and cluster identity survive URL changes + and requests keep succeeding during updates + + Background: + Given the gateway services are running + + Scenario: API-level main upstream URL update (host and path change) routes to new backend (URL-stable cluster naming) + 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: api-level-url-stable-main-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Main-API + version: v1.0 + context: /api-level-url-stable-main/$version + vhosts: + main: api-level-url-stable-main.local + upstream: + main: + url: http://sample-backend:9080/version-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" to be ready with host "api-level-url-stable-main.local" + + When I clear all headers + And I set request host to "api-level-url-stable-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-a/endpoint" + + # Envoy admin: the API-level cluster must use the identity-derived name + # (main_) and there must be no URL-derived (cluster__) + # cluster. The URL-derived form is what the pre-change naming produced, so + # this assertion fails on the old naming scheme. The exact name set is + # captured so the post-update step can prove the NAME survived the update. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And I capture the Envoy cluster names prefixed "main_" + + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-main-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: api-level-url-stable-main-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Main-API + version: v1.0 + context: /api-level-url-stable-main/$version + vhosts: + main: api-level-url-stable-main.local + upstream: + main: + # The host changes too (container alias of the same backend), proving + # the cluster survives a HOST edit, not only a path edit. The old + # URL-derived naming kept its name across path edits but renamed the + # cluster on any host or scheme change. + url: http://it-sample-backend:9080/version-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" to be ready with host "api-level-url-stable-main.local" + + When I clear all headers + And I set request host to "api-level-url-stable-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-b/endpoint" + + # After the HOST change the exact cluster-name set must be UNCHANGED: + # this proves the same main_ cluster survived the host edit (a + # rename to a different main_ would fail the unchanged step). The + # old naming would have minted a new cluster_http_it-sample-backend_9080 + # cluster here and dropped the previous one. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "main_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-main-api-v1.0" + Then the response should be successful + + Scenario: API-level sandbox upstream URL update (host and path change) routes to new 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: api-level-url-stable-sandbox-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Sandbox-API + version: v1.0 + context: /api-level-url-stable-sandbox/$version + vhosts: + main: api-level-url-stable-sandbox-main.local + sandbox: api-level-url-stable-sandbox-sb.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/sandbox-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" to be ready with host "api-level-url-stable-sandbox-sb.local" + + When I clear all headers + And I set request host to "api-level-url-stable-sandbox-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/sandbox-a/endpoint" + + # Capture the sandbox cluster-name set so the post-update step can prove + # the sandbox_ name survived the URL update. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And I capture the Envoy cluster names prefixed "sandbox_" + + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-sandbox-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: api-level-url-stable-sandbox-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Sandbox-API + version: v1.0 + context: /api-level-url-stable-sandbox/$version + vhosts: + main: api-level-url-stable-sandbox-main.local + sandbox: api-level-url-stable-sandbox-sb.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + # The sandbox host changes too (container alias of the same + # backend), so this update exercises a host edit on the sandbox + # cluster, not only a path edit. + url: http://it-sample-backend:9080/sandbox-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" to be ready with host "api-level-url-stable-sandbox-sb.local" + + When I clear all headers + And I set request host to "api-level-url-stable-sandbox-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/sandbox-b/endpoint" + + # Envoy admin: the sandbox cluster must use the identity-derived name + # (sandbox_); no URL-derived cluster may exist, and the exact name + # set must be unchanged across the host edit (identity proof). Fails on + # the old URL-derived naming scheme. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "sandbox_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-sandbox-api-v1.0" + Then the response should be successful + + Scenario: API-level upstream with cluster_header routing (default upstream cluster resolves correctly) + 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: api-level-url-stable-default-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Default-API + version: v1.0 + context: /api-level-url-stable-default/$version + vhosts: + main: api-level-url-stable-default.local + upstreamDefinitions: + - name: backend-default + basePath: /api-main + upstreams: + - url: http://sample-backend:9080 + upstream: + main: + ref: backend-default + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-default/v1.0/endpoint" to be ready with host "api-level-url-stable-default.local" + + When I clear all headers + And I set request host to "api-level-url-stable-default.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-default/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/endpoint" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-default-api-v1.0" + Then the response should be successful + + Scenario: API-level main and sandbox on the same backend host get separate identity-named clusters + 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: api-level-url-stable-collision-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Collision-API + version: v1.0 + context: /api-level-url-stable-collision/$version + vhosts: + main: api-level-url-stable-collision-main.local + sandbox: api-level-url-stable-collision-sb.local + upstream: + main: + url: http://sample-backend:9080/collision-main + sandbox: + url: http://sample-backend:9080/collision-sandbox + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" to be ready with host "api-level-url-stable-collision-main.local" + + # Main and sandbox share the same backend host:port but must route to their + # own base paths. The old URL-derived naming keyed the cluster on host and + # scheme only, so main and sandbox collapsed into one shared cluster here; + # identity naming gives each its own. + When I clear all headers + And I set request host to "api-level-url-stable-collision-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/collision-main/endpoint" + + When I clear all headers + And I set request host to "api-level-url-stable-collision-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/collision-sandbox/endpoint" + + # Envoy admin: an identity-named main_ and a sandbox_ cluster + # must both exist (they do not collide), and no URL-derived cluster may + # exist. Under the old naming both upstreams shared one cluster__ + # cluster, so this assertion fails on the previous scheme. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-collision-api-v1.0" + Then the response should be successful + + Scenario: Two APIs sharing the same backend host each route under their own identity-named cluster + 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: api-level-url-stable-shared-a-v1.0 + spec: + displayName: API-Level-URL-Stable-Shared-A + version: v1.0 + context: /api-level-url-stable-shared-a/$version + vhosts: + main: api-level-url-stable-shared-a.local + upstream: + main: + url: http://sample-backend:9080/shared-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" to be ready with host "api-level-url-stable-shared-a.local" + + 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: api-level-url-stable-shared-b-v1.0 + spec: + displayName: API-Level-URL-Stable-Shared-B + version: v1.0 + context: /api-level-url-stable-shared-b/$version + vhosts: + main: api-level-url-stable-shared-b.local + upstream: + main: + url: http://sample-backend:9080/shared-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-shared-b/v1.0/endpoint" to be ready with host "api-level-url-stable-shared-b.local" + + # Two distinct APIs point at the same backend host:port. The old URL-derived + # naming made them share one cluster__ cluster; identity naming + # keys each cluster on its apiID, so the two APIs route independently to their + # own base paths under identity-named clusters and no URL-derived cluster exists. + When I clear all headers + And I set request host to "api-level-url-stable-shared-a.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-a/endpoint" + + When I clear all headers + And I set request host to "api-level-url-stable-shared-b.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-b/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-b/endpoint" + + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + + # Delete API-B and confirm API-A still routes, proving the two APIs own + # independent clusters (deleting one does not disturb the other). + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-shared-b-v1.0" + Then the response should be successful + + When I clear all headers + And I set request host to "api-level-url-stable-shared-a.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-a/endpoint" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-shared-a-v1.0" + Then the response should be successful diff --git a/gateway/it/steps_envoy_admin.go b/gateway/it/steps_envoy_admin.go new file mode 100644 index 000000000..42bbb6fc6 --- /dev/null +++ b/gateway/it/steps_envoy_admin.go @@ -0,0 +1,118 @@ +/* + * 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 it + +import ( + "context" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "github.com/cucumber/godog" +) + +// envoyAdminURL is the Envoy admin endpoint exposed by the IT compose stack. +const envoyAdminURL = "http://localhost:9901" + +// envoyAdminClient bounds admin queries so a stalled admin endpoint fails the +// step instead of hanging until the suite timeout. +var envoyAdminClient = &http.Client{Timeout: 10 * time.Second} + +// rememberedClusterSets holds cluster-name sets captured during a scenario, +// keyed by name prefix. Cleared before each scenario. Safe under godog's +// default sequential execution; it would need per-scenario state if scenario +// parallelism is ever enabled. +var rememberedClusterSets = map[string][]string{} + +// fetchEnvoyClusterNames returns the sorted, de-duplicated set of cluster +// names with the given prefix, parsed from the Envoy admin /clusters output +// (each line has the form "::::"). +func fetchEnvoyClusterNames(prefix string) ([]string, error) { + resp, err := envoyAdminClient.Get(envoyAdminURL + "/clusters") + if err != nil { + return nil, fmt.Errorf("failed to query Envoy admin /clusters: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Envoy admin /clusters returned status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read Envoy admin /clusters response: %w", err) + } + seen := map[string]bool{} + for _, line := range strings.Split(string(body), "\n") { + name, _, ok := strings.Cut(line, "::") + if !ok { + continue + } + if strings.HasPrefix(name, prefix) { + seen[name] = true + } + } + names := make([]string, 0, len(seen)) + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + return names, nil +} + +// RegisterEnvoyAdminSteps registers steps that assert Envoy cluster identity +// via the admin endpoint. Capturing the exact cluster-name set before an API +// update and asserting it is unchanged afterwards proves the cluster NAME +// survived the update; substring checks on /clusters alone cannot prove that +// (an implementation renaming one hashed cluster to another would still pass +// a "contains prefix" check). +func RegisterEnvoyAdminSteps(ctx *godog.ScenarioContext) { + ctx.Before(func(c context.Context, sc *godog.Scenario) (context.Context, error) { + rememberedClusterSets = map[string][]string{} + return c, nil + }) + + ctx.Step(`^I capture the Envoy cluster names prefixed "([^"]*)"$`, func(prefix string) error { + names, err := fetchEnvoyClusterNames(prefix) + if err != nil { + return err + } + if len(names) == 0 { + return fmt.Errorf("no Envoy clusters with prefix %q found to capture", prefix) + } + rememberedClusterSets[prefix] = names + return nil + }) + + ctx.Step(`^the Envoy cluster names prefixed "([^"]*)" should be unchanged$`, func(prefix string) error { + captured, ok := rememberedClusterSets[prefix] + if !ok { + return fmt.Errorf("no captured cluster set for prefix %q; use the capture step first", prefix) + } + current, err := fetchEnvoyClusterNames(prefix) + if err != nil { + return err + } + if strings.Join(captured, ",") != strings.Join(current, ",") { + return fmt.Errorf("Envoy cluster set with prefix %q changed across the update: before=%v after=%v (cluster identity must be stable)", prefix, captured, current) + } + return nil + }) +} diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index 7eaaf1394..cb5a66d74 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -142,6 +142,7 @@ func getFeaturePaths() []string { "features/route-path-matching.feature", "features/secrets.feature", "features/template-functions.feature", + "features/api-level-url-stable.feature", // These tests require different gateway configurations and are not included in the default suite run. // "features/vhost-routing-single.feature", // cd it && make test-vhosts-single // "features/vhost-routing-multi.feature", // cd it && make test-vhosts-multi @@ -333,6 +334,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) { RegisterSubscriptionSteps(ctx, testState, httpSteps) RegisterSecretSteps(ctx, testState, httpSteps) RegisterTemplateSteps(ctx, testState, httpSteps) + RegisterEnvoyAdminSteps(ctx) } // Register common HTTP and assertion steps diff --git a/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md b/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md index bb7ad083a..90fdb1f74 100644 --- a/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md +++ b/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md @@ -661,8 +661,8 @@ For each API Configuration, create a RouteConfiguration: - **Routes**: One route per operation mapping `{method, path}` to cluster #### Cluster Creation -For each unique upstream URL, create a Cluster: -- **Name**: `cluster_{sanitized_upstream_url}` (e.g., `cluster_api_weather_com`) +For each API-level upstream (main/sandbox), create a Cluster: +- **Name**: `{env}_{hash}` where `hash` is the first 12 bytes of `sha256(apiID)` in hex (e.g., `main_f9811b73ac5d1a8db842634f`); main and sandbox share the hash, distinguished by the env prefix. The name is derived from the API's identity, not the URL, so a URL edit never renames the cluster: host/port/scheme changes are applied as an update to the same named cluster (warmed and swapped), path-only changes touch just the route rewrite, and routes and name-keyed stats stay continuous either way. - **Type**: `STRICT_DNS` or `LOGICAL_DNS` - **Load Assignment**: Endpoint with upstream host and port @@ -691,9 +691,9 @@ data: - **Listener**: `listener_http_8080` listening on `0.0.0.0:8080` - **Route**: `route_weather_api_v1_0` - Match: `GET /weather/{country_code}/{city}` - - Action: Forward to `cluster_api_weather_com` + - Action: Forward to `main_` (the API's main upstream cluster) - Prefix Rewrite: Prepend `/api/v2` → final path: `/api/v2/{country_code}/{city}` -- **Cluster**: `cluster_api_weather_com` +- **Cluster**: `main_` (identity-derived name, stable across URL edits) - Host: `api.weather.com:443` - TLS: Enabled (HTTPS upstream)