diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 47c58fffb..3b0dce5bf 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -4313,6 +4313,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 199b76602..713c4e843 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -407,6 +407,9 @@ func main() { llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver) transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer) policyManager.SetTransformers(transformerRegistry) + // In this controller wiring, only policy xDS receives the transformer + // registry. Main Envoy xDS still translates RestAPI configs directly, so + // both paths must keep cluster-name derivation in sync. // Load runtime configs from existing API configurations on startup. // We write directly to runtimeStore to avoid triggering N separate snapshot updates; diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index 4543d1512..513ddb281 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -1186,6 +1186,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 @@ -1247,6 +1250,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 @@ -2393,6 +2418,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 @@ -5285,187 +5420,191 @@ var swaggerSpec = []string{ "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", - "h8xIt8//JwAA//9q4TK55lQCAA==", + "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", + "AP//j7hCAOxZAgA=", } // 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..1115369fe 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,10 @@ 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 + // connectTimeoutRegex enforces the ms|s|m|h unit contract for upstream connect timeouts + connectTimeoutRegex *regexp.Regexp // policyValidator validates policy references and parameters policyValidator *PolicyValidator } @@ -46,6 +51,8 @@ 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\-_]+$`), + connectTimeoutRegex: regexp.MustCompile(`^[+-]?\d+(\.\d+)?(ms|s|m|h)$`), } } @@ -244,16 +251,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 +295,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 +374,28 @@ 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 !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), + Message: "Connect timeout must be a positive duration", + }) } } } @@ -421,7 +452,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 +583,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 +636,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..8a929aa96 100644 --- a/gateway/gateway-controller/pkg/config/validator_test.go +++ b/gateway/gateway-controller/pkg/config/validator_test.go @@ -811,6 +811,91 @@ 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 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() @@ -892,3 +977,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..6637d9076 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 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. 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) + // 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. + 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, +// 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) 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 408a6dda7..ffaab491b 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,116 @@ 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 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 + } + } } - - // Timeout for sandbox upstream cluster - var sbUpstreamClusterConnectTimeout *time.Duration - if sbTimeout != nil { - sbUpstreamClusterConnectTimeout = sbTimeout.Connect + } + 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 + var sbRouteHostRewrite *api.UpstreamHostRewrite + + 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. 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 +996,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 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 var timeout *resolvedTimeout var refBasePath *string @@ -974,12 +1059,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 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 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; 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 +2826,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 { @@ -2999,39 +3113,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..658dcf7cf 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,14 +237,51 @@ 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") } +// 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" @@ -262,7 +302,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 +333,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 +355,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 +365,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 +378,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 +778,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 +1493,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 +1512,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 +1530,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 +2157,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..ab50a2573 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,59 @@ 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 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) + 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 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") +} + // ============================================================================= // translateRequestActionsCore Tests // ============================================================================= diff --git a/gateway/it/features/api-level-url-stable.feature b/gateway/it/features/api-level-url-stable.feature new file mode 100644 index 000000000..12f7a4ada --- /dev/null +++ b/gateway/it/features/api-level-url-stable.feature @@ -0,0 +1,203 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +@api-level-url-stable +Feature: API-Level Upstream URL-Stable Cluster Naming + As an API developer + I want API-level main and sandbox 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 (URL-stable cluster naming) + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: api-level-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 + basePath: /api-main + upstreams: + - url: http://sample-backend:9080 + upstream: + main: + ref: backend-default + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-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..8a00bc5e7 --- /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 basePath update routes to new path (URL-stable cluster naming) + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: 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..5147b6d27 --- /dev/null +++ b/gateway/it/features/per-op-upstream-validation.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. +# -------------------------------------------------------------------- + +@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: 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: + """ + 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..9a2c30acb 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-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