Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion gateway/gateway-controller/cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions gateway/gateway-controller/pkg/policyxds/policyxds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
},
Expand All @@ -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}},
},
Expand Down
25 changes: 12 additions & 13 deletions gateway/gateway-controller/pkg/transform/restapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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_<scheme>_<sanitized_host>").
// 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 "<env>_<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
Expand Down Expand Up @@ -337,7 +339,12 @@ func (t *RestAPITransformer) addUpstreamCluster(
basePath = "/"
}

clusterKey := fmt.Sprintf("upstream_%s_%s_%d", upstreamName, parsedURL.Hostname(), port)
// URL-stable cluster naming: "<env>_<sha256(apiID) fragment>" 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,
Expand All @@ -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.
Expand Down
190 changes: 187 additions & 3 deletions gateway/gateway-controller/pkg/transform/restapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ package transform

import (
"net/url"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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_<hash>), 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 "<env>_<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 <env>_<hash> 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 <env>_<hash> 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_<hash> 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)
}
40 changes: 40 additions & 0 deletions gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading