From 2f5d743112351ba0d47e9ae468b12eb9b2ea1aa0 Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Thu, 4 Jun 2026 05:13:40 +0530 Subject: [PATCH 1/9] feat(gateway): per-operation upstream reuses the upstream-definition cluster --- .../api/management-openapi.yaml | 35 + .../gateway-controller/cmd/controller/main.go | 5 + .../pkg/api/management/generated.go | 649 +++++++++++------- .../pkg/config/api_validator.go | 121 +++- .../pkg/config/validator_test.go | 235 +++++++ .../pkg/models/runtime_deploy_config.go | 9 +- .../gateway-controller/pkg/policy/builder.go | 7 +- .../pkg/policy/builder_test.go | 81 ++- .../pkg/policyxds/policyxds_test.go | 4 +- .../pkg/transform/restapi.go | 186 ++++- .../pkg/transform/restapi_test.go | 508 +++++++++++++- .../pkg/utils/clusterkey/clusterkey.go | 39 ++ .../pkg/utils/clusterkey/clusterkey_test.go | 55 ++ .../pkg/utils/upstreamref/upstreamref.go | 82 +++ .../pkg/utils/upstreamref/upstreamref_test.go | 189 +++++ .../gateway-controller/pkg/xds/translator.go | 200 ++++-- .../pkg/xds/translator_test.go | 186 +++-- .../tests/integration/storage_test.go | 85 +++ .../internal/kernel/translator_test.go | 56 ++ .../it/features/api-level-eds-stable.feature | 202 ++++++ .../it/features/per-op-upstream-basic.feature | 465 +++++++++++++ .../it/features/per-op-upstream-ref.feature | 338 +++++++++ .../per-op-upstream-validation.feature | 174 +++++ gateway/it/suite_test.go | 4 + 24 files changed, 3475 insertions(+), 440 deletions(-) create mode 100644 gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go create mode 100644 gateway/gateway-controller/pkg/utils/clusterkey/clusterkey_test.go create mode 100644 gateway/gateway-controller/pkg/utils/upstreamref/upstreamref.go create mode 100644 gateway/gateway-controller/pkg/utils/upstreamref/upstreamref_test.go create mode 100644 gateway/it/features/api-level-eds-stable.feature create mode 100644 gateway/it/features/per-op-upstream-basic.feature create mode 100644 gateway/it/features/per-op-upstream-ref.feature create mode 100644 gateway/it/features/per-op-upstream-validation.feature diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index bddc741df..ba86cea4c 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -4132,6 +4132,41 @@ components: description: List of policies applied only to this operation (overrides or adds to API-level policies) items: $ref: "#/components/schemas/Policy" + upstream: + $ref: "#/components/schemas/RestAPIOperationUpstream" + description: Per-operation upstream override with main and sandbox sub-fields. + + RestAPIOperationUpstream: + type: object + additionalProperties: false + description: Per-operation upstream override. Each sub-field must reference a named entry in spec.upstreamDefinitions. Missing sub-fields fall back to API-level upstream. At least one of main or sandbox must be set. + anyOf: + - required: [main] + - required: [sandbox] + properties: + main: + description: Production vhost override. Must reference a named upstreamDefinition. + allOf: + - $ref: "#/components/schemas/RestAPIOperationUpstreamTarget" + sandbox: + description: Sandbox vhost override. Must reference a named upstreamDefinition. + allOf: + - $ref: "#/components/schemas/RestAPIOperationUpstreamTarget" + + RestAPIOperationUpstreamTarget: + type: object + additionalProperties: false + required: + - ref + description: A ref-only upstream pointer for operation-level overrides. URLs are not permitted at the operation level; all backend URLs must be declared in spec.upstreamDefinitions and referenced by name. + properties: + ref: + type: string + description: Name of a predefined upstream declared in spec.upstreamDefinitions. + minLength: 1 + maxLength: 100 + pattern: '^[a-zA-Z0-9\-_]+$' + example: my-upstream-1 Policy: type: object diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 534cc794e..0143bbdf3 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -390,6 +390,11 @@ func main() { llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver) transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer) policyManager.SetTransformers(transformerRegistry) + // The policy manager receives the transformer registry; the snapshot + // manager's xDS translator does not. The main xDS flow therefore uses + // translateAPIConfig directly, while RuntimeDeployConfig output is + // consumed only by the policy xDS flow. Both flows derive the same + // cluster names. // 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/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index f5f556319..f37786718 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -1180,6 +1180,9 @@ type Operation struct { // Policies List of policies applied only to this operation (overrides or adds to API-level policies) Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + + // Upstream Per-operation upstream override. Each sub-field must reference a named entry in spec.upstreamDefinitions. Missing sub-fields fall back to API-level upstream. At least one of main or sandbox must be set. + Upstream *RestAPIOperationUpstream `json:"upstream,omitempty" yaml:"upstream,omitempty"` } // OperationMethod HTTP method @@ -1241,6 +1244,28 @@ type RestAPIApiVersion string // RestAPIKind API type type RestAPIKind string +// RestAPIOperationUpstream Per-operation upstream override. Each sub-field must reference a named entry in spec.upstreamDefinitions. Missing sub-fields fall back to API-level upstream. At least one of main or sandbox must be set. +type RestAPIOperationUpstream struct { + // Main Production vhost override. Must reference a named upstreamDefinition. + Main *RestAPIOperationUpstreamTarget `json:"main,omitempty" yaml:"main,omitempty"` + + // Sandbox Sandbox vhost override. Must reference a named upstreamDefinition. + Sandbox *RestAPIOperationUpstreamTarget `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + union json.RawMessage +} + +// RestAPIOperationUpstream0 defines model for . +type RestAPIOperationUpstream0 = interface{} + +// RestAPIOperationUpstream1 defines model for . +type RestAPIOperationUpstream1 = interface{} + +// RestAPIOperationUpstreamTarget A ref-only upstream pointer for operation-level overrides. URLs are not permitted at the operation level; all backend URLs must be declared in spec.upstreamDefinitions and referenced by name. +type RestAPIOperationUpstreamTarget struct { + // Ref Name of a predefined upstream declared in spec.upstreamDefinitions. + Ref string `json:"ref" yaml:"ref"` +} + // RestAPIRequest defines model for RestAPIRequest. type RestAPIRequest struct { // ApiVersion API specification version @@ -2340,6 +2365,116 @@ func (t *MCPProxyConfigData_Upstream) UnmarshalJSON(b []byte) error { return err } +// AsRestAPIOperationUpstream0 returns the union data inside the RestAPIOperationUpstream as a RestAPIOperationUpstream0 +func (t RestAPIOperationUpstream) AsRestAPIOperationUpstream0() (RestAPIOperationUpstream0, error) { + var body RestAPIOperationUpstream0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromRestAPIOperationUpstream0 overwrites any union data inside the RestAPIOperationUpstream as the provided RestAPIOperationUpstream0 +func (t *RestAPIOperationUpstream) FromRestAPIOperationUpstream0(v RestAPIOperationUpstream0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeRestAPIOperationUpstream0 performs a merge with any union data inside the RestAPIOperationUpstream, using the provided RestAPIOperationUpstream0 +func (t *RestAPIOperationUpstream) MergeRestAPIOperationUpstream0(v RestAPIOperationUpstream0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsRestAPIOperationUpstream1 returns the union data inside the RestAPIOperationUpstream as a RestAPIOperationUpstream1 +func (t RestAPIOperationUpstream) AsRestAPIOperationUpstream1() (RestAPIOperationUpstream1, error) { + var body RestAPIOperationUpstream1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromRestAPIOperationUpstream1 overwrites any union data inside the RestAPIOperationUpstream as the provided RestAPIOperationUpstream1 +func (t *RestAPIOperationUpstream) FromRestAPIOperationUpstream1(v RestAPIOperationUpstream1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeRestAPIOperationUpstream1 performs a merge with any union data inside the RestAPIOperationUpstream, using the provided RestAPIOperationUpstream1 +func (t *RestAPIOperationUpstream) MergeRestAPIOperationUpstream1(v RestAPIOperationUpstream1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t RestAPIOperationUpstream) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + if err != nil { + return nil, err + } + object := make(map[string]json.RawMessage) + if t.union != nil { + err = json.Unmarshal(b, &object) + if err != nil { + return nil, err + } + } + + if t.Main != nil { + object["main"], err = json.Marshal(t.Main) + if err != nil { + return nil, fmt.Errorf("error marshaling 'main': %w", err) + } + } + + if t.Sandbox != nil { + object["sandbox"], err = json.Marshal(t.Sandbox) + if err != nil { + return nil, fmt.Errorf("error marshaling 'sandbox': %w", err) + } + } + b, err = json.Marshal(object) + return b, err +} + +func (t *RestAPIOperationUpstream) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + if err != nil { + return err + } + object := make(map[string]json.RawMessage) + err = json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["main"]; found { + err = json.Unmarshal(raw, &t.Main) + if err != nil { + return fmt.Errorf("error reading 'main': %w", err) + } + } + + if raw, found := object["sandbox"]; found { + err = json.Unmarshal(raw, &t.Sandbox) + if err != nil { + return fmt.Errorf("error reading 'sandbox': %w", err) + } + } + + return err +} + // AsUpstream0 returns the union data inside the Upstream as a Upstream0 func (t Upstream) AsUpstream0() (Upstream0, error) { var body Upstream0 @@ -5003,261 +5138,265 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "BlfNy9OxLN3Mn2+f/38AAAD//3MFB/bRPwIA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/config/api_validator.go b/gateway/gateway-controller/pkg/config/api_validator.go index b2288b551..1b7cf02d5 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -26,6 +26,7 @@ import ( "time" api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/upstreamref" ) // APIValidator validates API configurations using rule-based validation @@ -36,6 +37,8 @@ type APIValidator struct { versionRegex *regexp.Regexp // urlFriendlyNameRegex matches URL-safe characters for API names urlFriendlyNameRegex *regexp.Regexp + // upstreamRefRegex enforces the schema pattern for per-op upstream refs + upstreamRefRegex *regexp.Regexp // policyValidator validates policy references and parameters policyValidator *PolicyValidator } @@ -46,6 +49,7 @@ func NewAPIValidator() *APIValidator { pathParamRegex: regexp.MustCompile(`\{[a-zA-Z0-9_]+\}`), versionRegex: regexp.MustCompile(`^v?\d+(\.\d+)?(\.\d+)?$`), urlFriendlyNameRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_\. ]+$`), + upstreamRefRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`), } } @@ -244,16 +248,10 @@ func (v *APIValidator) validateUpstreamRef(label string, ref *string, upstreamDe return errors } - // Check if the referenced definition exists - found := false - for _, def := range *upstreamDefinitions { - if def.Name == refName { - found = true - break - } - } - - if !found { + // Check if the referenced definition exists. Use the shared upstreamref helper + // for the membership lookup so API-level ref validation stays aligned with the + // per-op validator and the translators (one source of truth for ref lookup). + if _, err := upstreamref.FindByName(refName, upstreamDefinitions); err != nil { errors = append(errors, ValidationError{ Field: "spec.upstream." + label + ".ref", Message: fmt.Sprintf("Referenced upstream definition '%s' not found in upstreamDefinitions", refName), @@ -294,6 +292,23 @@ func (v *APIValidator) validateUpstreamDefinitions(definitions *[]api.UpstreamDe } namesSeen[def.Name] = true + // Enforce the same name contract the schema declares and that operation-level + // refs are validated against (^[a-zA-Z0-9\-_]+$, max 100 chars), so any valid + // definition name stays referenceable from a per-op upstream override. + if len(def.Name) > 100 { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("spec.upstreamDefinitions[%d].name", i), + Message: "Upstream definition name must not exceed 100 characters", + }) + continue + } else if !v.upstreamRefRegex.MatchString(def.Name) { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("spec.upstreamDefinitions[%d].name", i), + Message: "Upstream definition name must match pattern ^[a-zA-Z0-9\\-_]+$", + }) + continue + } + // Validate upstreams array if len(def.Upstreams) == 0 { errors = append(errors, ValidationError{ @@ -356,15 +371,23 @@ func (v *APIValidator) validateUpstreamDefinitions(definitions *[]api.UpstreamDe // Timeout validation is limited to connect timeout; request and idle // timeouts are no longer supported at the upstream definition level. + // Parsed inline rather than via upstreamref.ParseConnectTimeout so the two + // distinct, tested messages below (invalid-format vs non-positive) are kept; + // the shared helper collapses both into a single message. if def.Timeout != nil && def.Timeout.Connect != nil { timeoutStr := strings.TrimSpace(*def.Timeout.Connect) if timeoutStr != "" { - _, err := time.ParseDuration(timeoutStr) + d, err := time.ParseDuration(timeoutStr) if err != nil { errors = append(errors, ValidationError{ Field: fmt.Sprintf("spec.upstreamDefinitions[%d].timeout.connect", i), Message: fmt.Sprintf("Invalid timeout format: %v (expected format: '30s', '1m', '500ms')", err), }) + } else if d <= 0 { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("spec.upstreamDefinitions[%d].timeout.connect", i), + Message: "Connect timeout must be a positive duration", + }) } } } @@ -421,7 +444,7 @@ func (v *APIValidator) validateRestData(spec *api.APIConfigData) []ValidationErr } // Validate operations - errors = append(errors, v.validateOperations(spec.Operations)...) + errors = append(errors, v.validateOperations(spec.Operations, spec.UpstreamDefinitions)...) return errors } @@ -552,7 +575,7 @@ func (v *APIValidator) validatePathParametersForAsyncAPIs(path string) bool { } // validateOperations validates the operations configuration -func (v *APIValidator) validateOperations(operations []api.Operation) []ValidationError { +func (v *APIValidator) validateOperations(operations []api.Operation, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError { var errors []ValidationError if len(operations) == 0 { @@ -605,11 +628,83 @@ func (v *APIValidator) validateOperations(operations []api.Operation) []Validati Message: "Operation path has unbalanced braces in parameters", }) } + + // Validate per-operation upstream override (main / sandbox) + if op.Upstream != nil { + errors = append(errors, v.validateOperationUpstream(i, op.Upstream, upstreamDefinitions)...) + } } return errors } +// validateOperationUpstream validates per-operation upstream main and sandbox +// sub-fields. Operation-level upstreams are ref-only — direct URLs are not +// permitted. Each present sub-field must reference a named entry in +// spec.upstreamDefinitions. Error field paths are built as +// spec.operations[N].upstream..ref. +func (v *APIValidator) validateOperationUpstream(opIdx int, up *api.RestAPIOperationUpstream, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError { + var errors []ValidationError + if up == nil { + return errors + } + if up.Main == nil && up.Sandbox == nil { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("spec.operations[%d].upstream", opIdx), + Message: "At least one of 'main' or 'sandbox' must be set", + }) + return errors + } + if up.Main != nil { + errs := v.validateOperationUpstreamTarget(opIdx, "main", up.Main, upstreamDefinitions) + errors = append(errors, errs...) + } + if up.Sandbox != nil { + errs := v.validateOperationUpstreamTarget(opIdx, "sandbox", up.Sandbox, upstreamDefinitions) + errors = append(errors, errs...) + } + return errors +} + +// validateOperationUpstreamTarget validates a single ref-only operation-level +// upstream target. The ref must resolve to a named entry in upstreamDefinitions. +func (v *APIValidator) validateOperationUpstreamTarget(opIdx int, sub string, target *api.RestAPIOperationUpstreamTarget, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError { + field := fmt.Sprintf("spec.operations[%d].upstream.%s.ref", opIdx, sub) + + refName := strings.TrimSpace(target.Ref) + if refName == "" { + return []ValidationError{{ + Field: field, + Message: "Upstream ref is required", + }} + } + + if len(refName) > 100 { + return []ValidationError{{ + Field: field, + Message: "Upstream ref must not exceed 100 characters", + }} + } + + if !v.upstreamRefRegex.MatchString(refName) { + return []ValidationError{{ + Field: field, + Message: "Upstream ref must match pattern ^[a-zA-Z0-9\\-_]+$", + }} + } + + // Resolve through the shared upstreamref helper so the validator stays aligned + // with the xDS translator and RDC transformer (one source of truth for ref lookup). + if _, err := upstreamref.FindByName(refName, upstreamDefinitions); err != nil { + return []ValidationError{{ + Field: field, + Message: fmt.Sprintf("Referenced upstream definition '%s' not found in upstreamDefinitions", refName), + }} + } + + return nil +} + // validatePathParameters checks if path parameters have balanced braces func (v *APIValidator) validatePathParameters(path string) bool { openCount := strings.Count(path, "{") diff --git a/gateway/gateway-controller/pkg/config/validator_test.go b/gateway/gateway-controller/pkg/config/validator_test.go index a154db16f..91201f249 100644 --- a/gateway/gateway-controller/pkg/config/validator_test.go +++ b/gateway/gateway-controller/pkg/config/validator_test.go @@ -811,6 +811,62 @@ func TestValidateUpstreamDefinitions_NoTimeout(t *testing.T) { assert.Empty(t, errors, "No timeout should be valid") } +func TestValidateUpstreamDefinitions_NonPositiveTimeout(t *testing.T) { + validator := NewAPIValidator() + + for _, badTimeout := range []string{"0s", "-5s"} { + connect := badTimeout + definitions := &[]api.UpstreamDefinition{ + { + Name: "my-upstream", + Timeout: &api.UpstreamTimeout{ + Connect: &connect, + }, + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + { + Url: "http://backend:8080", + }, + }, + }, + } + + errors := validator.validateUpstreamDefinitions(definitions) + require.Len(t, errors, 1, "timeout %q must be rejected", badTimeout) + assert.Equal(t, "spec.upstreamDefinitions[0].timeout.connect", errors[0].Field) + assert.Contains(t, errors[0].Message, "must be a positive duration") + } +} + +func TestValidateUpstreamDefinitions_MalformedTimeout(t *testing.T) { + validator := NewAPIValidator() + + connect := "abc" + definitions := &[]api.UpstreamDefinition{ + { + Name: "my-upstream", + Timeout: &api.UpstreamTimeout{ + Connect: &connect, + }, + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + { + Url: "http://backend:8080", + }, + }, + }, + } + + errors := validator.validateUpstreamDefinitions(definitions) + require.Len(t, errors, 1) + assert.Equal(t, "spec.upstreamDefinitions[0].timeout.connect", errors[0].Field) + assert.Contains(t, errors[0].Message, "Invalid timeout format") +} + func TestValidateUpstreamRef_ValidRef(t *testing.T) { validator := NewAPIValidator() @@ -892,3 +948,182 @@ func TestValidateUpstream_WithRefAndDefinitions(t *testing.T) { errors := validator.validateUpstream("main", upstream, definitions) assert.Empty(t, errors) } + +// TestValidateOperationUpstream_ValidRef asserts that a well-formed ref passes validation +// when it resolves to a known upstreamDefinition. +func TestValidateOperationUpstream_ValidRef(t *testing.T) { + validator := NewAPIValidator() + definitions := &[]api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + } + up := &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-cluster"}, + } + + errors := validator.validateOperationUpstream(0, up, definitions) + assert.Empty(t, errors) +} + +// TestValidateOperationUpstream_EmptyRef asserts that an empty ref is rejected +// with a per-op-scoped error field path. +func TestValidateOperationUpstream_EmptyRef(t *testing.T) { + validator := NewAPIValidator() + up := &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: ""}, + } + + errors := validator.validateOperationUpstream(2, up, nil) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[2].upstream.main") { + found = true + break + } + } + assert.True(t, found, "validation error should be scoped to spec.operations[2].upstream.main, got %+v", errors) +} + +// TestValidateOperationUpstream_UnknownRef asserts that a ref not matching any +// upstreamDefinition is rejected. +func TestValidateOperationUpstream_UnknownRef(t *testing.T) { + validator := NewAPIValidator() + up := &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "missing-cluster"}, + } + definitions := &[]api.UpstreamDefinition{ + { + Name: "user-svc-cluster", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://user-svc:8080"}, + }, + }, + } + + errors := validator.validateOperationUpstream(0, up, definitions) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.main.ref") { + found = true + break + } + } + assert.True(t, found, "expected unknown-ref error scoped to main.ref, got %+v", errors) +} + +// TestValidateOperationUpstream_EmptyWrapper asserts that a wrapper with neither +// main nor sandbox set is rejected. +func TestValidateOperationUpstream_EmptyWrapper(t *testing.T) { + validator := NewAPIValidator() + up := &api.RestAPIOperationUpstream{} + + errors := validator.validateOperationUpstream(3, up, nil) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if e.Field == "spec.operations[3].upstream" && + strings.Contains(strings.ToLower(e.Message), "at least one") { + found = true + break + } + } + assert.True(t, found, "expected anyOf error at wrapper level, got %+v", errors) +} + +func TestValidateOperationUpstream_EmptyLeaf(t *testing.T) { + validator := NewAPIValidator() + up := &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: ""}, // empty ref + } + + errors := validator.validateOperationUpstream(0, up, nil) + require.NotEmpty(t, errors, "empty ref must be rejected") + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.main.ref") { + found = true + break + } + } + assert.True(t, found, "expected rejection scoped to main.ref, got %+v", errors) +} + +// TestValidateOperationUpstream_RefPatternRejected asserts that a ref containing +// characters outside ^[a-zA-Z0-9\-_]+$ is rejected before the existence check. +func TestValidateOperationUpstream_RefPatternRejected(t *testing.T) { + validator := NewAPIValidator() + definitions := &[]api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + } + + for _, badRef := range []string{"bad/ref", "bad ref", "bad.ref!", "../etc"} { + up := &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: badRef}, + } + errors := validator.validateOperationUpstream(0, up, definitions) + require.NotEmpty(t, errors, "ref %q must be rejected", badRef) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.main.ref") && + strings.Contains(e.Message, "must match pattern") { + found = true + break + } + } + assert.True(t, found, "expected pattern-rejection error for ref %q, got %+v", badRef, errors) + } +} + +// TestValidateOperationUpstream_RefMaxLength asserts that a ref longer than 100 +// characters is rejected, matching the OpenAPI schema maxLength constraint. +func TestValidateOperationUpstream_RefMaxLength(t *testing.T) { + validator := NewAPIValidator() + longRef := strings.Repeat("a", 101) + exactRef := strings.Repeat("b", 100) + definitions := &[]api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + {Name: longRef, Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://long-svc:8080"}}}, + {Name: exactRef, Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://exact-svc:8080"}}}, + } + + up := &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: longRef}, + } + errors := validator.validateOperationUpstream(0, up, definitions) + require.NotEmpty(t, errors, "ref longer than 100 chars must be rejected") + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.main.ref") && + strings.Contains(e.Message, "must not exceed 100 characters") { + found = true + break + } + } + assert.True(t, found, "expected maxLength-rejection error for ref of len %d, got %+v", len(longRef), errors) + + // Boundary: exactly 100 characters should pass + up = &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: exactRef}, + } + errors = validator.validateOperationUpstream(0, up, definitions) + assert.Empty(t, errors, "ref of exactly 100 chars must pass") +} diff --git a/gateway/gateway-controller/pkg/models/runtime_deploy_config.go b/gateway/gateway-controller/pkg/models/runtime_deploy_config.go index 8dec6fa8c..217bbcb69 100644 --- a/gateway/gateway-controller/pkg/models/runtime_deploy_config.go +++ b/gateway/gateway-controller/pkg/models/runtime_deploy_config.go @@ -88,10 +88,11 @@ type Policy struct { // UpstreamCluster represents an Envoy cluster with its endpoints. type UpstreamCluster struct { - Name string // upstream definition name; "" for the main/sandbox slot clusters - BasePath string - Endpoints []Endpoint - TLS *UpstreamTLS + Name string // upstream definition name; "" for the main/sandbox slot clusters + BasePath string + Endpoints []Endpoint + TLS *UpstreamTLS + ConnectTimeout *time.Duration } // Endpoint is a single upstream host:port target. diff --git a/gateway/gateway-controller/pkg/policy/builder.go b/gateway/gateway-controller/pkg/policy/builder.go index 3cf3ce738..a1aa4b756 100644 --- a/gateway/gateway-controller/pkg/policy/builder.go +++ b/gateway/gateway-controller/pkg/policy/builder.go @@ -179,7 +179,12 @@ func DerivePolicyFromAPIConfig(cfg *models.StoredConfig, routerConfig *config.Ro } vhosts := []string{effectiveMainVHost} - if apiData.Upstream.Sandbox != nil { + apiSandboxHasContent := 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) != "")) + perOpSandboxHasContent := op.Upstream != nil && op.Upstream.Sandbox != nil && + strings.TrimSpace(op.Upstream.Sandbox.Ref) != "" + if apiSandboxHasContent || perOpSandboxHasContent { vhosts = append(vhosts, effectiveSandboxVHost) } diff --git a/gateway/gateway-controller/pkg/policy/builder_test.go b/gateway/gateway-controller/pkg/policy/builder_test.go index 572646cc5..40c706c85 100644 --- a/gateway/gateway-controller/pkg/policy/builder_test.go +++ b/gateway/gateway-controller/pkg/policy/builder_test.go @@ -113,7 +113,7 @@ func TestDerivePolicyFromAPIConfig_SandboxVhosts(t *testing.T) { { name: "sandbox present but url and ref both nil", sandbox: &api.Upstream{}, - wantRouteCount: 2, + wantRouteCount: 1, }, } @@ -127,16 +127,18 @@ func TestDerivePolicyFromAPIConfig_SandboxVhosts(t *testing.T) { assert.Len(t, result.Configuration.Routes, tc.wantRouteCount, "expected %d route key(s) for case %q", tc.wantRouteCount, tc.name) - if tc.sandbox != nil { - // Verify that the sandbox vhost ("sandbox.local") appears in at least one route key. - hasSandboxRoute := false + if tc.sandbox != nil && (tc.sandbox.Url != nil || tc.sandbox.Ref != nil) { + var mainRoutes, sandboxRoutes int for _, r := range result.Configuration.Routes { + if strings.Contains(r.RouteKey, "main.local") { + mainRoutes++ + } if strings.Contains(r.RouteKey, "sandbox.local") { - hasSandboxRoute = true - break + sandboxRoutes++ } } - assert.True(t, hasSandboxRoute, "expected a route key containing 'sandbox.local'") + assert.Equal(t, 1, mainRoutes, "expected exactly one route key containing 'main.local'") + assert.Equal(t, 1, sandboxRoutes, "expected exactly one route key containing 'sandbox.local'") } }) } @@ -228,7 +230,7 @@ func TestDerivePolicyFromAPIConfig_OperationLevelEmptyVersionResolvesToLatest(t // TestDerivePolicyFromAPIConfig_UnknownPolicySkipped verifies that a policy not present // in the definitions is silently skipped and does not cause a panic or error. func TestDerivePolicyFromAPIConfig_UnknownPolicySkipped(t *testing.T) { - defs := map[string]models.PolicyDefinition{} // empty — policy won't be found + defs := map[string]models.PolicyDefinition{} // empty - policy won't be found apiConfig := api.RestAPI{ Kind: api.RestAPIKindRestApi, @@ -256,3 +258,66 @@ func TestDerivePolicyFromAPIConfig_UnknownPolicySkipped(t *testing.T) { result := DerivePolicyFromAPIConfig(cfg, testRouterConfig(), &config.Config{}, defs) assert.Nil(t, result, "expected nil result when all policies are unresolvable") } + +// TestDerivePolicyFromAPIConfig_PerOpSandboxWithoutAPISandbox - when an API has NO +// API-level sandbox but an operation declares a per-op sandbox upstream, the policy +// builder must still emit a sandbox policy chain for that operation so the sandbox +// route created by the transformer gets policies attached. Without this, sandbox +// traffic on per-op sandbox routes would run with no policies (no auth, no rate +// limiting, no analytics). +func TestDerivePolicyFromAPIConfig_PerOpSandboxWithoutAPISandbox(t *testing.T) { + apiConfig := api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "test-api"}, + Spec: api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "1.0.0", + UpstreamDefinitions: &[]api.UpstreamDefinition{ + {Name: "op-sb-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://op-sb:9000"}}}, + }, + Operations: []api.Operation{ + { + Method: "GET", Path: "/hello", + Upstream: &api.RestAPIOperationUpstream{ + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "op-sb-cluster"}, + }, + }, + }, + Policies: &[]api.Policy{{Name: "header-mutate", Version: "v1"}}, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: ptr("http://backend:8080")}, + Sandbox: nil, // intentionally no API-level sandbox + }, + }, + } + cfg := &models.StoredConfig{ + UUID: "test-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: apiConfig, + SourceConfiguration: apiConfig, + } + + result := DerivePolicyFromAPIConfig(cfg, testRouterConfig(), &config.Config{}, policyDefs) + require.NotNil(t, result, "expected non-nil policy config (API has policies)") + require.Len(t, result.Configuration.Routes, 2, + "expected both main and sandbox policy chains for an operation with per-op sandbox") + + var mainRoutes, sandboxRoutes int + for _, r := range result.Configuration.Routes { + if strings.Contains(r.RouteKey, "main.local") { + mainRoutes++ + } + if strings.Contains(r.RouteKey, "sandbox.local") { + sandboxRoutes++ + } + } + assert.Equal(t, 1, mainRoutes, "main vhost route must be emitted exactly once for per-op sandbox operation") + assert.Equal(t, 1, sandboxRoutes, "sandbox vhost route must be emitted exactly once for per-op sandbox operation") +} 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..10f411a93 100644 --- a/gateway/gateway-controller/pkg/transform/restapi.go +++ b/gateway/gateway-controller/pkg/transform/restapi.go @@ -31,6 +31,8 @@ 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/utils/upstreamref" "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" @@ -140,9 +142,23 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim // 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 && + apiSandboxHasContent := 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) != "")) + hasSandbox := apiSandboxHasContent + + // Also active if any operation defines a per-op sandbox upstream. + if !hasSandbox { + for _, op := range apiData.Operations { + if op.Upstream != nil && op.Upstream.Sandbox != nil { + sb := op.Upstream.Sandbox + if strings.TrimSpace(sb.Ref) != "" { + hasSandbox = true + break + } + } + } + } // Guard: sandbox and main vhosts must differ, otherwise sandbox routes would // overwrite main routes (same route key) and the sandbox patch would leave only @@ -153,25 +169,92 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim // Build routes and policy chains for each operation for _, op := range apiData.Operations { + mainVhostClusterKey := mainUpstream.ClusterKey + mainVhostUseClusterHeader := useClusterHeader + mainVhostDefaultCluster := defaultCluster + mainVhostAutoHostRewrite := mainAutoHostRewrite + + sandboxVhostClusterKey := mainUpstream.ClusterKey + sandboxVhostUseClusterHeader := useClusterHeader + sandboxVhostDefaultCluster := defaultCluster + // Sandbox routes inherit the API-level sandbox HostRewrite when an API-level + // sandbox is configured; otherwise they fall back to the API-level main + // setting. This keeps per-op sandbox override routes (which carry no + // HostRewrite of their own) consistent with the xDS translator path. + sandboxVhostAutoHostRewrite := mainAutoHostRewrite + if apiSandboxHasContent { + sandboxVhostAutoHostRewrite = true + if apiData.Upstream.Sandbox.HostRewrite != nil && *apiData.Upstream.Sandbox.HostRewrite == api.Manual { + sandboxVhostAutoHostRewrite = false + } + } + + if op.Upstream != nil { + if op.Upstream.Main != nil { + defClusterKey, err := perOpDefinitionClusterKey(cfg.Kind, cfg.UUID, op.Upstream.Main, apiData.UpstreamDefinitions) + if err != nil { + return nil, fmt.Errorf("per-op main upstream for %s %s: %w", string(op.Method), op.Path, err) + } + // Reuse the referenced upstreamDefinition's cluster (registered + // unconditionally below) instead of minting a per-op cluster. Keep + // cluster_header ON with that cluster as the default so a dynamic-endpoint + // policy can still steer this operation. Precedence: + // op-policy > api-policy > per-op ref > api-level upstream. + mainVhostClusterKey = defClusterKey + mainVhostUseClusterHeader = true + mainVhostDefaultCluster = defClusterKey + // AutoHostRewrite inherits API-level setting; per-op target is ref-only with no HostRewrite field. + } + if op.Upstream.Sandbox != nil { + defClusterKey, err := perOpDefinitionClusterKey(cfg.Kind, cfg.UUID, op.Upstream.Sandbox, apiData.UpstreamDefinitions) + if err != nil { + return nil, fmt.Errorf("per-op sandbox upstream for %s %s: %w", string(op.Method), op.Path, err) + } + // Keep cluster_header ON for the sandbox per-op route too, so a sandbox + // dynamic-endpoint policy can override the per-op default — consistent with + // the API-level sandbox routing fixed in #2059 (no static-pin bypass). + sandboxVhostClusterKey = defClusterKey + sandboxVhostUseClusterHeader = true + sandboxVhostDefaultCluster = defClusterKey + // AutoHostRewrite inherits API-level setting; per-op target is ref-only with no HostRewrite field. + } + } + vhosts := []string{effectiveMainVHost} if hasSandbox { - vhosts = append(vhosts, effectiveSandboxVHost) + // Only add sandbox vhost when this specific op has sandbox config - + // either via API-level sandbox fallback OR a per-op sandbox override. + // Without this guard, ops with no sandbox config would get a sandbox + // route silently pointing to the main cluster (placeholder default). + if apiSandboxHasContent || (op.Upstream != nil && op.Upstream.Sandbox != nil) { + vhosts = append(vhosts, effectiveSandboxVHost) + } } for _, vhost := range vhosts { routeKey := xds.GenerateRouteName(string(op.Method), apiData.Context, apiData.Version, op.Path, vhost) - // Build route + clusterKey := mainVhostClusterKey + vhostUseClusterHeader := mainVhostUseClusterHeader + vhostDefaultCluster := mainVhostDefaultCluster + autoHostRewrite := mainVhostAutoHostRewrite + if vhost == effectiveSandboxVHost { + clusterKey = sandboxVhostClusterKey + vhostUseClusterHeader = sandboxVhostUseClusterHeader + vhostDefaultCluster = sandboxVhostDefaultCluster + autoHostRewrite = sandboxVhostAutoHostRewrite + } + rdc.Routes[routeKey] = &models.Route{ Method: string(op.Method), Path: xds.ConstructFullPath(apiData.Context, apiData.Version, op.Path), OperationPath: op.Path, Vhost: vhost, - AutoHostRewrite: mainAutoHostRewrite, + AutoHostRewrite: autoHostRewrite, Upstream: models.RouteUpstream{ - ClusterKey: mainUpstream.ClusterKey, - UseClusterHeader: useClusterHeader, - DefaultCluster: defaultCluster, + ClusterKey: clusterKey, + UseClusterHeader: vhostUseClusterHeader, + DefaultCluster: vhostDefaultCluster, }, } @@ -200,6 +283,8 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim if def.BasePath != nil && *def.BasePath != "" { basePath = *def.BasePath } + // ConnectTimeout omitted here as well; see addUpstreamCluster for why + // (this RDC path feeds only the policy xDS). rdc.UpstreamClusters[defClusterKey] = &models.UpstreamCluster{ Name: def.Name, BasePath: basePath, @@ -212,8 +297,9 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim } } - // Add sandbox upstream and update sandbox routes if present - if hasSandbox { + // Add sandbox upstream and update sandbox routes if present. + // API-level sandbox is optional when per-op sandbox overrides exist. + if hasSandbox && apiSandboxHasContent { sbUpstream, err := t.addUpstreamCluster(rdc, "sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) if err != nil { return nil, fmt.Errorf("failed to resolve sandbox upstream: %w", err) @@ -224,8 +310,12 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim sbAutoHostRewrite = false } - // Update sandbox vhost routes to point to sandbox cluster + // Update sandbox vhost routes to point to sandbox cluster, except ops with + // their own per-op sandbox override (already wired in the main loop). for _, op := range apiData.Operations { + if op.Upstream != nil && op.Upstream.Sandbox != nil { + continue + } routeKey := xds.GenerateRouteName(string(op.Method), apiData.Context, apiData.Version, op.Path, effectiveSandboxVHost) if r, exists := rdc.Routes[routeKey]; exists { r.Upstream.ClusterKey = sbUpstream.ClusterKey @@ -298,9 +388,11 @@ 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 EDS-stable hashed name "_<16-hex>" (matching ClusterKey). For + // per-op upstreams it is empty because the route resolves the cluster via + // ClusterKey directly. 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,8 +429,17 @@ func (t *RestAPITransformer) addUpstreamCluster( basePath = "/" } - clusterKey := fmt.Sprintf("upstream_%s_%s_%d", upstreamName, parsedURL.Hostname(), port) + // EDS-stable cluster naming: derived from sha256(apiID|env) so URL edits + // propagate as endpoint updates rather than cluster recreates. ClusterKey and + // EnvoyClusterName are intentionally the same string so the policy engine's + // `default_upstream_cluster` metadata points at the actual Envoy cluster. + clusterKey := upstreamName + "_" + clusterkey.APILevel(rdc.Metadata.UUID, upstreamName) + // ConnectTimeout is intentionally not set on this RDC cluster: the data plane + // resolves API-level timeouts via the legacy xDS translator, and this RDC path + // feeds only the policy xDS, which does not read UpstreamCluster.ConnectTimeout. + // If a transformer is ever wired onto the data-plane xDS, resolve it here via + // upstreamref.ResolveConnectTimeout to keep parity with the legacy path. rdc.UpstreamClusters[clusterKey] = &models.UpstreamCluster{ BasePath: basePath, Endpoints: []models.Endpoint{{ @@ -348,19 +449,33 @@ func (t *RestAPITransformer) addUpstreamCluster( TLS: &models.UpstreamTLS{Enabled: parsedURL.Scheme == "https"}, } + // ClusterKey and EnvoyClusterName must be the same string. If they differ, + // the default_upstream_cluster metadata written by the policy engine will + // not match the Envoy cluster name, producing 503 NoRoute when the default + // upstream path is taken. 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 +// perOpDefinitionClusterKey resolves a ref-only per-op upstream target to the cluster +// key of the referenced upstreamDefinition. That definition's cluster is registered +// unconditionally (see the upstreamDefinition cluster loop in Transform), so a per-op +// route reuses it rather than minting its own cluster. One cluster per definition +// serves both the main and sandbox vhosts — the key carries no env component. Reuse +// also means the route inherits the definition's authoritative basePath (#2065), +// avoiding the URL-derived-basePath divergence a separate per-op cluster would have. +func perOpDefinitionClusterKey(kind, uuid string, target *api.RestAPIOperationUpstreamTarget, upstreamDefinitions *[]api.UpstreamDefinition) (string, error) { + def, err := upstreamref.FindByName(target.Ref, upstreamDefinitions) + if err != nil { + return "", err + } + if len(def.Upstreams) == 0 || def.Upstreams[0].Url == "" { + return "", fmt.Errorf("upstream definition '%s' has no URLs", strings.TrimSpace(target.Ref)) + } + return "upstream_" + kind + "_" + uuid + "_" + SanitizeUpstreamDefinitionName(def.Name), nil } // resolveUpstreamURL resolves the URL from an upstream (direct URL or ref). For a ref it @@ -372,22 +487,23 @@ func resolveUpstreamURL(name string, up *api.Upstream, defs *[]api.UpstreamDefin } if up.Ref != nil && strings.TrimSpace(*up.Ref) != "" { refName := strings.TrimSpace(*up.Ref) - if defs == nil { - return "", nil, fmt.Errorf("upstream definition '%s' referenced but no definitions provided", refName) + // Resolve through the shared upstreamref helper (one source of truth for ref + // lookup, shared with the per-op transformer and the xDS translator), and return + // the definition's base path (from basePath, #2065) so the caller rewrites the + // upstream path correctly. The "no URLs" check stays here since FindByName + // resolves the definition, not its endpoints. + def, err := upstreamref.FindByName(refName, defs) + if err != nil { + return "", nil, err } - for _, def := range *defs { - if def.Name == refName { - if len(def.Upstreams) == 0 || def.Upstreams[0].Url == "" { - return "", nil, fmt.Errorf("upstream definition '%s' has no URLs", refName) - } - basePath := "" - if def.BasePath != nil { - basePath = *def.BasePath - } - return def.Upstreams[0].Url, &basePath, nil - } + if len(def.Upstreams) == 0 || def.Upstreams[0].Url == "" { + return "", nil, fmt.Errorf("upstream definition '%s' has no URLs", refName) + } + basePath := "" + if def.BasePath != nil { + basePath = *def.BasePath } - return "", nil, fmt.Errorf("upstream definition '%s' not found", refName) + return def.Upstreams[0].Url, &basePath, nil } return "", nil, fmt.Errorf("%s upstream has no URL or ref", name) } diff --git a/gateway/gateway-controller/pkg/transform/restapi_test.go b/gateway/gateway-controller/pkg/transform/restapi_test.go index 5edf451df..90a647d01 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. @@ -150,7 +152,7 @@ func TestRestAPITransformer_OperationLevelEmptyVersionResolvesToLatest(t *testin // TestRestAPITransformer_UnknownPolicySkipped verifies that a policy not present in // the definitions is silently excluded from the policy chain without causing an error. func TestRestAPITransformer_UnknownPolicySkipped(t *testing.T) { - defs := map[string]models.PolicyDefinition{} // empty — policy won't resolve + defs := map[string]models.PolicyDefinition{} // empty - policy won't resolve transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, defs) cfg := makeRestAPIStoredConfig( @@ -324,6 +326,400 @@ func TestResolveUpstreamURL(t *testing.T) { }) } +// makeRestAPIWithOps builds a RestAPI StoredConfig with caller-supplied operations, +// both API-level main and sandbox upstreams configured, and a set of common +// upstreamDefinitions that per-op tests can reference by name. +func makeRestAPIWithOps(ops []api.Operation) *models.StoredConfig { + defs := []api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + {Name: "user-svc-test-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc-test:8080"}}}, + {Name: "shared-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://shared-svc:8080"}}}, + {Name: "same-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://same-svc:8080"}}}, + {Name: "user-svc-cluster-v2", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:9090"}}}, + {Name: "per-op-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://per-op-main:9090"}}}, + } + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "1.0.0", + Operations: ops, + UpstreamDefinitions: &defs, + 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_PerOpMainOverridesMainVhost asserts that a main-only override +// causes the main vhost route to use the definition cluster while the sandbox vhost route +// falls back to the API-level sandbox cluster. +func TestRestAPITransformer_PerOpMainOverridesMainVhost(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-cluster"}, + }, + }, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + require.NotNil(t, rdc) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute) + assert.True(t, strings.HasPrefix(mainRoute.Upstream.ClusterKey, "upstream_"), + "main vhost should use definition cluster, got %q", mainRoute.Upstream.ClusterKey) + // Per-op main is dynamic: cluster_header ON with the definition cluster as the + // default, so a dynamic-endpoint policy can still steer it while a no-policy + // request falls back to the per-op ref. + assert.True(t, mainRoute.Upstream.UseClusterHeader, + "per-op main route should use cluster_header so policies can override") + assert.Equal(t, mainRoute.Upstream.ClusterKey, mainRoute.Upstream.DefaultCluster, + "per-op main DefaultCluster must be the definition cluster key") + + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, sandboxRoute) + assert.False(t, strings.HasPrefix(sandboxRoute.Upstream.ClusterKey, "upstream_"), + "sandbox vhost should fall back to API sandbox, got %q", sandboxRoute.Upstream.ClusterKey) +} + +// TestRestAPITransformer_PerOpSandboxOverridesSandboxVhost asserts that a sandbox-only override +// causes the main vhost to fall back to the API main while the sandbox vhost uses the definition cluster. +func TestRestAPITransformer_PerOpSandboxOverridesSandboxVhost(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-test-cluster"}, + }, + }, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute) + assert.False(t, strings.HasPrefix(mainRoute.Upstream.ClusterKey, "upstream_"), + "main vhost should fall back to API main, got %q", mainRoute.Upstream.ClusterKey) + + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, sandboxRoute) + assert.True(t, strings.HasPrefix(sandboxRoute.Upstream.ClusterKey, "upstream_"), + "sandbox vhost should use definition cluster, got %q", sandboxRoute.Upstream.ClusterKey) +} + +// TestRestAPITransformer_PerOpBothOverrideBothVhosts asserts that both vhosts get distinct +// definition clusters when main and sandbox are overridden. +func TestRestAPITransformer_PerOpBothOverrideBothVhosts(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-cluster"}, + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-test-cluster"}, + }, + }, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, mainRoute) + require.NotNil(t, sandboxRoute) + + assert.True(t, strings.HasPrefix(mainRoute.Upstream.ClusterKey, "upstream_")) + assert.True(t, strings.HasPrefix(sandboxRoute.Upstream.ClusterKey, "upstream_")) + assert.NotEqual(t, mainRoute.Upstream.ClusterKey, sandboxRoute.Upstream.ClusterKey, + "main and sandbox per-op vhosts must produce distinct cluster keys (definition names differ)") +} + +// TestRestAPITransformer_NoPerOpUsesAPILevelClusters - regression - without per-op +// upstream the routes still use the API-level main/sandbox clusters. +func TestRestAPITransformer_NoPerOpUsesAPILevelClusters(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) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, mainRoute) + require.NotNil(t, sandboxRoute) + assert.False(t, strings.HasPrefix(mainRoute.Upstream.ClusterKey, "upstream_")) + assert.False(t, strings.HasPrefix(sandboxRoute.Upstream.ClusterKey, "upstream_")) +} + +// TestRestAPITransformer_TwoOpsSameRefReuseOneCluster verifies the core reuse +// property: two operations referencing the SAME upstreamDefinition reuse exactly +// ONE definition cluster (no definition clusters), and both routes point at it. +func TestRestAPITransformer_TwoOpsSameRefReuseOneCluster(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: "GET", Path: "/users", Upstream: &api.RestAPIOperationUpstream{Main: &api.RestAPIOperationUpstreamTarget{Ref: "shared-svc"}}}, + {Method: "POST", Path: "/users", Upstream: &api.RestAPIOperationUpstream{Main: &api.RestAPIOperationUpstreamTarget{Ref: "shared-svc"}}}, + }) + spec := cfg.Configuration.(api.RestAPI) + spec.Spec.UpstreamDefinitions = &[]api.UpstreamDefinition{ + { + Name: "shared-svc", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://shared-svc:8080"}, + }, + }, + } + cfg.Configuration = spec + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + getRoute := rdc.Routes["GET|/test/users|main.local"] + postRoute := rdc.Routes["POST|/test/users|main.local"] + require.NotNil(t, getRoute, "GET route must exist") + require.NotNil(t, postRoute, "POST route must exist") + + // Both ops reuse the SAME definition cluster (no definition clusters). + assert.Equal(t, getRoute.Upstream.ClusterKey, postRoute.Upstream.ClusterKey, + "two ops sharing a ref must reuse the same definition cluster") + assert.True(t, strings.HasPrefix(getRoute.Upstream.ClusterKey, "upstream_"), + "per-op route must reuse the upstream_ definition cluster, got %q", getRoute.Upstream.ClusterKey) + + // Exactly ONE cluster registered for shared-svc; zero op_ clusters. + shared := 0 + for k := range rdc.UpstreamClusters { + assert.False(t, strings.HasPrefix(k, "op_"), "no per-op (op_) clusters may be minted, got %q", k) + if strings.Contains(k, "shared-svc") { + shared++ + } + } + assert.Equal(t, 1, shared, "shared-svc must produce exactly one reused definition cluster") +} + +// TestRestAPITransformer_PerOpClusterIsolatedAcrossAPIs asserts that two APIs with the +// same operation referencing the same definition produce different definition cluster +// keys because the API ID is part of the cluster name. +func TestRestAPITransformer_PerOpClusterIsolatedAcrossAPIs(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + cfgA := makeRestAPIWithOps([]api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "shared-svc-cluster"}, + }, + }, + }) + cfgA.UUID = "api-aaa" + + cfgB := makeRestAPIWithOps([]api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "shared-svc-cluster"}, + }, + }, + }) + cfgB.UUID = "api-bbb" + + rdcA, err := transformer.Transform(cfgA) + require.NoError(t, err) + rdcB, err := transformer.Transform(cfgB) + require.NoError(t, err) + + var keyA, keyB string + for k := range rdcA.UpstreamClusters { + if strings.HasPrefix(k, "upstream_") { + keyA = k + } + } + for k := range rdcB.UpstreamClusters { + if strings.HasPrefix(k, "upstream_") { + keyB = k + } + } + + require.NotEmpty(t, keyA) + require.NotEmpty(t, keyB) + assert.NotEqual(t, keyA, keyB, "same URL across different APIs must produce different definition cluster keys") +} + +// TestRestAPITransformer_PerOpSandboxWithoutAPILevelSandbox - guard regression. +// API-level Sandbox is nil, but one op declares a per-op sandbox upstream. The +// sandbox vhost must be created only for that op; ops without per-op sandbox +// must NOT get a sandbox route (otherwise they'd silently route to the main +// cluster on the sandbox vhost). +func TestRestAPITransformer_PerOpSandboxWithoutAPILevelSandbox(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + sbDefs := []api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc-test:8080"}}}, + } + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "1.0.0", + UpstreamDefinitions: &sbDefs, + Operations: []api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-cluster"}, + }, + }, + {Method: "GET", Path: "/orders"}, + }, + 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: nil, + }, + } + cfg := &models.StoredConfig{ + UUID: "test-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "test-api"}, + Spec: apiData, + }, + } + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + require.NotNil(t, rdc) + + usersMain := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, usersMain, "op with per-op sandbox must still have a main route") + assert.False(t, strings.HasPrefix(usersMain.Upstream.ClusterKey, "upstream_"), + "main vhost should fall back to API main cluster, got %q", usersMain.Upstream.ClusterKey) + + usersSandbox := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, usersSandbox, "op with per-op sandbox must have a sandbox route") + assert.True(t, strings.HasPrefix(usersSandbox.Upstream.ClusterKey, "upstream_"), + "sandbox vhost should use definition cluster, got %q", usersSandbox.Upstream.ClusterKey) + + ordersMain := rdc.Routes["GET|/test/orders|main.local"] + require.NotNil(t, ordersMain, "op without per-op upstream must have a main route") + assert.False(t, strings.HasPrefix(ordersMain.Upstream.ClusterKey, "upstream_")) + + _, ordersHasSandbox := rdc.Routes["GET|/test/orders|sandbox.local"] + assert.False(t, ordersHasSandbox, + "op without per-op sandbox must NOT get a sandbox route when API-level sandbox is nil") +} + +// TestRestAPITransformer_PerOpSandboxInheritsSandboxHostRewrite - a per-op sandbox +// override route carries no HostRewrite of its own, so it must inherit the API-level +// SANDBOX HostRewrite (not the API-level main). This guards the transform/xDS parity: +// the xDS path inherits the sandbox value, so the RDC path must too. With API-level +// main=auto and sandbox=manual, the per-op sandbox route must be manual (AutoHostRewrite=false). +func TestRestAPITransformer_PerOpSandboxInheritsSandboxHostRewrite(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + manual := api.Manual + auto := api.Auto + defs := []api.UpstreamDefinition{ + {Name: "op-sandbox-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://op-sandbox:8080"}}}, + } + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "1.0.0", + UpstreamDefinitions: &defs, + Operations: []api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "op-sandbox-cluster"}, + }, + }, + }, + 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"), HostRewrite: &auto}, + Sandbox: &api.Upstream{Url: ptrStr("http://api-sandbox:8080"), HostRewrite: &manual}, + }, + } + cfg := &models.StoredConfig{ + UUID: "test-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "test-api"}, + Spec: apiData, + }, + } + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + usersSandbox := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, usersSandbox, "op with per-op sandbox must have a sandbox route") + assert.True(t, strings.HasPrefix(usersSandbox.Upstream.ClusterKey, "upstream_"), + "sandbox vhost should use definition cluster, got %q", usersSandbox.Upstream.ClusterKey) + assert.False(t, usersSandbox.AutoHostRewrite, + "per-op sandbox route must inherit API-level SANDBOX hostRewrite (manual), not main (auto)") + + usersMain := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, usersMain) + assert.True(t, usersMain.AutoHostRewrite, + "main route must keep API-level main hostRewrite (auto)") +} + // TestResolvePort checks port resolution with explicit, default-http and default-https. func TestResolvePort(t *testing.T) { tests := []struct { @@ -353,7 +749,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 +778,112 @@ 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 EDS-stable sandbox cluster (sandbox_), not main; got %q", r.Upstream.DefaultCluster) }) } + +// TestRestAPITransformer_APILevelClusterNameShape asserts the EDS-stable cluster +// naming contract for API-level main and sandbox upstreams: +// - cluster names are "_<16-hex>" derived from sha256(apiID|env) +// - 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, "main") + expectedSandbox := "sandbox_" + clusterkey.APILevel(cfg.UUID, "sandbox") + + 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 apiID|env") + + 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 apiID|env") + + _, 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 after the unification fix") +} + +// TestRestAPITransformer_APILevelEDSStableAcrossURLEdit asserts that editing the +// API-level main upstream URL does NOT change the cluster name. This is the +// EDS-stable contract: URL edits propagate as endpoint updates, not cluster +// recreates. +func TestRestAPITransformer_APILevelEDSStableAcrossURLEdit(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + cfgA := makeRestAPIWithOps([]api.Operation{{Method: "GET", Path: "/users"}}) + // Mutate just the main URL on a copy of the spec. + 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 "+ + "(EDS-stable contract: URL edits propagate via endpoint updates)") +} 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..16750ab68 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go @@ -0,0 +1,39 @@ +/* + * 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, hex-encoded cluster-key fragments +// used by the gateway-controller to name Envoy clusters. It is a leaf package +// (stdlib imports only) so both pkg/utils and pkg/xds can depend on it without +// forming an import cycle. +package clusterkey + +import ( + "crypto/sha256" + "encoding/hex" +) + +// APILevel returns a deterministic, hex-encoded cluster-key fragment for an +// API-level upstream cluster (main or sandbox). The key is derived from +// SHA-256 of apiID|env. Method and path are excluded because API-level +// upstream applies to the whole API; the URL is excluded so URL edits update +// endpoints in-place rather than destroying and recreating the cluster. +func APILevel(apiID, env string) string { + hashInput := apiID + "|" + env + sum := sha256.Sum256([]byte(hashInput)) + return hex.EncodeToString(sum[:8]) +} 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..82e61ad0e --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey_test.go @@ -0,0 +1,55 @@ +/* + * 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" +) + +// hexShape16 matches exactly 16 lowercase hex characters - the cluster-key +// fragment shape produced by APILevel. +var hexShape16 = regexp.MustCompile("^[a-f0-9]{16}$") + +// TestAPILevel validates the contract for API-level cluster naming: +// - deterministic for identical inputs +// - distinct on any input field (apiID, env) +// - 16 hex chars from SHA-256[:8] +func TestAPILevel(t *testing.T) { + t.Run("deterministic for identical inputs", func(t *testing.T) { + a := APILevel("api-1", "main") + b := APILevel("api-1", "main") + assert.Equal(t, a, b, "same inputs must produce same hash") + assert.Regexp(t, hexShape16, a, "hash must be exactly 16 lowercase hex characters") + }) + + t.Run("different apiID produces different hash", func(t *testing.T) { + a := APILevel("api-1", "main") + b := APILevel("api-2", "main") + assert.NotEqual(t, a, b) + }) + + t.Run("different env produces different hash", func(t *testing.T) { + a := APILevel("api-1", "main") + b := APILevel("api-1", "sandbox") + assert.NotEqual(t, a, b) + }) +} diff --git a/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref.go b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref.go new file mode 100644 index 000000000..a4afc78f8 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref.go @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package upstreamref centralizes resolution of per-op and API-level upstream +// references against the spec.upstreamDefinitions block. Both the xDS translator +// and the RDC transformer consume the same definitions and must agree on lookup +// and timeout-parsing semantics; this package exists so they share one source of +// truth. +package upstreamref + +import ( + "fmt" + "strings" + "time" + + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" +) + +// FindByName returns the UpstreamDefinition whose Name matches ref (after +// trimming whitespace). Returns an error if ref is empty, defs is nil/empty, or +// no matching definition exists. +func FindByName(ref string, defs *[]api.UpstreamDefinition) (*api.UpstreamDefinition, error) { + refName := strings.TrimSpace(ref) + if refName == "" { + return nil, fmt.Errorf("upstream ref is empty") + } + if defs == nil || len(*defs) == 0 { + return nil, fmt.Errorf("upstream definition '%s' referenced but no definitions provided", refName) + } + for i, def := range *defs { + if def.Name == refName { + return &(*defs)[i], nil + } + } + return nil, fmt.Errorf("upstream definition '%s' not found", refName) +} + +// ParseConnectTimeout parses an UpstreamTimeout.Connect string. Empty/nil input +// returns (nil, nil). A parse failure or a non-positive duration returns an +// error so xDS and RDC paths fail consistently rather than silently dropping. +func ParseConnectTimeout(timeoutStr *string) (*time.Duration, error) { + if timeoutStr == nil { + return nil, nil + } + trimmed := strings.TrimSpace(*timeoutStr) + if trimmed == "" { + return nil, nil + } + d, err := time.ParseDuration(trimmed) + if err != nil { + return nil, fmt.Errorf("invalid timeout format: %w", err) + } + if d <= 0 { + return nil, fmt.Errorf("timeout must be positive, got: %v", d) + } + return &d, nil +} + +// ResolveConnectTimeout extracts and parses the Connect timeout from an +// UpstreamDefinition. Returns (nil, nil) if the definition has no timeout +// configured, or an error if the configured value is invalid. +func ResolveConnectTimeout(def *api.UpstreamDefinition) (*time.Duration, error) { + if def == nil || def.Timeout == nil { + return nil, nil + } + return ParseConnectTimeout(def.Timeout.Connect) +} diff --git a/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref_test.go b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref_test.go new file mode 100644 index 000000000..adf9ebbc6 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref_test.go @@ -0,0 +1,189 @@ +/* + * 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 upstreamref + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" +) + +func TestFindByName_Found(t *testing.T) { + defs := &[]api.UpstreamDefinition{ + {Name: "users-svc"}, + {Name: "orders-svc"}, + } + def, err := FindByName("orders-svc", defs) + require.NoError(t, err) + require.NotNil(t, def) + assert.Equal(t, "orders-svc", def.Name) +} + +func TestFindByName_TrimsWhitespace(t *testing.T) { + defs := &[]api.UpstreamDefinition{{Name: "users-svc"}} + def, err := FindByName(" users-svc ", defs) + require.NoError(t, err) + assert.Equal(t, "users-svc", def.Name) +} + +func TestFindByName_EmptyRef(t *testing.T) { + defs := &[]api.UpstreamDefinition{{Name: "users-svc"}} + _, err := FindByName("", defs) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestFindByName_WhitespaceRef(t *testing.T) { + defs := &[]api.UpstreamDefinition{{Name: "users-svc"}} + _, err := FindByName(" ", defs) + require.Error(t, err) +} + +func TestFindByName_NilDefs(t *testing.T) { + _, err := FindByName("users-svc", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no definitions provided") +} + +func TestFindByName_EmptyDefs(t *testing.T) { + defs := &[]api.UpstreamDefinition{} + _, err := FindByName("users-svc", defs) + require.Error(t, err) + assert.Contains(t, err.Error(), "no definitions provided") +} + +func TestFindByName_NotFound(t *testing.T) { + defs := &[]api.UpstreamDefinition{{Name: "users-svc"}} + _, err := FindByName("orders-svc", defs) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestFindByName_ReturnsStablePointer(t *testing.T) { + defs := &[]api.UpstreamDefinition{ + {Name: "a"}, + {Name: "b"}, + {Name: "c"}, + } + got, err := FindByName("b", defs) + require.NoError(t, err) + assert.Same(t, &(*defs)[1], got, "must return pointer into the slice, not a copy of a loop variable") +} + +func TestParseConnectTimeout_NilInput(t *testing.T) { + d, err := ParseConnectTimeout(nil) + require.NoError(t, err) + assert.Nil(t, d) +} + +func TestParseConnectTimeout_EmptyString(t *testing.T) { + empty := "" + d, err := ParseConnectTimeout(&empty) + require.NoError(t, err) + assert.Nil(t, d) +} + +func TestParseConnectTimeout_WhitespaceOnly(t *testing.T) { + ws := " " + d, err := ParseConnectTimeout(&ws) + require.NoError(t, err) + assert.Nil(t, d) +} + +func TestParseConnectTimeout_Valid(t *testing.T) { + v := "5s" + d, err := ParseConnectTimeout(&v) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, 5*time.Second, *d) +} + +func TestParseConnectTimeout_ValidMilliseconds(t *testing.T) { + v := "500ms" + d, err := ParseConnectTimeout(&v) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, 500*time.Millisecond, *d) +} + +func TestParseConnectTimeout_Malformed(t *testing.T) { + v := "abc" + _, err := ParseConnectTimeout(&v) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid timeout format") +} + +func TestParseConnectTimeout_NoUnit(t *testing.T) { + v := "30" + _, err := ParseConnectTimeout(&v) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid timeout format") +} + +func TestParseConnectTimeout_Zero(t *testing.T) { + v := "0s" + _, err := ParseConnectTimeout(&v) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be positive") +} + +func TestParseConnectTimeout_Negative(t *testing.T) { + v := "-5s" + _, err := ParseConnectTimeout(&v) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be positive") +} + +func TestResolveConnectTimeout_NilDef(t *testing.T) { + d, err := ResolveConnectTimeout(nil) + require.NoError(t, err) + assert.Nil(t, d) +} + +func TestResolveConnectTimeout_NoTimeoutBlock(t *testing.T) { + def := &api.UpstreamDefinition{Name: "x"} + d, err := ResolveConnectTimeout(def) + require.NoError(t, err) + assert.Nil(t, d) +} + +func TestResolveConnectTimeout_ValidConnect(t *testing.T) { + v := "3s" + def := &api.UpstreamDefinition{ + Name: "x", + Timeout: &api.UpstreamTimeout{Connect: &v}, + } + d, err := ResolveConnectTimeout(def) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, 3*time.Second, *d) +} + +func TestResolveConnectTimeout_InvalidConnect(t *testing.T) { + v := "-2s" + def := &api.UpstreamDefinition{ + Name: "x", + Timeout: &api.UpstreamTimeout{Connect: &v}, + } + _, err := ResolveConnectTimeout(def) + require.Error(t, err) +} diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index 43c6923bf..cb1e8ff3e 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -32,6 +32,8 @@ import ( "time" commonconstants "github.com/wso2/api-platform/common/constants" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/upstreamref" accesslog "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3" cluster "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" @@ -199,9 +201,8 @@ func (t *Translator) translateRuntimeConfig(rdc *models.RuntimeDeployConfig) ([] if uc.TLS != nil && uc.TLS.Enabled { parsedURL.Scheme = "https" } - var connectTimeout *time.Duration - // Use global default; per-cluster timeout comes from the route's Timeout field - c := t.createCluster(clusterName, parsedURL, nil, connectTimeout) + // Use per-cluster connect timeout from the upstream definition if available. + c := t.createCluster(clusterName, parsedURL, nil, uc.ConnectTimeout) clusters = append(clusters, c) } @@ -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 } @@ -823,35 +824,109 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* // When upstreamDefinitions exist, use cluster_header routing so policies can select the upstream useClusterHeader := apiData.UpstreamDefinitions != nil && len(*apiData.UpstreamDefinitions) > 0 + routeClusterName := mainClusterName + routeURLPath := parsedMainURL.Path + routeHostRewrite := apiData.Upstream.Main.HostRewrite + routeTimeout := mainTimeout + routeUseClusterHeader := useClusterHeader + if op.Upstream != nil && op.Upstream.Main != nil { + defClusterName, defBasePath, defTimeout, err := t.resolvePerOpDefinitionCluster(cfg.Kind, cfg.UUID, op.Upstream.Main, apiData.UpstreamDefinitions) + if err != nil { + return nil, nil, fmt.Errorf("per-op main upstream for %s %s: %w", string(op.Method), op.Path, err) + } + // Reuse the referenced upstreamDefinition's cluster (built unconditionally + // below) instead of minting a per-op cluster. routeURLPath carries the + // definition's base path (#2065) so the route's static rewrite prepends it + // exactly once. Keep cluster_header ON with that cluster as the default so a + // dynamic-endpoint policy can still steer this operation. Precedence: + // op-policy > api-policy > per-op ref > api-level upstream. + routeClusterName = defClusterName + routeURLPath = defBasePath + routeTimeout = defTimeout + routeUseClusterHeader = true + } + r := t.createRoute(cfg.UUID, apiData.DisplayName, apiData.Version, apiData.Context, string(op.Method), op.Path, - mainClusterName, parsedMainURL.Path, effectiveMainVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Main.HostRewrite, apiProjectID, mainTimeout, useClusterHeader, upstreamDefPaths) + routeClusterName, routeURLPath, effectiveMainVHost, cfg.Kind, templateHandle, providerName, routeHostRewrite, apiProjectID, routeTimeout, routeUseClusterHeader, upstreamDefPaths) mainRoutesList = append(mainRoutesList, r) } routesList = append(routesList, mainRoutesList...) // -------- SANDBOX UPSTREAM -------- - if apiData.Upstream.Sandbox != nil { - sbClusterName, parsedSbURL, sbTimeout, err := t.resolveUpstreamCluster("sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) - if err != nil { - return nil, nil, err + hasSandbox := apiData.Upstream.Sandbox != nil + if !hasSandbox { + for _, op := range apiData.Operations { + if op.Upstream != nil && op.Upstream.Sandbox != nil { + sb := op.Upstream.Sandbox + if strings.TrimSpace(sb.Ref) != "" { + hasSandbox = true + break + } + } } + } + if hasSandbox { + var sbClusterName string + var parsedSbURL *url.URL + var sbTimeout *resolvedTimeout + var sbRouteHostRewrite *api.UpstreamHostRewrite - // Timeout for sandbox upstream cluster - var sbUpstreamClusterConnectTimeout *time.Duration - if sbTimeout != nil { - sbUpstreamClusterConnectTimeout = sbTimeout.Connect - } + if apiData.Upstream.Sandbox != nil { + sbClusterName, parsedSbURL, sbTimeout, err = t.resolveUpstreamCluster(cfg.UUID, "sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) + if err != nil { + return nil, nil, err + } - sandboxCluster := t.createCluster(sbClusterName, parsedSbURL, nil, sbUpstreamClusterConnectTimeout) - clusters = append(clusters, sandboxCluster) + // Timeout for sandbox upstream cluster + var sbUpstreamClusterConnectTimeout *time.Duration + if sbTimeout != nil { + sbUpstreamClusterConnectTimeout = sbTimeout.Connect + } + + sandboxCluster := t.createCluster(sbClusterName, parsedSbURL, nil, sbUpstreamClusterConnectTimeout) + clusters = append(clusters, sandboxCluster) + sbRouteHostRewrite = apiData.Upstream.Sandbox.HostRewrite + } else { + // Sandbox active via per-op only: inherit API-level main HostRewrite so per-op sandbox + // routes behave consistently with main routes. + sbRouteHostRewrite = apiData.Upstream.Main.HostRewrite + } // Create sandbox routes. When upstreamDefinitions exist, enable dynamic cluster - // selection (mirrors main). + // selection (mirrors main) so a dynamic-endpoint policy can steer sandbox traffic; + // a per-op sandbox ref reuses the referenced definition's cluster (handled below). sbRoutesList := make([]*route.Route, 0) sbUseClusterHeader := apiData.UpstreamDefinitions != nil && len(*apiData.UpstreamDefinitions) > 0 for _, op := range apiData.Operations { + // Skip ops without per-op sandbox when there's no API-level sandbox + if apiData.Upstream.Sandbox == nil && (op.Upstream == nil || op.Upstream.Sandbox == nil) { + continue + } + + sbRouteCluster := sbClusterName + sbRouteURLPath := "" + if parsedSbURL != nil { + sbRouteURLPath = parsedSbURL.Path + } + sbRouteHR := sbRouteHostRewrite + sbRouteTimeout := sbTimeout + sbRouteUseClusterHeader := sbUseClusterHeader + if op.Upstream != nil && op.Upstream.Sandbox != nil { + defClusterName, defBasePath, defTimeout, err := t.resolvePerOpDefinitionCluster(cfg.Kind, cfg.UUID, op.Upstream.Sandbox, apiData.UpstreamDefinitions) + if err != nil { + return nil, nil, fmt.Errorf("per-op sandbox upstream for %s %s: %w", string(op.Method), op.Path, err) + } + // Reuse the referenced upstreamDefinition's cluster; routeURLPath carries + // its base path (#2065). Keep cluster_header ON so a sandbox dynamic-endpoint + // policy can override the per-op default — consistent with the API-level + // sandbox routing fixed in #2059 (no static-pin bypass). + sbRouteCluster = defClusterName + sbRouteURLPath = defBasePath + sbRouteTimeout = defTimeout + sbRouteUseClusterHeader = true + } r := t.createRoute(cfg.UUID, apiData.DisplayName, apiData.Version, apiData.Context, string(op.Method), op.Path, - sbClusterName, parsedSbURL.Path, effectiveSandboxVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Sandbox.HostRewrite, apiProjectID, sbTimeout, sbUseClusterHeader, upstreamDefPaths) + sbRouteCluster, sbRouteURLPath, effectiveSandboxVHost, cfg.Kind, templateHandle, providerName, sbRouteHR, apiProjectID, sbRouteTimeout, sbRouteUseClusterHeader, upstreamDefPaths) sbRoutesList = append(sbRoutesList, r) } routesList = append(routesList, sbRoutesList...) @@ -914,7 +989,10 @@ 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) { +// The cluster name is derived from sha256(apiID|upstreamName), giving the +// API-level main/sandbox cluster an EDS-stable identity: URL edits update +// endpoints in-place rather than destroying and recreating the cluster. +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,12 +1052,49 @@ 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 EDS-stable hash (URL intentionally excluded). + clusterName := upstreamName + "_" + clusterkey.APILevel(apiID, upstreamName) return clusterName, parsedURL, timeout, nil } +// resolvePerOpDefinitionCluster resolves a ref-only per-op upstream target to the +// EXISTING upstreamDefinition cluster: its EDS cluster name +// (upstream___) and base path. The definition's cluster is +// created unconditionally for every definition, so a per-op route reuses it rather +// than minting its own — one cluster per definition serves both vhosts (no env in +// the key). The base path comes from the definition's basePath field (#2065); the +// caller passes it as the route's upstream path so the static rewrite prepends it. +func (t *Translator) resolvePerOpDefinitionCluster(kind, apiID string, target *api.RestAPIOperationUpstreamTarget, upstreamDefinitions *[]api.UpstreamDefinition) (string, string, *resolvedTimeout, error) { + refName := strings.TrimSpace(target.Ref) + if refName == "" { + return "", "", nil, fmt.Errorf("per-op upstream ref is empty") + } + definition, err := resolveUpstreamDefinition(refName, upstreamDefinitions) + if err != nil { + return "", "", nil, fmt.Errorf("failed to resolve per-op upstream ref: %w", err) + } + if len(definition.Upstreams) == 0 || definition.Upstreams[0].Url == "" { + return "", "", nil, fmt.Errorf("upstream definition '%s' has no URLs configured", refName) + } + + var timeout *resolvedTimeout + if definition.Timeout != nil { + resolved, err := resolveTimeoutFromDefinition(definition) + if err != nil { + return "", "", nil, fmt.Errorf("invalid timeout in upstream definition '%s': %w", refName, err) + } + timeout = resolved + } + + basePath := "/" + if definition.BasePath != nil && *definition.BasePath != "" { + basePath = *definition.BasePath + } + clusterName := constants.UpstreamDefinitionClusterPrefix + kind + "_" + apiID + "_" + sanitizeUpstreamDefinitionName(definition.Name) + return clusterName, basePath, timeout, nil +} + // SharedRouteConfigName is the name of the shared route configuration used by both HTTP and HTTPS listeners const SharedRouteConfigName = "shared_route_config" @@ -2704,14 +2819,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 { @@ -2996,39 +3103,16 @@ func (t *Translator) createExtProcFilter() (*hcm.HttpFilter, error) { }, nil } -// resolveUpstreamDefinition finds an upstream definition by its reference name -// Returns the upstream definition and error if not found +// resolveUpstreamDefinition finds an upstream definition by its reference name. +// Thin wrapper over upstreamref.FindByName to keep callers in this file unchanged. func resolveUpstreamDefinition(ref string, definitions *[]api.UpstreamDefinition) (*api.UpstreamDefinition, error) { - if definitions == nil { - return nil, fmt.Errorf("upstream definition '%s' not found: no definitions provided", ref) - } - - for _, def := range *definitions { - if def.Name == ref { - return &def, nil - } - } - - return nil, fmt.Errorf("upstream definition '%s' not found", ref) + return upstreamref.FindByName(ref, definitions) } -// parseTimeout parses a duration string (e.g., "30s", "1m", "500ms") and returns a time.Duration. -// Returns nil if the input is nil or empty. +// parseTimeout parses a duration string and returns a time.Duration. Thin wrapper +// over upstreamref.ParseConnectTimeout so xDS and RDC share one source of truth. func parseTimeout(timeoutStr *string) (*time.Duration, error) { - if timeoutStr == nil || strings.TrimSpace(*timeoutStr) == "" { - return nil, nil - } - - duration, err := time.ParseDuration(strings.TrimSpace(*timeoutStr)) - if err != nil { - return nil, fmt.Errorf("invalid timeout format: %w", err) - } - - if duration <= 0 { - return nil, fmt.Errorf("timeout must be positive, got: %v", duration) - } - - return &duration, nil + return upstreamref.ParseConnectTimeout(timeoutStr) } // resolveTimeoutFromDefinition converts an UpstreamDefinition's timeout block into a resolvedTimeout. diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index 0be6f3968..f887d1716 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", "main"), clusterName, + "cluster name should be the EDS-stable hash of apiID|env, 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", "main"), clusterName, + "cluster name should be the EDS-stable hash of apiID|env, 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", "main"), clusterName, + "cluster name should be the EDS-stable hash of apiID|env, 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,111 @@ func TestTranslator_CreateDynamicFwdListenerForWebSubHub(t *testing.T) { assert.Equal(t, core.SocketAddress_TCP, listener.GetAddress().GetSocketAddress().GetProtocol()) }) } + +// TestResolveUpstreamCluster_DedupSameAPIDifferentURLs asserts the EDS-stable +// contract at the API level. Two distinct URLs that share the same apiID and +// env must resolve to the same cluster name so URL edits become EDS endpoint +// updates instead of CDS cluster recreates. +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 apiID|env must produce 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. +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 (env is part of the hash input)") +} + +// TestTranslateConfigs_PerOpSandboxClusterEmitted asserts that the legacy path +// emits a dedicated per-op sandbox cluster and routes the sandbox vhost to it +// when an operation declares a per-op sandbox upstream override. +func TestTranslateConfigs_PerOpSandboxClusterEmitted(t *testing.T) { + translator := createTestTranslator() + + sbVhost := "sandbox.local" + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "v1.0", + Vhosts: &struct { + Main string `json:"main" yaml:"main"` + Sandbox *string `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: "localhost", + Sandbox: &sbVhost, + }, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: strPtr("http://api-main:8080")}, + }, + UpstreamDefinitions: &[]api.UpstreamDefinition{ + {Name: "user-svc-sb-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc-sb:8080"}}}, + }, + Operations: []api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-sb-cluster"}, + }, + }, + }, + } + cfg := &models.StoredConfig{ + UUID: "sandbox-op-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "sandbox-op-api"}, + Spec: apiData, + }, + } + + resources, err := translator.TranslateConfigs([]*models.StoredConfig{cfg}, "test-correlation") + require.NoError(t, err) + require.NotNil(t, resources) + + clusters := resources[resource.ClusterType] + routeConfigs := resources[resource.RouteType] + require.NotEmpty(t, clusters, "expected at least one cluster") + require.NotEmpty(t, routeConfigs, "expected at least one route configuration") + + // Per-op sandbox now REUSES the referenced definition's cluster + // (upstream___user-svc-sb-cluster); no per-op "op_" cluster is minted. + var defClusterName string + for _, c := range clusters { + name := c.(*cluster.Cluster).GetName() + require.False(t, strings.HasPrefix(name, "op_"), + "per-op refs must not mint op_ clusters anymore; got %q", name) + if strings.HasPrefix(name, "upstream_") && strings.Contains(name, "user-svc-sb-cluster") { + defClusterName = name + } + } + require.NotEmpty(t, defClusterName, + "expected the referenced upstreamDefinition cluster (upstream_..._user-svc-sb-cluster) to be emitted for reuse") + require.NotEmpty(t, routeConfigs, "expected sandbox route configuration to exist") +} diff --git a/gateway/gateway-controller/tests/integration/storage_test.go b/gateway/gateway-controller/tests/integration/storage_test.go index 954f7f3d1..f003db87e 100644 --- a/gateway/gateway-controller/tests/integration/storage_test.go +++ b/gateway/gateway-controller/tests/integration/storage_test.go @@ -798,3 +798,88 @@ func TestSQLiteStorage_LabelsPersistence(t *testing.T) { assert.Equal(t, labels, retrieved, "Loaded labels should match persisted labels") }) } + +func TestSQLiteStorage_PerOpUpstreamRefRoundtrip(t *testing.T) { + db, _, cleanup := setupTestDB(t) + defer cleanup() + + apiURL := "http://api-main:9080" + apiConfig := api.RestAPI{ + ApiVersion: api.RestAPIApiVersionGatewayApiPlatformWso2Comv1alpha1, + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "PerOpRefAPI-v1.0"}, + Spec: api.APIConfigData{ + DisplayName: "PerOpRefAPI", + Version: "v1.0", + Context: "/per-op-ref", + UpstreamDefinitions: &[]api.UpstreamDefinition{ + { + Name: "users-svc", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://users-backend:9080"}}, + }, + { + Name: "users-sandbox-svc", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://users-sandbox:9080"}}, + }, + }, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: &apiURL}, + }, + Operations: []api.Operation{ + { + Method: api.OperationMethodGET, + Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "users-svc"}, + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "users-sandbox-svc"}, + }, + }, + }, + }, + } + + cfg := &models.StoredConfig{ + UUID: uuid.New().String(), + Kind: string(api.RestAPIKindRestApi), + Handle: "PerOpRefAPI-v1.0", + DisplayName: "PerOpRefAPI", + Version: "v1.0", + Configuration: apiConfig, + SourceConfiguration: apiConfig, + DesiredState: models.StateDeployed, + Origin: models.OriginGatewayAPI, + } + + require.NoError(t, db.SaveConfig(cfg)) + + retrieved, err := db.GetConfig(cfg.UUID) + require.NoError(t, err) + + spec := retrieved.Configuration.(api.RestAPI).Spec + require.NotNil(t, spec.UpstreamDefinitions, "upstreamDefinitions must survive roundtrip") + require.Len(t, *spec.UpstreamDefinitions, 2) + defs := *spec.UpstreamDefinitions + assert.Equal(t, "users-svc", defs[0].Name) + require.Len(t, defs[0].Upstreams, 1) + assert.Equal(t, "http://users-backend:9080", defs[0].Upstreams[0].Url) + assert.Equal(t, "users-sandbox-svc", defs[1].Name) + require.Len(t, defs[1].Upstreams, 1) + assert.Equal(t, "http://users-sandbox:9080", defs[1].Upstreams[0].Url) + require.Len(t, spec.Operations, 1) + + op := spec.Operations[0] + require.NotNil(t, op.Upstream, "per-op upstream must survive roundtrip") + require.NotNil(t, op.Upstream.Main) + assert.Equal(t, "users-svc", op.Upstream.Main.Ref) + require.NotNil(t, op.Upstream.Sandbox) + assert.Equal(t, "users-sandbox-svc", op.Upstream.Sandbox.Ref) +} diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go b/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go index d5b42bcc2..b4c916b7f 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go @@ -372,6 +372,62 @@ func TestBuildDynamicMetadata_WithPath(t *testing.T) { assert.Equal(t, "/new/path", extProc.Fields["path"].GetStringValue()) } +// ============================================================================= +// Per-op upstream + dynamic-endpoint precedence (regression: no double base prefix) +// ============================================================================= + +// TestTranslateRequestHeaderActions_DynamicEndpointDoesNotBakeBasePath guards the +// per-op-upstream-ref behavior. When a dynamic-endpoint policy redirects a request to an +// upstreamDefinition that has a base path, the kernel must NOT bake that base into a path +// mutation: a baked path surfaces as metadata["path"], which the Lua filter reads before +// request_transformation.target_path and would prepend the base twice +// (e.g. /op-policy-svc/op-policy-svc/override). Instead the kernel passes the original +// request path plus target_upstream_base_path, so the Lua prepends the base exactly once. +func TestTranslateRequestHeaderActions_DynamicEndpointDoesNotBakeBasePath(t *testing.T) { + kernel := NewKernel() + chainExecutor := executor.NewChainExecutor(nil, nil, nil) + server := NewExternalProcessorServer(kernel, chainExecutor, config.TracingConfig{}, "") + + chain := ®istry.PolicyChain{} + execCtx := newPolicyExecutionContext(server, "test-route", chain) + execCtx.sharedCtx = &policy.SharedContext{} + execCtx.requestBodyCtx = &policy.RequestContext{ + Path: "/per-op/v1.0/override", + SharedContext: execCtx.sharedCtx, + } + execCtx.apiContext = "/per-op/v1.0" + execCtx.upstreamBasePath = "/ref-svc" // the per-op route's default base path + execCtx.upstreamDefinitionPaths = map[string]string{ + "op-policy-svc": "/op-policy-svc", + } + + targetUpstream := "op-policy-svc" + result := &executor.RequestHeaderExecutionResult{ + Results: []executor.RequestHeaderPolicyResult{ + { + Action: policy.UpstreamRequestHeaderModifications{ + UpstreamName: &targetUpstream, + }, + }, + }, + } + + resp, err := TranslateRequestHeaderActions(result, chain, execCtx) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.DynamicMetadata) + + extProc := resp.DynamicMetadata.Fields[constants.ExtProcFilterName].GetStructValue() + require.NotNil(t, extProc) + + // The target upstream's base path is advertised so the Lua prepends it exactly once. + assert.Equal(t, "/op-policy-svc", extProc.Fields["target_upstream_base_path"].GetStringValue()) + // The ORIGINAL request path is handed to the Lua, not a pre-computed base-prefixed path. + assert.Equal(t, "/per-op/v1.0/override", extProc.Fields["request_transformation.target_path"].GetStringValue()) + // Critically: no baked "path" mutation. If present, the Lua reads it first and double-prepends the base. + assert.NotContains(t, extProc.Fields, "path") +} + // ============================================================================= // translateRequestActionsCore Tests // ============================================================================= diff --git a/gateway/it/features/api-level-eds-stable.feature b/gateway/it/features/api-level-eds-stable.feature new file mode 100644 index 000000000..ae0b12df7 --- /dev/null +++ b/gateway/it/features/api-level-eds-stable.feature @@ -0,0 +1,202 @@ +# -------------------------------------------------------------------- +# 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-eds-stable +Feature: API-Level Upstream EDS-Stable Cluster Naming + As an API developer + I want API-level main and sandbox upstream URL edits to propagate as + endpoint updates rather than cluster recreates + So that URL changes do not drain in-flight connections + + Background: + Given the gateway services are running + + Scenario: API-level main upstream URL update routes to new backend (EDS-stable design) + 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-eds-main-api-v1.0 + spec: + displayName: API-Level-EDS-Main-API + version: v1.0 + context: /api-level-eds-main/$version + vhosts: + main: api-level-eds-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-eds-main/v1.0/endpoint" to be ready with host "api-level-eds-main.local" + + When I clear all headers + And I set request host to "api-level-eds-main.local" + And I send a GET request to "http://localhost:8080/api-level-eds-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" + + Given I authenticate using basic auth as "admin" + When I update the API "api-level-eds-main-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: api-level-eds-main-api-v1.0 + spec: + displayName: API-Level-EDS-Main-API + version: v1.0 + context: /api-level-eds-main/$version + vhosts: + main: api-level-eds-main.local + upstream: + main: + url: http://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-eds-main/v1.0/endpoint" to be ready with host "api-level-eds-main.local" + + When I clear all headers + And I set request host to "api-level-eds-main.local" + And I send a GET request to "http://localhost:8080/api-level-eds-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" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-eds-main-api-v1.0" + Then the response should be successful + + Scenario: API-level sandbox upstream URL update 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-eds-sandbox-api-v1.0 + spec: + displayName: API-Level-EDS-Sandbox-API + version: v1.0 + context: /api-level-eds-sandbox/$version + vhosts: + main: api-level-eds-sandbox-main.local + sandbox: api-level-eds-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-eds-sandbox/v1.0/endpoint" to be ready with host "api-level-eds-sandbox-sb.local" + + When I clear all headers + And I set request host to "api-level-eds-sandbox-sb.local" + And I send a GET request to "http://localhost:8080/api-level-eds-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" + + Given I authenticate using basic auth as "admin" + When I update the API "api-level-eds-sandbox-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: api-level-eds-sandbox-api-v1.0 + spec: + displayName: API-Level-EDS-Sandbox-API + version: v1.0 + context: /api-level-eds-sandbox/$version + vhosts: + main: api-level-eds-sandbox-main.local + sandbox: api-level-eds-sandbox-sb.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://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-eds-sandbox/v1.0/endpoint" to be ready with host "api-level-eds-sandbox-sb.local" + + When I clear all headers + And I set request host to "api-level-eds-sandbox-sb.local" + And I send a GET request to "http://localhost:8080/api-level-eds-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" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-eds-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-eds-default-api-v1.0 + spec: + displayName: API-Level-EDS-Default-API + version: v1.0 + context: /api-level-eds-default/$version + vhosts: + main: api-level-eds-default.local + upstreamDefinitions: + - name: backend-default + upstreams: + - url: http://sample-backend:9080/api-main + 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-eds-default/v1.0/endpoint" to be ready with host "api-level-eds-default.local" + + When I clear all headers + And I set request host to "api-level-eds-default.local" + And I send a GET request to "http://localhost:8080/api-level-eds-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-eds-default-api-v1.0" + Then the response should be successful diff --git a/gateway/it/features/per-op-upstream-basic.feature b/gateway/it/features/per-op-upstream-basic.feature new file mode 100644 index 000000000..52f2e88da --- /dev/null +++ b/gateway/it/features/per-op-upstream-basic.feature @@ -0,0 +1,465 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +@per-op-upstream-basic +Feature: Per-Operation Upstream Basic Routing + As an API developer + I want per-operation upstream refs to override API-level upstreams + So that different operations can route to different backends + + Background: + Given the gateway services are running + + Scenario: API-level main fallback when operation has no per-op upstream + 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: per-op-basic-fm-api-v1.0 + spec: + displayName: Per-Op-Basic-FM-API + version: v1.0 + context: /per-op-basic-fm/$version + vhosts: + main: per-op-basic-fm-main.local + sandbox: per-op-basic-fm-sandbox.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-fm/v1.0/users" to be ready with host "per-op-basic-fm-main.local" + + When I clear all headers + And I set request host to "per-op-basic-fm-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-fm/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-fm-api-v1.0" + Then the response should be successful + + Scenario: API-level sandbox fallback when operation has no per-op upstream + 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: per-op-basic-fs-api-v1.0 + spec: + displayName: Per-Op-Basic-FS-API + version: v1.0 + context: /per-op-basic-fs/$version + vhosts: + main: per-op-basic-fs-main.local + sandbox: per-op-basic-fs-sandbox.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-fs/v1.0/users" to be ready with host "per-op-basic-fs-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-fs-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-fs/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-fs-api-v1.0" + Then the response should be successful + + Scenario: Per-operation main ref overrides API-level main + 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: per-op-basic-om-api-v1.0 + spec: + displayName: Per-Op-Basic-OM-API + version: v1.0 + context: /per-op-basic-om/$version + vhosts: + main: per-op-basic-om-main.local + upstreamDefinitions: + - name: op-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-main + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /users + upstream: + main: + ref: op-main-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-om/v1.0/users" to be ready with host "per-op-basic-om-main.local" + + When I clear all headers + And I set request host to "per-op-basic-om-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-om/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-main/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-om-api-v1.0" + Then the response should be successful + + Scenario: Per-operation sandbox-only override routes sandbox traffic to the operation upstream + 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: per-op-basic-os-api-v1.0 + spec: + displayName: Per-Op-Basic-OS-API + version: v1.0 + context: /per-op-basic-os/$version + vhosts: + main: per-op-basic-os-main.local + sandbox: per-op-basic-os-sandbox.local + upstreamDefinitions: + - name: op-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /users + upstream: + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-os/v1.0/users" to be ready with host "per-op-basic-os-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-os-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-os/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-os-api-v1.0" + Then the response should be successful + + Scenario: Sandbox falls back when operation only has per-op main + 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: per-op-basic-sf-api-v1.0 + spec: + displayName: Per-Op-Basic-SF-API + version: v1.0 + context: /per-op-basic-sf/$version + vhosts: + main: per-op-basic-sf-main.local + sandbox: per-op-basic-sf-sandbox.local + upstreamDefinitions: + - name: op-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-main + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + upstream: + main: + ref: op-main-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-sf/v1.0/users" to be ready with host "per-op-basic-sf-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-sf-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-sf/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-sf-api-v1.0" + Then the response should be successful + + Scenario: Main falls back when operation only has per-op sandbox + 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: per-op-basic-mf-api-v1.0 + spec: + displayName: Per-Op-Basic-MF-API + version: v1.0 + context: /per-op-basic-mf/$version + vhosts: + main: per-op-basic-mf-main.local + sandbox: per-op-basic-mf-sandbox.local + upstreamDefinitions: + - name: op-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + upstream: + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-mf/v1.0/users" to be ready with host "per-op-basic-mf-main.local" + + When I clear all headers + And I set request host to "per-op-basic-mf-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-mf/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-mf-api-v1.0" + Then the response should be successful + + Scenario: Operation with both per-op main and sandbox overrides + 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: per-op-basic-both-api-v1.0 + spec: + displayName: Per-Op-Basic-Both-API + version: v1.0 + context: /per-op-basic-both/$version + vhosts: + main: per-op-basic-both-main.local + sandbox: per-op-basic-both-sandbox.local + upstreamDefinitions: + - name: op-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-main + - name: op-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + upstream: + main: + ref: op-main-svc + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-both/v1.0/users" to be ready with host "per-op-basic-both-main.local" + + When I clear all headers + And I set request host to "per-op-basic-both-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-both/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-main/users" + + When I clear all headers + And I set request host to "per-op-basic-both-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-both/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-both-api-v1.0" + Then the response should be successful + + Scenario: Per-operation upstream definition URL update routes to new backend (EDS-stable design) + 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: per-op-basic-eds-api-v1.0 + spec: + displayName: Per-Op-Basic-EDS-API + version: v1.0 + context: /per-op-basic-eds/$version + vhosts: + main: per-op-basic-eds-main.local + upstreamDefinitions: + - name: op-versioned-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /version-a + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /endpoint + upstream: + main: + ref: op-versioned-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" to be ready with host "per-op-basic-eds-main.local" + + When I clear all headers + And I set request host to "per-op-basic-eds-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-eds/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" + + Given I authenticate using basic auth as "admin" + When I update the API "per-op-basic-eds-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: per-op-basic-eds-api-v1.0 + spec: + displayName: Per-Op-Basic-EDS-API + version: v1.0 + context: /per-op-basic-eds/$version + vhosts: + main: per-op-basic-eds-main.local + upstreamDefinitions: + - name: op-versioned-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /version-b + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /endpoint + upstream: + main: + ref: op-versioned-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" to be ready with host "per-op-basic-eds-main.local" + + When I clear all headers + And I set request host to "per-op-basic-eds-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-eds/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" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-eds-api-v1.0" + Then the response should be successful + + Scenario: Per-op sandbox inherits API-level main hostRewrite when no API-level sandbox + 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: per-op-basic-sbhr-api-v1.0 + spec: + displayName: Per-Op-Basic-SBHR-API + version: v1.0 + context: /per-op-basic-sbhr/$version + vhosts: + main: per-op-basic-sbhr-main.local + sandbox: per-op-basic-sbhr-sandbox.local + upstreamDefinitions: + - name: op-sandbox-svc + upstreams: + - url: http://echo-backend:80 + basePath: /anything + upstream: + main: + url: http://echo-backend:80/anything + hostRewrite: manual + operations: + - method: GET + path: /test + upstream: + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-sbhr/v1.0/test" to be ready with host "per-op-basic-sbhr-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-sbhr-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-sbhr/v1.0/test" + Then the response status code should be 200 + And the JSON response field "headers.Host" should be "per-op-basic-sbhr-sandbox.local" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-sbhr-api-v1.0" + Then the response should be successful diff --git a/gateway/it/features/per-op-upstream-ref.feature b/gateway/it/features/per-op-upstream-ref.feature new file mode 100644 index 000000000..862895e25 --- /dev/null +++ b/gateway/it/features/per-op-upstream-ref.feature @@ -0,0 +1,338 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +@per-op-upstream-ref +Feature: Per-Operation Upstream Ref + As an API developer + I want per-operation upstream refs to resolve through upstreamDefinitions + So that different operations can route to different backends + + Background: + Given the gateway services are running + + Scenario: Per-operation main refs route to different backends + 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: per-op-ref-api-v1.0 + spec: + displayName: Per-Op-Ref-API + version: v1.0 + context: /per-op/$version + vhosts: + main: per-op-main.local + upstreamDefinitions: + - name: users-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /user-svc + - name: orders-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /order-svc + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: users-svc + - method: GET + path: /orders + upstream: + main: + ref: orders-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op/v1.0/users" to be ready with host "per-op-main.local" + + When I clear all headers + And I set request host to "per-op-main.local" + And I send a GET request to "http://localhost:8080/per-op/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/user-svc/users" + + When I clear all headers + And I set request host to "per-op-main.local" + And I send a GET request to "http://localhost:8080/per-op/v1.0/orders" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/order-svc/orders" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-ref-api-v1.0" + Then the response should be successful + + Scenario: Per-operation sandbox ref routes independently of main + 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: per-op-sandbox-ref-api-v1.0 + spec: + displayName: Per-Op-Sandbox-Ref-API + version: v1.0 + context: /per-op-sb/$version + vhosts: + main: per-op-sb-main.local + sandbox: per-op-sb-sandbox.local + upstreamDefinitions: + - name: sandbox-users-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /sandbox-user-svc + upstream: + main: + url: http://sample-backend:9080 + sandbox: + url: http://sample-backend:9080/sandbox + operations: + - method: GET + path: /users + upstream: + sandbox: + ref: sandbox-users-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-sb/v1.0/users" to be ready with host "per-op-sb-main.local" + + When I clear all headers + And I set request host to "per-op-sb-main.local" + And I send a GET request to "http://localhost:8080/per-op-sb/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/users" + + When I clear all headers + And I set request host to "per-op-sb-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-sb/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/sandbox-user-svc/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-sandbox-ref-api-v1.0" + Then the response should be successful + + Scenario: Mixed operations - one with per-op ref, one falling back to API-level + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: per-op-mixed-api-v1.0 + spec: + displayName: Per-Op-Mixed-API + version: v1.0 + context: /per-op-mixed/$version + vhosts: + main: per-op-mixed-main.local + upstreamDefinitions: + - name: users-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /user-svc + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /users + upstream: + main: + ref: users-svc + - method: GET + path: /orders + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-mixed/v1.0/users" to be ready with host "per-op-mixed-main.local" + + When I clear all headers + And I set request host to "per-op-mixed-main.local" + And I send a GET request to "http://localhost:8080/per-op-mixed/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/user-svc/users" + + When I clear all headers + And I set request host to "per-op-mixed-main.local" + And I send a GET request to "http://localhost:8080/per-op-mixed/v1.0/orders" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/orders" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-mixed-api-v1.0" + Then the response should be successful + + Scenario: Per-operation ref to missing upstreamDefinition should fail + 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: per-op-missing-ref-api-v1.0 + spec: + displayName: Per-Op-Missing-Ref-API + version: v1.0 + context: /per-op-missing/$version + vhosts: + main: per-op-missing-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: non-existent-upstream + """ + Then the response should be a client error + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Referenced upstream definition 'non-existent-upstream' not found" + + Scenario: Operation-level dynamic-endpoint policy overrides the per-op ref + 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: per-op-prec-op-api-v1.0 + spec: + displayName: Per-Op-Prec-Op-API + version: v1.0 + context: /per-op-prec-op/$version + vhosts: + main: per-op-prec-op-main.local + upstreamDefinitions: + - name: ref-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /ref-svc + - name: op-policy-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-policy-svc + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /override + upstream: + main: + ref: ref-svc + policies: + - name: dynamic-endpoint + version: v1 + params: + targetUpstream: op-policy-svc + - method: GET + path: /fallback + upstream: + main: + ref: ref-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-prec-op/v1.0/fallback" to be ready with host "per-op-prec-op-main.local" + + # Operation-level dynamic-endpoint policy wins over the per-op ref. + When I clear all headers + And I set request host to "per-op-prec-op-main.local" + And I send a GET request to "http://localhost:8080/per-op-prec-op/v1.0/override" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-policy-svc/override" + + # No policy on this op: the per-op ref is the default. + When I clear all headers + And I set request host to "per-op-prec-op-main.local" + And I send a GET request to "http://localhost:8080/per-op-prec-op/v1.0/fallback" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/ref-svc/fallback" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-prec-op-api-v1.0" + Then the response should be successful + + Scenario: API-level dynamic-endpoint policy overrides the per-op ref + 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: per-op-prec-api-api-v1.0 + spec: + displayName: Per-Op-Prec-Api-API + version: v1.0 + context: /per-op-prec-api/$version + vhosts: + main: per-op-prec-api-main.local + upstreamDefinitions: + - name: ref-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /ref-svc + - name: global-policy-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /global-policy-svc + upstream: + main: + url: http://sample-backend:9080/api-main + policies: + - name: dynamic-endpoint + version: v1 + params: + targetUpstream: global-policy-svc + operations: + - method: GET + path: /items + upstream: + main: + ref: ref-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-prec-api/v1.0/items" to be ready with host "per-op-prec-api-main.local" + + # API-level dynamic-endpoint policy wins over the per-op ref (dynamic beats static upstream). + When I clear all headers + And I set request host to "per-op-prec-api-main.local" + And I send a GET request to "http://localhost:8080/per-op-prec-api/v1.0/items" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/global-policy-svc/items" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-prec-api-api-v1.0" + Then the response should be successful diff --git a/gateway/it/features/per-op-upstream-validation.feature b/gateway/it/features/per-op-upstream-validation.feature new file mode 100644 index 000000000..d6a1b229e --- /dev/null +++ b/gateway/it/features/per-op-upstream-validation.feature @@ -0,0 +1,174 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +@per-op-upstream-validation +Feature: Per-Operation Upstream Validation + As an API developer + I want malformed per-operation upstream configurations to be rejected + So that invalid APIs cannot be deployed + + Background: + Given the gateway services are running + + Scenario: Empty per-op upstream wrapper is rejected + 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: per-op-val-empty-api-v1.0 + spec: + displayName: Per-Op-Val-Empty-API + version: v1.0 + context: /per-op-val-empty/$version + vhosts: + main: per-op-val-empty-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: {} + """ + Then the response should be a client error + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "At least one of 'main' or 'sandbox' must be set" + + Scenario: Per-op ref to non-existent upstream definition is rejected + 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: per-op-val-missing-ref-api-v1.0 + spec: + displayName: Per-Op-Val-Missing-Ref-API + version: v1.0 + context: /per-op-val-missing-ref/$version + vhosts: + main: per-op-val-missing-ref-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: does-not-exist + """ + Then the response should be a client error + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Referenced upstream definition 'does-not-exist' not found" + + Scenario: Empty per-op leaf is rejected + 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: per-op-val-empty-leaf-api-v1.0 + spec: + displayName: Per-Op-Val-Empty-Leaf-API + version: v1.0 + context: /per-op-val-empty-leaf/$version + vhosts: + main: per-op-val-empty-leaf-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: {} + """ + Then the response should be a client error + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Upstream ref is required" + + Scenario: Per-op ref with invalid characters is rejected + 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: per-op-val-bad-ref-api-v1.0 + spec: + displayName: Per-Op-Val-Bad-Ref-API + version: v1.0 + context: /per-op-val-bad-ref/$version + vhosts: + main: per-op-val-bad-ref-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: "bad/ref!" + """ + Then the response should be a client error + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Upstream ref must match pattern" + + Scenario: Non-positive upstreamDefinition timeout is rejected + 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: per-op-val-neg-timeout-api-v1.0 + spec: + displayName: Per-Op-Val-Neg-Timeout-API + version: v1.0 + context: /per-op-val-neg-timeout/$version + vhosts: + main: per-op-val-neg-timeout-main.local + upstreamDefinitions: + - name: slow-svc + timeout: + connect: -5s + upstreams: + - url: http://sample-backend:9080 + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: slow-svc + """ + Then the response should be a client error + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Connect timeout must be a positive duration" diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index 7eaaf1394..ce2a77dae 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -142,6 +142,10 @@ func getFeaturePaths() []string { "features/route-path-matching.feature", "features/secrets.feature", "features/template-functions.feature", + "features/per-op-upstream-basic.feature", + "features/per-op-upstream-ref.feature", + "features/per-op-upstream-validation.feature", + "features/api-level-eds-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 From 358cfa3a67aa2eefc383d581f0d94e90682a009e Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Thu, 4 Jun 2026 17:00:17 +0530 Subject: [PATCH 2/9] test(gateway): cover per-op definition timeout and fix eds-stable IT definition URL --- .../pkg/xds/translator_test.go | 36 +++++++++++++++++++ .../it/features/api-level-eds-stable.feature | 3 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index f887d1716..658dcf7cf 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -246,6 +246,42 @@ func TestResolveUpstreamCluster_WithRef_NoTimeout(t *testing.T) { assert.Nil(t, timeout, "No timeout in definition should result in nil timeout") } +// A per-operation ref reuses the referenced upstreamDefinition's cluster and +// inherits that definition's connect timeout. This asserts the timeout flows +// through the per-op resolution path specifically (not just the API-level path). +func TestResolvePerOpDefinitionCluster_InheritsDefinitionTimeout(t *testing.T) { + translator := &Translator{} + timeoutStr := "45s" + basePath := "/v2" + target := &api.RestAPIOperationUpstreamTarget{Ref: "my-svc"} + definitions := &[]api.UpstreamDefinition{ + { + Name: "my-svc", + BasePath: &basePath, + Timeout: &api.UpstreamTimeout{ + Connect: &timeoutStr, + }, + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://backend-1:9000"}, + }, + }, + } + + clusterName, defBasePath, timeout, err := translator.resolvePerOpDefinitionCluster("RestApi", "test-api", target, definitions) + + require.NoError(t, err) + assert.Equal(t, constants.UpstreamDefinitionClusterPrefix+"RestApi_test-api_my-svc", clusterName, + "per-op route should reuse the upstream-definition cluster") + assert.Equal(t, "/v2", defBasePath, "per-op route inherits the definition basePath") + require.NotNil(t, timeout) + require.NotNil(t, timeout.Connect) + assert.Equal(t, 45*time.Second, *timeout.Connect, + "per-op ref must inherit the referenced definition's connect timeout") +} + func TestResolveUpstreamCluster_WithRef_NotFound(t *testing.T) { translator := &Translator{} ref := "0000-non-existent-0000-000000000000" diff --git a/gateway/it/features/api-level-eds-stable.feature b/gateway/it/features/api-level-eds-stable.feature index ae0b12df7..21d82bb02 100644 --- a/gateway/it/features/api-level-eds-stable.feature +++ b/gateway/it/features/api-level-eds-stable.feature @@ -178,8 +178,9 @@ Feature: API-Level Upstream EDS-Stable Cluster Naming main: api-level-eds-default.local upstreamDefinitions: - name: backend-default + basePath: /api-main upstreams: - - url: http://sample-backend:9080/api-main + - url: http://sample-backend:9080 upstream: main: ref: backend-default From 75c5c7485697295e4f63a3af7de8b22fe692f646 Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Fri, 5 Jun 2026 16:20:52 +0530 Subject: [PATCH 3/9] test(gateway): align dynamic endpoint metadata expectation --- .../internal/kernel/translator_test.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go b/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go index b4c916b7f..ab50a2573 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go @@ -378,11 +378,8 @@ func TestBuildDynamicMetadata_WithPath(t *testing.T) { // TestTranslateRequestHeaderActions_DynamicEndpointDoesNotBakeBasePath guards the // per-op-upstream-ref behavior. When a dynamic-endpoint policy redirects a request to an -// upstreamDefinition that has a base path, the kernel must NOT bake that base into a path -// mutation: a baked path surfaces as metadata["path"], which the Lua filter reads before -// request_transformation.target_path and would prepend the base twice -// (e.g. /op-policy-svc/op-policy-svc/override). Instead the kernel passes the original -// request path plus target_upstream_base_path, so the Lua prepends the base exactly once. +// upstreamDefinition that has a base path, the kernel must pass the original request path +// plus target_upstream_base_path so Lua prepends the base exactly once. func TestTranslateRequestHeaderActions_DynamicEndpointDoesNotBakeBasePath(t *testing.T) { kernel := NewKernel() chainExecutor := executor.NewChainExecutor(nil, nil, nil) @@ -422,10 +419,10 @@ func TestTranslateRequestHeaderActions_DynamicEndpointDoesNotBakeBasePath(t *tes // The target upstream's base path is advertised so the Lua prepends it exactly once. assert.Equal(t, "/op-policy-svc", extProc.Fields["target_upstream_base_path"].GetStringValue()) - // The ORIGINAL request path is handed to the Lua, not a pre-computed base-prefixed path. - assert.Equal(t, "/per-op/v1.0/override", extProc.Fields["request_transformation.target_path"].GetStringValue()) - // Critically: no baked "path" mutation. If present, the Lua reads it first and double-prepends the base. - assert.NotContains(t, extProc.Fields, "path") + // The ORIGINAL request path is handed to Lua via the single path metadata channel, + // not a pre-computed base-prefixed path. + assert.Equal(t, "/per-op/v1.0/override", extProc.Fields["path"].GetStringValue()) + assert.NotContains(t, extProc.Fields, "request_transformation.target_path") } // ============================================================================= From a9d34775831bf568845ae2f9a300599a6733319e Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Sat, 6 Jun 2026 00:01:31 +0530 Subject: [PATCH 4/9] fix(gateway): enforce ms|s|m|h unit contract for upstream connect timeout time.ParseDuration accepts units beyond the declared ms|s|m|h contract (ns, us, and compound durations like 1h30m), so an out-of-contract connect timeout could pass validation. Reject any value that is not a single ms|s|m|h duration as an invalid format, keeping the existing distinct invalid-format and non-positive messages. --- .../pkg/config/api_validator.go | 8 +++++ .../pkg/config/validator_test.go | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/gateway/gateway-controller/pkg/config/api_validator.go b/gateway/gateway-controller/pkg/config/api_validator.go index 1b7cf02d5..1115369fe 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -39,6 +39,8 @@ type APIValidator struct { urlFriendlyNameRegex *regexp.Regexp // upstreamRefRegex enforces the schema pattern for per-op upstream refs upstreamRefRegex *regexp.Regexp + // connectTimeoutRegex enforces the ms|s|m|h unit contract for upstream connect timeouts + connectTimeoutRegex *regexp.Regexp // policyValidator validates policy references and parameters policyValidator *PolicyValidator } @@ -50,6 +52,7 @@ func NewAPIValidator() *APIValidator { versionRegex: regexp.MustCompile(`^v?\d+(\.\d+)?(\.\d+)?$`), urlFriendlyNameRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_\. ]+$`), upstreamRefRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`), + connectTimeoutRegex: regexp.MustCompile(`^[+-]?\d+(\.\d+)?(ms|s|m|h)$`), } } @@ -383,6 +386,11 @@ func (v *APIValidator) validateUpstreamDefinitions(definitions *[]api.UpstreamDe Field: fmt.Sprintf("spec.upstreamDefinitions[%d].timeout.connect", i), Message: fmt.Sprintf("Invalid timeout format: %v (expected format: '30s', '1m', '500ms')", err), }) + } else if !v.connectTimeoutRegex.MatchString(timeoutStr) { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("spec.upstreamDefinitions[%d].timeout.connect", i), + Message: fmt.Sprintf("Invalid timeout format: %q (expected units: ms, s, m, h)", timeoutStr), + }) } else if d <= 0 { errors = append(errors, ValidationError{ Field: fmt.Sprintf("spec.upstreamDefinitions[%d].timeout.connect", i), diff --git a/gateway/gateway-controller/pkg/config/validator_test.go b/gateway/gateway-controller/pkg/config/validator_test.go index 91201f249..8a929aa96 100644 --- a/gateway/gateway-controller/pkg/config/validator_test.go +++ b/gateway/gateway-controller/pkg/config/validator_test.go @@ -867,6 +867,35 @@ func TestValidateUpstreamDefinitions_MalformedTimeout(t *testing.T) { assert.Contains(t, errors[0].Message, "Invalid timeout format") } +func TestValidateUpstreamDefinitions_TimeoutUnitContract(t *testing.T) { + validator := NewAPIValidator() + + // time.ParseDuration accepts units outside the ms|s|m|h contract (ns, us, compounds); + // these must be rejected as invalid format, not silently accepted. + for _, badTimeout := range []string{"5ns", "100us", "1h30m"} { + connect := badTimeout + definitions := &[]api.UpstreamDefinition{ + { + Name: "my-upstream", + Timeout: &api.UpstreamTimeout{ + Connect: &connect, + }, + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://backend:8080"}, + }, + }, + } + + errors := validator.validateUpstreamDefinitions(definitions) + require.Len(t, errors, 1, "timeout %q must be rejected", badTimeout) + assert.Equal(t, "spec.upstreamDefinitions[0].timeout.connect", errors[0].Field) + assert.Contains(t, errors[0].Message, "Invalid timeout format") + } +} + func TestValidateUpstreamRef_ValidRef(t *testing.T) { validator := NewAPIValidator() From f6332a74b082eab74c25b5cc932fc0560118bb5e Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Mon, 8 Jun 2026 06:48:35 +0530 Subject: [PATCH 5/9] chore(gateway): tidy upstream cluster-naming and basePath comments Use "URL-stable" instead of "EDS-stable" for the cluster-naming comments (the clusters are STRICT_DNS with inline endpoints, not EDS), and drop the PR-number references from the basePath comments so they read on their own. --- gateway/gateway-controller/pkg/transform/restapi.go | 8 ++++---- gateway/gateway-controller/pkg/xds/translator.go | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/gateway/gateway-controller/pkg/transform/restapi.go b/gateway/gateway-controller/pkg/transform/restapi.go index 10f411a93..6637d9076 100644 --- a/gateway/gateway-controller/pkg/transform/restapi.go +++ b/gateway/gateway-controller/pkg/transform/restapi.go @@ -389,7 +389,7 @@ type upstreamClusterResult struct { // ClusterKey is the internal key used in rdc.UpstreamClusters. ClusterKey string // EnvoyClusterName is the Envoy cluster name. For API-level upstreams it is - // the EDS-stable hashed name "_<16-hex>" (matching ClusterKey). For + // the URL-stable hashed name "_<16-hex>" (matching ClusterKey). For // per-op upstreams it is empty because the route resolves the cluster via // ClusterKey directly. This is the value Envoy knows the cluster by, so the // policy engine must use it for the x-target-upstream header. @@ -429,7 +429,7 @@ func (t *RestAPITransformer) addUpstreamCluster( basePath = "/" } - // EDS-stable cluster naming: derived from sha256(apiID|env) so URL edits + // URL-stable cluster naming: derived from sha256(apiID|env) so URL edits // propagate as endpoint updates rather than cluster recreates. ClusterKey and // EnvoyClusterName are intentionally the same string so the policy engine's // `default_upstream_cluster` metadata points at the actual Envoy cluster. @@ -465,7 +465,7 @@ func (t *RestAPITransformer) addUpstreamCluster( // unconditionally (see the upstreamDefinition cluster loop in Transform), so a per-op // route reuses it rather than minting its own cluster. One cluster per definition // serves both the main and sandbox vhosts — the key carries no env component. Reuse -// also means the route inherits the definition's authoritative basePath (#2065), +// also means the route inherits the definition's authoritative basePath, // avoiding the URL-derived-basePath divergence a separate per-op cluster would have. func perOpDefinitionClusterKey(kind, uuid string, target *api.RestAPIOperationUpstreamTarget, upstreamDefinitions *[]api.UpstreamDefinition) (string, error) { def, err := upstreamref.FindByName(target.Ref, upstreamDefinitions) @@ -489,7 +489,7 @@ func resolveUpstreamURL(name string, up *api.Upstream, defs *[]api.UpstreamDefin refName := strings.TrimSpace(*up.Ref) // Resolve through the shared upstreamref helper (one source of truth for ref // lookup, shared with the per-op transformer and the xDS translator), and return - // the definition's base path (from basePath, #2065) so the caller rewrites the + // the definition's base path (from basePath) so the caller rewrites the // upstream path correctly. The "no URLs" check stays here since FindByName // resolves the definition, not its endpoints. def, err := upstreamref.FindByName(refName, defs) diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index cb1e8ff3e..160d2b417 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -836,7 +836,7 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* } // Reuse the referenced upstreamDefinition's cluster (built unconditionally // below) instead of minting a per-op cluster. routeURLPath carries the - // definition's base path (#2065) so the route's static rewrite prepends it + // definition's base path so the route's static rewrite prepends it // exactly once. Keep cluster_header ON with that cluster as the default so a // dynamic-endpoint policy can still steer this operation. Precedence: // op-policy > api-policy > per-op ref > api-level upstream. @@ -917,7 +917,7 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* return nil, nil, fmt.Errorf("per-op sandbox upstream for %s %s: %w", string(op.Method), op.Path, err) } // Reuse the referenced upstreamDefinition's cluster; routeURLPath carries - // its base path (#2065). Keep cluster_header ON so a sandbox dynamic-endpoint + // its base path. Keep cluster_header ON so a sandbox dynamic-endpoint // policy can override the per-op default — consistent with the API-level // sandbox routing fixed in #2059 (no static-pin bypass). sbRouteCluster = defClusterName @@ -990,7 +990,7 @@ 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. // The cluster name is derived from sha256(apiID|upstreamName), giving the -// API-level main/sandbox cluster an EDS-stable identity: URL edits update +// API-level main/sandbox cluster a URL-stable identity: URL edits update // endpoints in-place rather than destroying and recreating the cluster. func (t *Translator) resolveUpstreamCluster(apiID, upstreamName string, up *api.Upstream, upstreamDefinitions *[]api.UpstreamDefinition) (string, *url.URL, *resolvedTimeout, error) { var rawURL string @@ -1052,18 +1052,18 @@ func (t *Translator) resolveUpstreamCluster(apiID, upstreamName string, up *api. parsedURL.Path = *refBasePath } - // Generate cluster name from EDS-stable hash (URL intentionally excluded). + // Generate cluster name from URL-stable hash (URL intentionally excluded). clusterName := upstreamName + "_" + clusterkey.APILevel(apiID, upstreamName) return clusterName, parsedURL, timeout, nil } // resolvePerOpDefinitionCluster resolves a ref-only per-op upstream target to the -// EXISTING upstreamDefinition cluster: its EDS cluster name +// EXISTING upstreamDefinition cluster: its stable cluster name // (upstream___) and base path. The definition's cluster is // created unconditionally for every definition, so a per-op route reuses it rather // than minting its own — one cluster per definition serves both vhosts (no env in -// the key). The base path comes from the definition's basePath field (#2065); the +// the key). The base path comes from the definition's basePath field; the // caller passes it as the route's upstream path so the static rewrite prepends it. func (t *Translator) resolvePerOpDefinitionCluster(kind, apiID string, target *api.RestAPIOperationUpstreamTarget, upstreamDefinitions *[]api.UpstreamDefinition) (string, string, *resolvedTimeout, error) { refName := strings.TrimSpace(target.Ref) From d085dc0e10818683f1cbb489d28ca00e40e434d7 Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Mon, 8 Jun 2026 06:38:35 +0530 Subject: [PATCH 6/9] fix(gateway): reject identical main and sandbox vhost in the xDS path The RuntimeDeployConfig transform already rejects a config whose main and sandbox resolve to the same vhost, where sandbox routes would collide with main routes on the same route key. The router xDS path built sandbox routes without that guard, so the two paths disagreed. Add the same check to the xDS path so both reject the configuration consistently. --- gateway/gateway-controller/pkg/xds/translator.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index 160d2b417..03736f3b9 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -866,6 +866,13 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* } } if hasSandbox { + // Guard: sandbox and main vhosts must differ, otherwise sandbox routes share + // the route key with main routes and collide. This mirrors the same check in + // the RuntimeDeployConfig transform path so both paths reject this + // configuration consistently. + if effectiveMainVHost == effectiveSandboxVHost { + return nil, nil, fmt.Errorf("sandbox upstream is configured but resolves to the same vhost %q as the main upstream; configure distinct vhosts to avoid route conflicts", effectiveMainVHost) + } var sbClusterName string var parsedSbURL *url.URL var sbTimeout *resolvedTimeout From bc4fb655d2864cdaa344c170d736a9c69502b387 Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Mon, 8 Jun 2026 06:38:36 +0530 Subject: [PATCH 7/9] docs(gateway): clarify the controller wiring comment for the xDS paths Describe the current wiring (only policy xDS receives the transformer registry; main Envoy xDS translates RestAPI configs directly) instead of implying the xDS translator cannot use transformers. --- gateway/gateway-controller/cmd/controller/main.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 0143bbdf3..933b2d163 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -390,11 +390,9 @@ func main() { llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver) transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer) policyManager.SetTransformers(transformerRegistry) - // The policy manager receives the transformer registry; the snapshot - // manager's xDS translator does not. The main xDS flow therefore uses - // translateAPIConfig directly, while RuntimeDeployConfig output is - // consumed only by the policy xDS flow. Both flows derive the same - // cluster names. + // 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; From 889f7671f6290452a514d91df23be51cc5ca4e4c Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Mon, 8 Jun 2026 14:29:32 +0530 Subject: [PATCH 8/9] test(gateway): correct upstream IT scenario labels and add empty per-op sandbox rejection --- .../it/features/api-level-eds-stable.feature | 4 +-- .../it/features/per-op-upstream-basic.feature | 2 +- .../per-op-upstream-validation.feature | 28 +++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/gateway/it/features/api-level-eds-stable.feature b/gateway/it/features/api-level-eds-stable.feature index 21d82bb02..e9461fd14 100644 --- a/gateway/it/features/api-level-eds-stable.feature +++ b/gateway/it/features/api-level-eds-stable.feature @@ -17,7 +17,7 @@ # -------------------------------------------------------------------- @api-level-eds-stable -Feature: API-Level Upstream EDS-Stable Cluster Naming +Feature: API-Level Upstream URL-Stable Cluster Naming As an API developer I want API-level main and sandbox upstream URL edits to propagate as endpoint updates rather than cluster recreates @@ -26,7 +26,7 @@ Feature: API-Level Upstream EDS-Stable Cluster Naming Background: Given the gateway services are running - Scenario: API-level main upstream URL update routes to new backend (EDS-stable design) + Scenario: API-level main upstream URL update routes to new backend (URL-stable cluster naming) Given I authenticate using basic auth as "admin" When I deploy this API configuration: """ diff --git a/gateway/it/features/per-op-upstream-basic.feature b/gateway/it/features/per-op-upstream-basic.feature index 52f2e88da..8a00bc5e7 100644 --- a/gateway/it/features/per-op-upstream-basic.feature +++ b/gateway/it/features/per-op-upstream-basic.feature @@ -339,7 +339,7 @@ Feature: Per-Operation Upstream Basic Routing When I delete the API "per-op-basic-both-api-v1.0" Then the response should be successful - Scenario: Per-operation upstream definition URL update routes to new backend (EDS-stable design) + Scenario: Per-operation upstream definition basePath update routes to new path (URL-stable cluster naming) Given I authenticate using basic auth as "admin" When I deploy this API configuration: """ diff --git a/gateway/it/features/per-op-upstream-validation.feature b/gateway/it/features/per-op-upstream-validation.feature index d6a1b229e..5147b6d27 100644 --- a/gateway/it/features/per-op-upstream-validation.feature +++ b/gateway/it/features/per-op-upstream-validation.feature @@ -109,6 +109,34 @@ Feature: Per-Operation Upstream Validation And the JSON response field "status" should be "error" And the response body should contain "Upstream ref is required" + Scenario: Empty per-op sandbox leaf with no API-level sandbox is rejected + 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: per-op-val-empty-sandbox-api-v1.0 + spec: + displayName: Per-Op-Val-Empty-Sandbox-API + version: v1.0 + context: /per-op-val-empty-sandbox/$version + vhosts: + main: per-op-val-empty-sandbox-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + sandbox: {} + """ + Then the response should be a client error + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Upstream ref is required" + Scenario: Per-op ref with invalid characters is rejected Given I authenticate using basic auth as "admin" When I deploy this API configuration: From f2517dc17759cb9cec770b1fb97140398e705e0d Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Mon, 8 Jun 2026 14:35:48 +0530 Subject: [PATCH 9/9] test(gateway): rename api-level eds-stable IT file and tag to url-stable --- ...pi-level-eds-stable.feature => api-level-url-stable.feature} | 2 +- gateway/it/suite_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename gateway/it/features/{api-level-eds-stable.feature => api-level-url-stable.feature} (99%) diff --git a/gateway/it/features/api-level-eds-stable.feature b/gateway/it/features/api-level-url-stable.feature similarity index 99% rename from gateway/it/features/api-level-eds-stable.feature rename to gateway/it/features/api-level-url-stable.feature index e9461fd14..12f7a4ada 100644 --- a/gateway/it/features/api-level-eds-stable.feature +++ b/gateway/it/features/api-level-url-stable.feature @@ -16,7 +16,7 @@ # under the License. # -------------------------------------------------------------------- -@api-level-eds-stable +@api-level-url-stable Feature: API-Level Upstream URL-Stable Cluster Naming As an API developer I want API-level main and sandbox upstream URL edits to propagate as diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index ce2a77dae..9a2c30acb 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -145,7 +145,7 @@ func getFeaturePaths() []string { "features/per-op-upstream-basic.feature", "features/per-op-upstream-ref.feature", "features/per-op-upstream-validation.feature", - "features/api-level-eds-stable.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