diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 454eba734d..d88f025a74 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -3016,6 +3016,14 @@ components: default: deployed example: deployed + UpstreamReference: + type: string + description: Name of a predefined upstreamDefinition. + minLength: 1 + maxLength: 100 + pattern: '^[a-zA-Z0-9\-_]+$' + example: my-upstream-1 + UpstreamDefinition: type: object required: @@ -3024,12 +3032,7 @@ components: description: Reusable upstream configuration with optional timeout and load balancing settings properties: name: - type: string - description: Unique identifier for this upstream definition - minLength: 1 - maxLength: 100 - pattern: '^[a-zA-Z0-9\-_]+$' - example: my-upstream-1 + $ref: "#/components/schemas/UpstreamReference" basePath: type: string description: Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. Must start with '/' and must not end with '/'; omit for root. @@ -3100,8 +3103,7 @@ components: description: Direct backend URL to route traffic to example: http://prod-backend:5000/api/v2 ref: - type: string - description: Reference to a predefined upstreamDefinition + $ref: "#/components/schemas/UpstreamReference" hostRewrite: type: string enum: @@ -3140,6 +3142,8 @@ components: $ref: "#/components/schemas/Policy" resilience: $ref: "#/components/schemas/Resilience" + upstream: + $ref: "#/components/schemas/OperationUpstream" OperationMethod: type: string @@ -3209,6 +3213,29 @@ components: enum: [Exact, RegularExpression] default: Exact + OperationUpstream: + 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. + minProperties: 1 + properties: + main: + type: object + additionalProperties: false + required: + - ref + properties: + ref: + $ref: "#/components/schemas/UpstreamReference" + sandbox: + type: object + additionalProperties: false + required: + - ref + properties: + ref: + $ref: "#/components/schemas/UpstreamReference" + Policy: type: object required: diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index 5ab19469d7..4a3739a03f 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -788,8 +788,8 @@ type LLMProviderConfigData_Upstream struct { // HostRewrite Controls how the Host header is handled when routing to the upstream. `auto` delegates host rewriting to Envoy, which rewrites the Host header using the upstream cluster host. `manual` disables automatic rewriting and expects explicit configuration. HostRewrite *LLMProviderConfigDataUpstreamHostRewrite `json:"hostRewrite,omitempty" yaml:"hostRewrite,omitempty"` - // Ref Reference to a predefined upstreamDefinition - Ref *string `json:"ref,omitempty" yaml:"ref,omitempty"` + // Ref Name of a predefined upstreamDefinition. + Ref *UpstreamReference `json:"ref,omitempty" yaml:"ref,omitempty"` // Url Direct backend URL to route traffic to Url *string `json:"url,omitempty" yaml:"url,omitempty"` @@ -1127,8 +1127,8 @@ type MCPProxyConfigData_Upstream struct { // HostRewrite Controls how the Host header is handled when routing to the upstream. `auto` delegates host rewriting to Envoy, which rewrites the Host header using the upstream cluster host. `manual` disables automatic rewriting and expects explicit configuration. HostRewrite *MCPProxyConfigDataUpstreamHostRewrite `json:"hostRewrite,omitempty" yaml:"hostRewrite,omitempty"` - // Ref Reference to a predefined upstreamDefinition - Ref *string `json:"ref,omitempty" yaml:"ref,omitempty"` + // Ref Name of a predefined upstreamDefinition. + Ref *UpstreamReference `json:"ref,omitempty" yaml:"ref,omitempty"` // Url Direct backend URL to route traffic to Url *string `json:"url,omitempty" yaml:"url,omitempty"` @@ -1239,6 +1239,9 @@ type Operation struct { // Resilience Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply. Resilience *Resilience `json:"resilience,omitempty" yaml:"resilience,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 *OperationUpstream `json:"upstream,omitempty" yaml:"upstream,omitempty"` } // OperationHeaderMatch defines model for OperationHeaderMatch. @@ -1302,6 +1305,18 @@ type OperationPolicyPath struct { // OperationPolicyPathMethods HTTP method: GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, or * for all type OperationPolicyPathMethods string +// OperationUpstream 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 OperationUpstream struct { + Main *struct { + // Ref Name of a predefined upstreamDefinition. + Ref UpstreamReference `json:"ref" yaml:"ref"` + } `json:"main,omitempty" yaml:"main,omitempty"` + Sandbox *struct { + // Ref Name of a predefined upstreamDefinition. + Ref UpstreamReference `json:"ref" yaml:"ref"` + } `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` +} + // Policy defines model for Policy. type Policy struct { // ExecutionCondition Expression controlling conditional execution of the policy @@ -1638,8 +1653,8 @@ type Upstream struct { // HostRewrite Controls how the Host header is handled when routing to the upstream. `auto` delegates host rewriting to Envoy, which rewrites the Host header using the upstream cluster host. `manual` disables automatic rewriting and expects explicit configuration. HostRewrite *UpstreamHostRewrite `json:"hostRewrite,omitempty" yaml:"hostRewrite,omitempty"` - // Ref Reference to a predefined upstreamDefinition - Ref *string `json:"ref,omitempty" yaml:"ref,omitempty"` + // Ref Name of a predefined upstreamDefinition. + Ref *UpstreamReference `json:"ref,omitempty" yaml:"ref,omitempty"` // Url Direct backend URL to route traffic to Url *string `json:"url,omitempty" yaml:"url,omitempty"` @@ -1674,8 +1689,8 @@ type UpstreamDefinition struct { // BasePath Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. Must start with '/' and must not end with '/'; omit for root. BasePath *string `json:"basePath,omitempty" yaml:"basePath,omitempty"` - // Name Unique identifier for this upstream definition - Name string `json:"name" yaml:"name"` + // Name Name of a predefined upstreamDefinition. + Name UpstreamReference `json:"name" yaml:"name"` // Timeout Timeout configuration for upstream requests Timeout *UpstreamTimeout `json:"timeout,omitempty" yaml:"timeout,omitempty"` @@ -1690,6 +1705,9 @@ type UpstreamDefinition struct { } `json:"upstreams" yaml:"upstreams"` } +// UpstreamReference Name of a predefined upstreamDefinition. +type UpstreamReference = string + // UpstreamTimeout Timeout configuration for upstream requests type UpstreamTimeout struct { // Connect Connection timeout duration (e.g., "5s", "500ms") @@ -4561,261 +4579,263 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "/wcAAP//nIc3NSrXAQA=", + "H4sIAAAAAAAC/+y97XbbNro/eisY/btX7FaUZTtJG2fNmqPYbqpJnGj80u4zlXcDkZCFhgJZAHSsZrzX", + "uYhzhedKzsIbCZIgRcmyLbvuhyYRSeAB8LzjhwdfW340jSOCCGetva8t5k/QFMq/9gb9/YiM8cUB5FD8", + "ENMoRpRjJB/7EeHoiou/Boj5FMccR6S113oDGQIx5BMwjiiAYQh6gz6gUcIRAxvThHHAOKQcfMF8Arba", + "gESAU4hDTC4ACyGbbHbAGUPgm0tEGY4I4BFA0xEKAJ8gYH7ERP5TdrSBOhedNtiiCAaYXHghZnwr/Zwi", + "FoWXiIl28q9cbne6m51Wu4Wu4DQOUWuv5W6j1W5N4dV7RC74pLW30+22W1NMzL+3260Yco6oGP7/DIdb", + "G79C78+e9++u9+q34dAbDrfOv/1VPDjf/Mc3rXaLz2LRF+MUk4vWdbsVoDiMZlNE+AmHHKlJHcMk5K09", + "/RAFrXZhpg8QwxQFIPtazCxHwAPPzEfPwIZuaRNEFDxLSPqkA36ZIAIY4mJm7CdtObVi2TADFE2jSxSA", + "MY2mahmpWK/xGPtglHDgSyZJKBRUteVXn9GMtQEkAYijEPsYMQApAjFFDFHZVkRBHHFEOIYhoCgbgVwN", + "kkxbe7/aA8+Ia53by2W9Up5UzOIQzj7AKSpz6U/JFBJPLDYchWqsBE6RZtARAmfH770xxYgE4Qx4ICLh", + "DIRIrDJrA5JMR/IvLIY+Ym0wmcUTRFgbCEIp8yOK9AwEEWdCCqIvKNjMsdqx4jTwHjMuCMgz2XYtk2UM", + "Nhx6vw2HHXD+nZOzhMjKlWHlOZAdR2Pw0+npAGQvbilZbbVbmKOp/O4bisatvdb/2cq0xZZWFVsfzYei", + "uykmffXRdkoMpBTOxEPDDNWU9AZ9L0SXKLQYJ45DLGQ/krokIxMkJESMgegSUYqDAJGmFA9E25KiIoUU", + "MRxiRHw0r43j7M3rdoslo3Q4gxDWTbb9KohDSCTfMQAvIQ4lLwrh4BPMNE+kDPNr620UCk4/weElokIQ", + "0uGW1r04siRmnCI4LROWzbl5Jy/SrXZB808hJvOm58x0JyYHkmAUXTX/RC7EH4nQbWLUsr/zdEjR6Hfk", + "c3tMB2iMCZ7D5BQlTE5vOsog+0zZokh+A0PA8RRFRdXWWCDOSmS5FsRYlhLBJ2gKCcd+aumisVHHOfUh", + "jFcrpxQuh8Pgu+GwI/5wKoPLScS4Y472E8ajKbjElCcwBPKtrSASE880O5r+3awwt7kNtqkb3GCbSv3T", + "KEh8KQXamnTAR4KEkZpGFMmvpGQMCUMxpJCjAIxm4NnrZ+D/+3/+X4CgP0lfAtKuMEmn6CRbZOkagC/C", + "0EHwFnL0Bc7EUIZEaL1joekA5Bz6E+UgTJOQ4zhEQNh/RBDNCNnsgNMJAmNMGQeIcDoT5lE6IRRPIZ0N", + "iZzgDjjM0TaFM2FQIPiCw8CHNAAs8ScAMvBtRy9nx4+mnSHJrS+Msf34dRD5LPdD7us8J2wMh98Oh53N", + "f2R2ojMceuffbQyH7NvX4n+Vr2x+6+QdS4rnrrZearnO+juzyLkh6mdeYaitSlOnKHTQ10xlFN6yHYRM", + "INupa2tpzZwhdemi3qD/Ds3Ks3OAOMQhE0IMiXGO7En4Kha6H7T2WrbnKabE0xIOYyybFn+Jf9ve2X3+", + "4uX3P7zqwpEfoPGi/xbjo0hIU084lzvdnZde97nX3T7d7u7tdve63X9nr7yR3QZTLKYl50+1jmZgkInw", + "Oz2oGFPERMMkCcN2i6h3pzMvE3dPTQCLEirMbCuMfBiKHzjkCRP9+RxfSrOaVzZ6noozfEbwHwkCcTIK", + "sQ9wIJzKMUbU0puATyCX//iMpNBCxiIfS5UiNH+OKauWoSQRZl2KBL0VakO2rZdbWRe5esIHHuOroqCv", + "ZFlLBFrrXKTxFE8R43AaK9Vo5kkSCxm4MEPIEVrBK+OITqEMVCBHnrCdNcS8cUxYv7RmCUMUfJlEGSE2", + "ifnZ09x5I/df6mnL0MmJ2BBUCMa9xAEK2mCacPFy3ol3iUG9F18i1JKaIpmH4hFURjJdsQ0hWwCPReCM", + "0hc2i0v1vdfdFkvVFetUt1SiOTGw1h6nCXISKHQxDI/R2CWAh/oxoGiMqHCJQf+gOJs56vwwSgIhW1Oh", + "DLxXP3z/8oVrCYlz7URkxuAY2bJeWjuY8MjLuEcGrxZHtAGe6vVsC24LhDmWuQThakwRRzQ/oS4VZq3z", + "y93cMu+WLFjXe3X+3YaX/rXKymqtWHIK5e+2SpOjlLpTuExmiTat8NkoVvMsHzmbp2UStB4ukSB/L5Bg", + "dafVtjCxl9FnrTpiaWtzHafv1ZtwoqyyUvopVbZSs3WKLUXpLFbb6X3xIY7IMfojQUwKnmWQK62WyyQ5", + "TcBHE0nEIcTEE95EumiXMEyUsjELo6wSESTiiHSGpD8GmdqRoaCyImEoHEnJrpgwjmAglkNzOSYXAAKC", + "voCIoM6QnGpzZz6bQDYRLjQaC/ea8YjCC6RcWvGaD4l4CxMAyQwoRTEkG1NM8DSZgt2XwJ9ACn2OKNMJ", + "OkmZGIimnVykQwpnmeoeEpMTKrq4V/I/7wuLdqSljUPIRc9SK+iH6g9hMW35enlzPdoB/TEYRXwC9Id9", + "IhM2aTM6Z2XWIfudw8+ICUvuo0Cou07ZSm7veN0flrCSKSm1Ywh0SOpQsnn+NC86/FLThM2OpgN7PLvd", + "lExMOLpAVIbeBFd4FUA8crSntQRDfkQCppZTp5kmUULFnwGciT++IPRZvhARPmGFfJ96pV51SOLa2eBd", + "emAVNk0KmRABjMJAuJVpAkHwkRRT+QWFvpCNOKFxxBCTuUQtoBc6IjXCwgDmDERfCBCTLSkw/VLof8bk", + "oihDTW0pZixBtMb50qFsRDkMlcOs1WuqgaTEZAIhU6IwxlK0Qd7WDolojAm3Srdo1BD0fRRzFMjGSMRz", + "mg5RJOaRROYrisQIjF4sus2ZwgjQpfrCNfQpZJ9R0KvQ1UfyqSPbItWimHrtN6QL2BmSgSYajGZq2jQh", + "8jvpUmc6MabI08rXpQSl+//tt99+ezX78/sfXjX3g/rOUMesU35qIdC7ALbTZJbE7e3ficdz3cBEszgi", + "DBVsdGZ5n8LnqvB5ihiDF0jleCU3Z0LKEt9HjI2TMJxJn20KMcHkQknJv5KIw9beK6tZ/UGdD1SXFNX5", + "EZsqaz3nE1iSCTfFRRk5Nm+lAv2HeDHV5CLEs7n+lcvYZR6xlbrS0zHPFqV+qxl2tVP6HjNuc7trmuVf", + "G2WhswkvZp4XGk67xSMOw/0oIS6DL57p3TC9fyN1XM6BKE9ptdQfI+PNVjjnJfZb0Ot7ctUemKtWxyuX", + "kV+yEYUNijplowPVuarmjuT/LBb8ZnE9DMOP49ber00EvRjRXp/n6dBa+vy63doX0zPGPuSoXuX42YvN", + "9Y7VetryipTQmxl3bR4rJTQSD2WaPQyBRTkY4xDlFNLOzvaLV05Fv4iqq+2ioc5zzZUDaOOk54OLEmZg", + "MYIim6Bt13BxdTbd8hI3zs76B5up/rJ6y+nSFy+66Ifn3a6Hdl6NvOfbwXMPfr/90nv+/OXLFy+eP+92", + "u91F4hJrboB6Bxx8ABuCDLUDJwgBeAxGCQmKWdn9D38/moH9Xvuj+PMjvYAE/6kAKvt/PztxBgmZpijk", + "vRRXApnnUKZBBXnmi1zHFtVJHEZQxAgiGjw5OAGJFPD5+sbt7gvH0Tj6VYswnXm+3I7zfOhsOeK9MZ83", + "3cgyX+LfDSddWdNtb+cl6L7c636/t/OysTG11IGxPqkyQJRGNG9bajQFS5R41Y5Qv3SbHDVH3s8kc1jK", + "vlL1lkcyODzyEPEjwVv/3XnRfWXzwwbb7IB9SIAfEQ4xyXa0bT2RT1l54r83h2/7H8D+4fFp/8f+fu/0", + "UP46JEf9/sF/n+7v9z7/ctH70n/Tu+j/s/fufffs7XfT43f896Ne9+3+yR9vT/qj3YN/Hb7Z/3LWOzo8", + "u9r/s/fPNxcffh6STqczJLK1ww8Hjh4WSP0r7ZTbrrGG1QFHGr2VqBehTyPGiiahMPqC0CyBwer81mhX", + "Oi+1coQub+BQ8Hu1PZDiwKp2mlEg3EQcKPHV7zYErvycfihJcJntSi35E76YaBiR7BTYj3OCZGNqbFrH", + "kvqm/pdSCivxvg6vOIUyts4yKuVpx7ln+cH/8+TjhwFUmWSKmMojUTBBMEBUcSuPjE1VCSMefUbao89N", + "zzedRBDawSRO+Kl4yanlQu35lmn5RSbReATGmARWV5btsnz8GM6EHhKevSS21W79kSA6G0AKNQ5jov6e", + "07/ZZ/Xzn5LZtufPtQjv3x/1pE7fjwinUejg+ysfxRUgLz355gUxfDFyqCy3r5oE0yhATWVBIoMOTYtO", + "URCtldF0zi7TPbIwjL78BsNQYnnJTP61AGjVv86FuIiWK2ZSAxxLU2iUqrULGE49P2LcG0GGAo9CjkI8", + "lTFZiecELzSPA1IyxNrMAcDZoLamG4MZXEfRVTsVkgZHcMgnUZAfklmpt4enrXZr8PFE/nEm/n9w+P7w", + "9FD8s3e6/1Or3fo4OO1//CBs/0+HvYNWu/WtRUU1FFPuMKucThBg5UwOLMLULnxZw4ATObVas44wudAI", + "eL1hzdLcuspKY6ZQtLMOkNsUmDMUjiX8BeTai/zEQK9LUxjrmbMQ8v4EcrniITK4yPoVk2200+lOZ6Bq", + "yVTWmtadPoBFXTGHFfO65bqdP75gkPZbJYj9Cg4z5I8XRDEiEP8lzxO8f38EzNoufLDgQZ0myI1U66us", + "l19OPu6AjzEivX761q1g/y/CaATDQSXq/q18DjZgjJXrtlmG3WsPuvf+vQ29hwxEBAE2gYJfmB/FqA2Q", + "8GYUTFdhDNIPCpj+zs2B+mnT1aP7WNG7IlceKGAx8oVDLiWcbWkFZY8EimgZqIlcnP6POSqdA8mfiYgp", + "8uVGnNMIHBwOjg9F3HQAPJAwa4LNLHTACcdhCCYRiRKxNBtc7+Eq98uXyAwelb/cbDyozL9Y4QEKjqZx", + "6Ax2T/WT1I0WA0+PSNiSlhOyVM+WpMI+CdEsw2odZmj2Yi8RLs/53R9R6IBjg1eQToBpqEPRuHPP5xcq", + "l2rZgwzlrn+2MOiKX1IQhrAvmFx0wEkSxxHlTJg2EkAaAA1Wlxj/NmDJSJ98aAsDl2L29Y86xTCOhCcP", + "jn/c96QnhCHhGeKfJqGQxV/0t8peKbiEOghm0rQhGnNvKqgN4QiF5iBjDtm/6ToYoNhbg+VtV+LFbo3l", + "0Jj//2QW5HzjH3s5e3L+tdt+uX1tvbH5j+Gws/md/uX86077en6qowpan8p5Dluf9+YauYXWblkzIa5q", + "Id0waRd9zCzt0KyHY6Q25RVQUu7AFCWDXiLqTSGBFygAIR4jf+aHSAGIWAcMojgJpbpWx1ZlBkiaG+Fa", + "fCThTBkGR3LxvHik4Gcjny2NMerYgJnOFxbtCPbZkhHXZ0wCoYjCqe2QIA4D7X1rJIJE6ineM8BotUMe", + "I9/plttB+69WxPWrCq3OTYDhiCrEgljvi4DMel2Ev2Gzl7a+yj/7wbWcJhW3Z4G2HQwY/3xLrALjJdRG", + "2WvLDFdmcnIWJlHxk06v7LWEbYiozh1nciQWR0NMExq29loTzmO2t7WVl3axXLbyVcozlyNzwVN2np92", + "v9/b2d7b3v13q536uXXv4KBqvVVnBX9Z721Ut3h9XSPGbhzuExc/VC5ut1Rmc6/1BkGKKGCfvVmUUO9m", + "bO7CHv1c5YekAZjx8nXGObVFJjCcy1m5GLEBH5bcFcWYlQTKxxk9Nv/mus4ztmMHM+P0Ojt1ZN6zWH4h", + "yylTMkWLby2FHrBFke5ojmU/tYKAhY26+fjJnjs14WnmeDk0olaGqRqwOCNTZno3orAXku5YZC/+xs2+", + "RbZNkW4ZXLu1kcJDTWM+pxf10rweUnBgRWtXWabbS9/1XI1qjaeZHTF+JLSwgzypnWsIUou/3NcSlzJn", + "YuQ79fOyqJsgXYAiayxh6g3vVRWSKTNYnVg6t+uWSNDlEgu5ACvlyPrAqpxvo1ESu04TnHBV2AFOcTjz", + "5GuYXNggG51JG80AukR0lo+dMRsSM/8i/tT7+fodnRlI8faaCplCDfBYpgP4kEwgCUKdOmUJHUNfHXpL", + "W4nGMqeXdXSg8rwM8GhIjNLoyABXItyjKeYcBcXw1JXhft51wvKl3nSdCP1I8QVOMwcZSW8SHHJPBM/6", + "JybTQc+E9nv2Gqht/GyyWIp65xF4pp4i+kzmkvWpfJ2thkQfzCqORrRc5IQXrs2xgvJahoMdWmu5ZvKK", + "ark2lO07grFgVbaAj5AZ4kITLjW4DG0FbbhME5XJq1QpSGea8FQQVR0JVTDKIYKaU4UADomRQF+icNAV", + "Zvy14US5Ny2aqZUhnRKzuG63u0jKpaGjdVth15Oz8eRsLBaspXK3rsFaSmB1sJZyfVXQZonFfQRvOTfs", + "FsO3guK/PY/vkdpc1ykm9cRUKJAJ/WwTbKonulDHUIebrbl+63pY5QJDprOxHNexMtuZFhfDMM1h7tLO", + "2HUluVezno33UXmcMoAsfUdGKSY/aYpUBQrbhpl4cjUTHjwEDIXI57mtww4wO7sKWiEBiFwEGPJEM5Xo", + "IYWS+wTZJ1380HZSPuHg02YHpKUMYMInersRYKZ21szpV0mKdGh8GIaqEkFMI458jgJgqUBZrU9+k55v", + "DqMoHkH/s6JTeUIFw+HaM40usK/nKAe1SAlL9/t5pCconTf5dgksLMIoU1VUDCgXAcnpqPXZIOETGsXY", + "96ydrSVBHRWADpOGncOz+W3oOec85JEZYDL5IAynIHbt0mbDi2tykJxCwoSVVaw9X7iuZqfWJ0UlgIMa", + "8b+a1SLESrLGaup0hHoLHrqlj9WIn0P4mCV9KnCAxCB+5AyFkEd0UwYISjq1/WAmFlXxBEOCkRni3ID9", + "UmSAYHVVvEMXnwOfDLGfdEECOIouRcuqjJ/42kTDaStQQ4R/fAc4pBeIK6ZeQDk6lZoDLvCEt7svvN3V", + "7PGD7ZQw3nUB32wf7Wq2CAzjCcD3BOBbVwBfbDmmTbS/rfOXBf8tBSWLtdQ94cj+ijiy2Nogn+MeLokU", + "K3z+tK1czPQqm1eZ3lXymcvtFuApnhHhKnSKfJjp11/P8+qpGqF0hwipwlBuiIyqYLoV5ucf1qotCvi5", + "mq0z2udq5s4eX81cKeOr2d3niXMh9WpTxJarUI7V7zGvUQFxrDdLc/ISp/ksSDGZK4U6TdBaGYHUaddF", + "ma1slSk1r7INKLASfadpBk7VnFMv2q0yAEWAl6Y21LFA8GUSMQTQFfITKSzpK2AKuSqIb5PQBkzmEGlC", + "VA3DrHa0TaWhMCVMPnnGavOM4sMYMmYSLEX6sXhXZiiygTsyhcscrTxNO/KscCI9U7mh6iNJdpHHecM2", + "yCRh03loUv3wtbIjswBqMuwOzN5o5KX5tsIFRY435mf4Kz3sI/h7RD25mLxEXrr3bVN4ua3vQTCXKWic", + "bIhodtMS5mYZMWEchqHwnpMwNE2W97tbNe7mZYX/XhBK+TQba4WA5pRISRMZgGvljSrZCeEM6xrxidQZ", + "JCLIeQJYg2FLuiq9b4WiQCVUOuAXijmSK7KXnecVwimdli3lTcjEBEGXcs55QkmWi1duo0wm9QZ98SUE", + "wi1sq4wamQEahagDekTXo1G1PqOpEDHMASYTRLFWIoxHVFWUSNBrmWn6JAa3B8RQP4k1VnkluTnQcVTy", + "aLe+iPHkfdLSormW6mh/MJD7ew5zQS/kgeVGCV3zrhy+ggBlsOU0Xi7UYrPbLNfZyO7v0fGo6WS5Yj51", + "X2dT5ajzINgu14JK9Okv0tZGURQiqA5xYR6imlmb5FNr8vX5ZLoO6J9XqsUs11A7zVU05euKLFY3xlEw", + "Xu0hO7cyFpsrYq2oatRZO3b52VMCUb/pUXkp39H+QOeC9StAH8q3UuUSecgnSmwfRoo7G9ajTnFnwzTs", + "VMKsHtqLt/qj5POvbstodF/gdvOUsZKq5rv3mQVZ4UHlxUEER/sDk/JxFkKMkV8Z0opJrQxo7cJrL7zu", + "S2/7h9xlH44iilG4EN2nkSqWUXeZ3O0eoS456xMERtD/jEggOU5KLAUJVTXXLYxCemvbX/MUdiaOLo6p", + "utLoL50Pn/rxduEasgeUEU9lcr7vsHBG3Pn5U0Y8y60e+bE7rZr5VN7Uj700F13Orua8r3xuNWfbUyv4", + "63nOGol/5myJZRZaqe4Xb9naOzuEubdlkbC32+3e6UFj1zzdIJtey7Aryab/ZVZ8oRR8ZnXWNQ2fUajT", + "RYYgsaC5PtUK31kC3hHerSoBb3ugi+U60mB3TtQ9xVN06kx6pi0c9Y8OzZw3jNqFs2eH1Skk2FUjFP9Z", + "17t4LJwDWSW85az9vXy4b+hqGPC3WwnFi+QoqsddrKZPcV1hWePRL8YDP1XmX8T4xwnx1Qxh7tywkoVM", + "VaVBd+HUrKzhWN3Uga5itcWRZeFXkekR+tB5Q3rCayhM17+eVNUIYJwmPk8oWnFCSdDuvoqoabnMvADb", + "i+LkFEvJFbQ/IRHPLpR3b7N8daWPciD3rBXpw0M6wpxCOgMkIp4plCtmOD13Km+cU8GCpy5RNfcp5W/T", + "rTcVMY3EGD3pdHS3XwWvXuyOvWD3h5fe9/Dlcw/CVzve9g8vX8GdH3Ze7aBuy4Xnl0HFTcb/XjYgh/4Z", + "zTx1sUcMMVVp6kiVF5c4ehLoHTV9iQ3rgHdoxoCEOZKIp3W+FZKxMBuIXGIaEZm33WtltwjJmh/CIWjp", + "aLqVt/zOYddKnDpf7NJZC1+t2zQjmqLzHKcoSIaQA/KIDJfobYRl0lzvlTAst7R4FGuIoQIQfmcAyFMZ", + "quqXKfbFp89kU8/AKIz8z2BDfQG+U6Dl73TNZ7apM5fmbbmNipjM0cs0PVQFVIQQXKIUh12kZEu2KtgE", + "X5CIoqADehyECDIu8ZvyyjEDeDW3fbk2RiUZjdGOR/Lta1OytXkkl7WgPiyHcj+dng704MCGnn8xitdm", + "hGpT2Zo3hvimXYm2sJsu4fNymvKpma/SelyDOIQ+mkShRPEv0GMuNT6Kos9s6ysOrltFrHjn2yUTpiWk", + "rtqF1ccLMu7diC4RpThA8goKGARy17w36BdgsZs3z7AumxS144tG7JGlA6/r5PonKUxHhnfdxawL/GXV", + "XN/wIUMeJgwRhoWc5Vc1V0G6nBH/2//55r+GSbe78/LZt98Nh17nf3779J//rciPZzv+Zkvk8Ar6vLQf", + "osmTvFaMQMwXx+giCSE9TGvJL7KnrDtQFoVHqqc8gIA0vnxX9VGretPFcYJcVPfCcvkUc0Qx1LvQGX93", + "wOEVFwskfB4pwrIAvXL+WBv4UfQZI9YGiPudkl7T6rZyHpTepwz0Phzoy9510Ww+0asgCDokl9FMH8XR", + "1jYiC4PEbXZ13p1glOlCKjTTfM2Q6pBPNAnFivGqQd1e/aqmpFZqb4txm9RK1xXSTcn0XKStvi9xuGNI", + "JSXQVO4G6XrLg0IaV85SdyVTt5IJHFIpWhioy/tzxJvnTQV0IYN1MytUWP8G0lx1cUCKEds3EDHnpXfm", + "7gt9+4E8lGXjzjKomdkoV/3VRHfZ8GW1gdXfU1AY++K3FSwOVFrgBgMXdY3uMaiU2z3w9vC0DYS0tsHg", + "7LQNlKy2gRTVNtAi2gZCZKUD/K05jLegzD/dj7D6+xHKLlTlPI1hyEoTNUDUy5RdusVpfMwOOIT+BLBk", + "5KlDpDK4yC7BhtKzCgAinM6AjDqR33Hsr3bAEWZMKlvTFgNjGIZy+zbvwWZQ1lxwE42B3ECMKGCQBKPo", + "Kg11GJKrMcXEHvB2OerBZO78FIsEjJvuzKabvI7aBWPn4ulhrA9JLu/73myAnSeQ3mPHJH9+FdGyOv7G", + "UXAO/vZ3IJTAcnA7R39+5E4xLqOJemkqy1I8KdpMAWs3xhQhTwbvn9FsSznrae5w06VnKjf6f84fjTMa", + "7aMIJqcZutdgeXF6ltVsil922wrU+2MShqlrlC981ZYlqzrdTXU7O880KSRBeo84Rb9byO86fLACqv4e", + "0bQbDRcWGiNEmadmg4YtLLHe5je35+MpvEBOTPGNjbNLjI9zYXLxALtMp23pohsufEgHHMFYBvEq7JAe", + "Yc9P7ymOEs7UFX66Ph3k6c3BSmFuqJSBqhYQhvp8/KZYjK2SQ1v+RAF+zQvPdIWQTQtsBzkYRXyivmXt", + "fItaa+sQE35GMrflo0DMiG4kIQzxtr1Iz5g5hZufmvQogCBw5spd4SBEp+ptx7EaRD0dtylLJt5OG7dy", + "JxIWghlHBFHnu2k5HTMbw1aXDVsgwBIOpE9VqJfzkPwuK/rjwXcb+tjp5j82puw/7D/T/0w23ZmDqpEd", + "wSs8Taayy1SBCCVIkZ7CDa0n5a0YxpIb9MMiA9h+sfwIrt0CYgM6HOd6K/Ac1iV1yue088gFqGuGPnDd", + "bS0v+84OqKRbdF8gM7d96oPVG2en+46LTMtAhWY3mdqQh0UJC4Xrkx5Z2tCFZtTLGcZ0hcQ2uwEYMoYv", + "rCMGGnS3gf5IYCiTTHZx0M1lUv4p1uNrUwAxRXFEeZGo1eFzLaDJUstoLvtdKXtVCBvvDfoLwa3EB0/4", + "rQzNI6ckxm5Ej5uF3Zge+92tb7IIPw/vOdZvvRctirWz6mHYVwykuTFzFYDMAFnXBQgDqELwmjccTagk", + "Ur6dsyZvpTG+68Xz3LnfdP4Y4p7J09o+Nc3wWGkaN/vqypMnM2GMY08vpJfNp7leQLml6q4lal2Z62zQ", + "3gzNmgiENxPF8tfr8+vr4kZoAT9lYsrS9QWsM8K/Ywo7AbrcYpIj2VaJd4Sawj7aSsFVd4Wwq1LES2Ps", + "CmpkJai6Jzl8ksM1kcOFcI8iNFtXxKOgrbDTaMQs12Mme3eGeewN+k3hjhbOUSMfK+GOhaub69LllVny", + "3J3pzXPezdLbLhzDwKrsmt/1uWk62TVFJ8iniNedJFz0CCyTLeYoH0SMX1B08q/3Ko8rlm+k6vsx9iWi", + "QfGk2s7zG56TU0TceR24AzOwgXNgKyoGV7GfqJZSZ2M29IFtRHw6i3mRUJbEu5Tt+nSX/82OOKoXpDun", + "tED94ZTKDUeb/4TxXSUPtgEe22EqJn6YBLIowRN73hZ7LngXgb3+t3E648RoI4cbadbZS9fZslYFpdyA", + "RfIepWuujYOTk77F/Ast5OvqYmjy0iRIocqRXo1c5+kK3ZmzUbJ5qzpe4WRm5QLvywDuTMZTDvb6eHK6", + "NTg7BVtKM7A09dEBn+TGqmSdT2bTxZT6eA0YQqBahlSpi1y9EJMpHkUBRqywVfIYxGxO3LztdV+cbnf3", + "ds3haBkTl2l0Bb+Fb+dJ7iLCWClfZdG5FzlJbXNueud/nWYEVZRlEoNLCFza74KSd4w4xejSVTnl7WEm", + "cTJiTsVO+wqYXIAAaQ8qJ4mPUHCq7NOTPN2a3VljWRIC3+doet9u2M20vTsD2ow7S6nOJz/t/vw0t/25", + "q12pj3r/FRNVTkwmhOTFgZeQzl5bMacOv4WfhqyYMwATRJF7G2t1nqeYpGMr51osCZUQ1x5mxGGo40sR", + "P2t7aFu3F65jsua9ymMt+oUO+DGi4h8JxXymkCCZIdU3VGBmLlORLqu6+lLMclrHQ16dR7UtB9Dgg/Ss", + "j2YAy4KR0UgegVN3XBjDre5zbIriL+g/V6WelAHtjIq8K7zZTm2dPi/NZ19hq3KlZuSmM+uAD5FCBEl0", + "VJ7PVYlKsEEi8Elu7XwCER2ST9k+0adNF8gmB6co7lWXrP3y6IITOEUAsjxkAGyZFVWnCHPpC5fart+t", + "Xwn5zSq+niSjdHQq2LPyGCW70a9Iz1tYiw0L6NA/ABHVU5JP6fivxjujlxB52zu7z70XL7//wXsFR74X", + "oHFX/CR+cV4gFMehNktOWrLHOZpkRbUDdDmIKIfh1snpiX0xlMQmZeB8wKw5cR1QbrdGWOJC9/WFrC5S", + "3mANHdXv5OgxQmFqWsJwJk9zcAr9z5hcbNb1ai9ZXc/2MFbQO7Pk3JxV6e2f9n8+tCxw+kP/Q/rX48Of", + "P747PHD6rDaNgxA6x2OPF8QhJODsrH+gijdBLnTsFHOpa0Y4hetaaMXWnH7lnW+uY+3wjwTlZ1FyiexZ", + "cj251PdGKiSbELXXpuQoZGAC2UTmQ4tJ7JG6PNODI397Z/dq9udc6VWy56J7nlA3NK4OQ2lLQePTKHbX", + "abeN7pg7KbDCHG2k11q8mVeZ+x+Pjg6P9/u9966FR1cxprNTXDycIxXt9o63u326s7v34tXei1fN7YRg", + "yg+l8z5vozBYoSDlvNr0saP1KP5I/pVEHB4jaI426n4U3jttRv3TUWV1QiPOQ/ReSNa+YZH0s+1ut+us", + "QGJ/dkYwtwPXIyxs9k9RQlvt1gGctdqto4ioc3zZuPTzOfuDZrrPG7DRSvhfNLScDIgvbyYH1cQXRKDE", + "CjmXqBkn58Wj2Tc6vFOqu8KHqhWZGgmpFYdGvN+Uuxuyc73jtiwEsrjmKuHeVPetZBUf6oI00S8LrkC1", + "xKUu8HzHdMU+4+35g66Wl9AcS2mBJnx1Ww7kyt3CjbSSvdwDT0vev5Y3pQ104suTcKYoywmouwkw48U1", + "YptzA8VV6Js5uuamS+Tq3j76WXGjgKmRm695vaHTJ/omDREBmAODYrIignReLV9ULGydX7e/lo8TXp+X", + "yjFEwluQJf/zDhpMeFQ6lK9PhjEwib7IfMZPEeO6gg7ATEe++vyDLjRrDoplZ0Y/ibY/gQCFSAgRU1Vq", + "qaRCfyDPWbXBlwn2J/qJPg5j95iw0i2rfpgwjqhssgM+TSFJYPgpO1Ejup5Cjn2rPxFJqbpgTPwZYh8X", + "D4AN7WSwnhrVtlNIlzsA2lbQyBJuBlPk85RBzo7fS3FSZ7J0tXRJUOZV6mKRMY0CT3+396Lb7W7BGG9d", + "7th+vqpAtwAPu6/hgH/xyzlOkjgOZ6ZaFQQh5ohCc+ZOHoxiJpdvjmbri4bBJ/XkE+D6ynLBhfpU7uZr", + "0+YXqCgqJ2JpNC3Sme6PavjAml8eUsdvVjltR9GaYuXvvPp01/4Wqx1GMAAjGELiq/oi8sZmVsqyjiBD", + "Ayf+M7v4WBW3S+8/RiSII0zE/OoroVLq9NFcLYabHdALw9xN0vnX5THdCbxEuvaB7ixGJECBrr9tXa78", + "bOuZHFtaaA6RIH3yWq65rgAeFY4TZnrBApJt5ZBknd/+92/f6OpKG5vfftd+/fe9/+u/5DXLW+ff1B3s", + "XlgLWgcsm3xrTppaZa1qdjuMGlUWtVge/gvCFxN99UyeRarvnnEq7TeWtt6Q9k1dX0G5dJbaajH9aIqY", + "EmDDaJvzFLm3LVX5XB3ebqnBuKQmVEXs1AuOwZo7iKdJyHFsy5eetg44tu/eGCc8oUi97mlfJd/ia3UI", + "XRdOmyEONlT1NKnRKOPGxcEM+AmliPBwJovW52/T+qEroZB4KqyGuSpD/cuRoilVWw2dKZQpJn21ttuO", + "hIXjrHnGZ+c1mitj6sryBlDIsyxOal0Ql2m8vJROZ555xdte/tKQCrRyUZychyhLR+Pl8ltHmZUqK29W", + "RYSI2Sk1uq8eWCfpQZA6v0pbDlsv2LAl/+x2p2zYyovIik9C/wxDHMj+DymNHFcfSvtbHsiP0ixL4zqG", + "OFRmVLeUz4bHyO+Yg0/OXXrG4MV8bDIS5AHztt3Dvr4wqHT5vtRBviwIm9mGrSbzovaR5c6wrLGTGkfs", + "G39QqmWZ6BC/Zo0KFabOn2EyjsypL6iYQUNLfjn5uCP9FhM0glN19UhRcx2enMr3BNdJl0fXWC3c52F2", + "o8vt6hoX2nlR9X1bjsIXRzl/KldrVR+caquL/WLc2mvtdrqd3ZZVyGrLFwwjASVqqi6QUxGbbfIw1DkQ", + "cPr+BNgfW9pQaNSslIb1knLcOkNyOkEM5T+H1Lr74xJRXaX3p9PTwUnObdJiqKGm6am6fqCN5749ouzM", + "mBzdTrebHudTySwrPbT1O1O+G0srNteZdaufXCpbspDbpucm+7ot9MTKyJFaoI6IPhGaB4bm9IKUSyUx", + "yXQK6cwQai2yn59LDi+YsC7W0C0GFDbmypNSJfxuTwQc8nUYTGUOUJ/DQ1RE/a3YeSHMWSztMQQEfSny", + "GNgYHB4B5U1smnDdCIqsDGO/jJlhxGBG4FRfcC9UiVDeFEmFY+Jy00qJoxQ91oBbbXOs8U0UzBosnwV2", + "s8hr7bU88d+bw7f9D2D/8Pi0/2N/v3d6KH8dkqN+/+C/T/f3e59/ueh96b/pXfT/2Xv3vnv29rvp8Tv+", + "+1Gv+3b/5I+3J/3R7sG/Dt/sfznrHR2eXe3/2fvnm4sPPw9Jp9MZEtna4YcDRw+mnru012q9PV/hoRbl", + "fzVJ6bH/vDMiw6uSHG7fhhzWsb/Ns0msOUODbMZJGEogzvO7FUhpeXNMq13lddQNOcn0cwJxI70gD+Tm", + "7NAWRaIr6dI4lcSRzGjJm2XxxQVS1VwkdcJVFerLtiwybFH+e4jYjCkUWEF9lAT/GBUE/8bGpHhWNHWf", + "LI/IplsNSVfCOjk4SQt/5Li2dh+7AZys3eIRh+GbGXeVlFZgPnm3hZlbTVTBNKQ97exsv3j1yhniFH21", + "Ohm1hl8U0rWTjJQdNROu0mo6pEMexZcrFSJ3YRvxO4B5xWKEIG8vJ5BcSFNpIt6b2ErVcd5WWjdt7P1a", + "QiIemAOWNqk8AnpoufDpRRf98Lzb9dDOq5H3fDt47sHvt196z5+/fPnixfPnXZVrwESeOpXnlLV5w0Gr", + "aI9sG1eMKc5XKuZqf23hYdRFW051oafslpXFgkKcElW2s8/vToRtgkREOY4SEqylInFJ7moUSBhOPXNh", + "vmfS99UBn4wD3r8/yq7fT78BFF1gxhHNIjytENppejKcCVur3hmpK2U7zljt/fujge7hNCVqjtL4UbYs", + "9z3MNkTl3bUfY0R6faMWZPn3TC/kj5TflULwS6itXScafkEbbi9pIyyUY+qbwKCqg1s3u6x3mFtBcyZy", + "4gUzTcDM0yLCVxXn9gLjSjtpKEW3PYvbZRqWaXCDwQarHRUJ0UdX4keZmjRgeHM5gt1ZWSQVstPFGYsG", + "vc0W09FTFkTmoDtbMzgNV9TwnUanTjFzCJGTCUwhyfUIU/OAjyxrrPPIm4qyV3do2CMyDrHPgZeJpkwV", + "MzjVt5zBkCIYzBSOZz2VkRK6OmWwSn1U7Qw0jitIhcoqhRgVAYJbv9TafF05NE5GIfbtIxPmFjtLbTpi", + "B5kAxw8gOkgJbeb/u9fB6XTfhee/ADl3HQO4SXsY0QC5fa3QdocBbxGvFvfRDGDOQP+gLOdvkcuzfzPr", + "B0sLuilTXTUVaynsizsGK3Z6FpFSDnHIngSzgWAKsaiWiWDF4UPi3CXTcMAMoOwmKB+hu7a3AnjrFlml", + "om5VSP9CsUl3PWITZ35xzWOTJ702Z4evmVa5zXhkgZzksqnItqmb0gYa3tQGyheWd5JdSkj+vHTlAmnK", + "3BzOSVWmk3nDnGW7ITlWBRnreptOt6L77PWbd63nfksrfwvkmzcOBRIyRNpSJBRue0jyFyFa1zm4e0+/", + "yTqfeyvE0msjGDFHHoxxR01Ox4+mVWukP7u/hPaOK6GdE/BFM9S5Qmy3UEGlWVb7ASWzK3PYK4ZrVaWx", + "S9nrTAHq7LU8mRUBwSEU+hr5qYNNXUK9bVXNSxGA6amFNrDubpPaHMr1NvfDtTWSXZWrb5Dsvv0kt7Ms", + "7cq8yYrW610TTMD/3Tt6LwyfvJlSA5DuKUVekPM5tJv0uCxSbO42esqVz8uVp7qgmCsnQXoh3kPOm99Y", + "9Tm80mWT40vkxBtG3uWQuzAHmSGU12wov8GLC/7lGifDK8heIjW+Hhnx9UuEP8T89wqke4Fsd+Mk9wLJ", + "7ccguUva89vwdBrI3Rqkth9YRlsmsu1CJKuNJZbJaS+cyn5o4vgXCD3OdNK4MMP3kvJeTImsb7r7Sa8t", + "ndG+tUhhS1cJmZPNhmEoT32KN10Ivbk6r5CU7g3670SnzRSfKoLjUnq5MkiGuIfvmKjpaXpY0yzMk3zV", + "+w2mmrY9Zy5mXoEX4UeEJdPahORbRBDN8gKaoKWEq5QgVPyzEum6MGSKh5rAB+1qqLmRU7YyB6OqzTsF", + "8BaJqBYYw2sPEbW7FurtfhKiG0GiOlGCGMmdSflI7TsID2Jz/TOgNZputZp3jsez9RXG+B2SW9S1GdNj", + "dBl9lr6ZJr0DPhIfASp/D9oAc+BDAkgEwohciKBUV4jgkb31k15BxlyHeEVbq1fhd6Oq21XVeAQ5Zr2l", + "qyZGmaMphXdn5eoc9GQrtWY+mlg3v7HC1RzzpHAbKFxdIV9M23q7liX1cCc+ZX1iylCi9qpNkRR9Qw9h", + "HOkKBAmPPO3hCRsSEdQgXfUoVZMD+nn7qum2vNt8Qd5V+LbFFu8U/rm4Z7tWSTBz8eiDUbFP7u2yybu1", + "9G23KDJRfHWlmuP0nVwS8iZpiazJx+/XphP8OAxIunQrTpG4211zY0Ij/pQmeXxee6rv7kNpX+GGRU3E", + "i/d0fEDSuOjhgasZyEP/7+/gwNXsfk4NXM3W8sjAWhwYEGvy2E4LGFle4KzA1ezeDwpIqh/CMQGthgp6", + "+Gp26ycErmbu4wFCxTU/G5ABvouqOzszkD8fsMBxgKvZrZ4FKLDpKtE4lU1X+RdXs/U5AlAS3zqqn8D/", + "y4L/r2aPEPkvRXZlyqzgUi6O/r+aLQj9v5rdFK4oWyiesPfMg4dR+SYldyGQv7Qc94vwryLhnqLGq9lD", + "w/avVn4bIfyvZo3g/VezVWD71106l7HOK3dX5gnYveL4116mLBC/Yu2kyJMr9vcXQ/ErT7MxhP+BGMRH", + "HSMU4PppWHSXWP2FVMQTSv/Baa06hXHbLv3NYfoNlJqV+Z2tAKB/NZuPzn9Q3sXDQuU/CC+gAST/5sK1", + "KjB+AxHK5+ZuvtetZGguBv+heAxP2Psn7P2NlNgTMmnlwPuV6tda32VtAfer0dS3q5FvBrG/mj3h65+U", + "aqZUHw24ftXe4f3A6h+TAnID6W9TAT2h6J9Q9OumSJ8c1dVC6O/JS109dL5BEqGIm39c7mkVUv4hWogn", + "mPwTTP5RO99zMPIr18pTP26Gjj/aHwxWDo6PqMZNu/dGsj6bo+KP9gd5VHy5nv6Remtg6+LVY+IzQu4W", + "E5/1W42JR5eIzvhEtPU4cfG3jUx/4UKmT/14sCA4XXP4PYLTLRlba2x6ThcYDZiK8e1B080KFZHpFTtR", + "5vVbQok7+WU1jtCcpu90d6dCLMoslK7O032oTWHemcw8Iqi3JXYr0w0F92gBpHfKlU2B3hb5N7paLRtz", + "ettpZ5h3PDLT74nB2X7IGmPA3VQ3g4Knq3FvSPB6Cu46LkqpeRg48FuR7XoUeDpD9SBw89qNbi8tSu5D", + "kddlzPfK3ZM5wnY/oPAHIl+C13OMHqzYsW6IAU9paAYBvxVTqRL1dyp6f7HYoHuPscHTfaSPQV/VqI5V", + "e/0UMe7BGM9JiR4jxnuD/h0mRE2PzdOhvUG/OhF6jKA8DS9H0xv0by8ZKsi42zSo6LE6AUrVyL0QyxIX", + "j/M20dWGZEYeGuU1NaO6MpkNk6m3lvBMZWit052WpBvVJn6SbH1ruU7dacNUp1nj2/FmdOur8V9Kjd1p", + "NjMVhjJPmBl/Sl82TV+K2XpEictMiFYl5jkHpnHSMpX9pinLjPAbhWFa3bhzlbaVlliVB5KtrKK7Wb7S", + "rMS9pStrCbjr6MQQ80CSlauX57pUZSq19YlK/daN8pTjiBqBfThi2swqr8CzqBej+8lDPgzJEXxsc3Gw", + "Wo+3YRLSUNAsB7la2+dOPt6yUD1Ch717lw77U07xEeieakVwq/740rUlGqsp8f1iBSXmKam0qoQ+ES8p", + "ehR+wAMpMvFwrHldiYmbi9YNa0tUiRA41ZUeMAMQ7O54oxlHgEISpOcNEfGjQKX4J+gKBsjHUxi2QUzR", + "GF+hQKUlPsEYx7996oAzhlIBeodmqr7sDETEFiutqhHAxI+mQgGZA9SqNT7BTJ7HrsjBLXROZZ6Mu6pe", + "PHSv5KkAxlMBjMekYOvqS6xUuda4LWtYVmKlelCRdy9acLGiE/PIeqo+8aTR1l6jlZTESh3Euy4vsTJF", + "tHYqR2U87kXlPNWbeKo3cbeqU0zQgzk1XKnPhI+Ynf8PlGK7exdxZTUdaoP3mKJLHCXMRPHGOYBEsFYc", + "Qt+E6GpiVhDj1xSSeDyB+eKFJh6VjXiqOPFUceKxOdxVRSZWnkBgyKeIV+9zHJtdBZhmjGEYAsYjKrhM", + "fd0Bx4gnlDD9g6UnVZY0SviQCG0EfZ7IscvXpEZXmWeG/IRiPgNxQuOIIaZ2W8ubJiea4FuUOtVF0/0G", + "PQfp/otL9rbvjr/OiFj3iOI/UQC84jVqqepaa2gtS9fYcLpe9eaMXr33cCJYl2kXQzMiIj6dxfJGMg6E", + "w6QcFv20fwCmCeMy9SXdgc6QiMc6CmXW5wkTLhGXzg4WwzLPxOSnN8KO0DiiCMSIMsw4Ij5ycbtKJKqR", + "3xKEVzV+C8eRahteURZe+y+q/ofKnEsCU346SeVQZdbVWQXlYiu4/M/6BMNe60I7qsL7iUPIxxGddr6w", + "aKfjR9Oty+1Wu/UZE7Es6YJMEYcB5HIuzDkMyOEIMuTFkLEvEZVyxmLkl9lwEDF+QdHJv96DKcQEmE9B", + "+mk7d6xjr3Vg3hjYjafQQj0FPd7aa+10d1563W2v++J0u7u3293rdv8tHLrASWO7paPM6m+v5ardYO3V", + "6iqWVtGQS0uoT9djH+QNzAJeD0wxk6IdUYC1dzPGKAzYGiv4+wKAa7WZbY/2D9YS9Q08Wzsrl7RuM4cZ", + "yb+BVbJ8rrnI7wGiUygGGpq6BMJs6dlNUeBGnoXJwkztjk8gDfQnchmGhIjwz48uEZ2BKfInkGA2VVYu", + "tTriWxygaRyJFQGeakFexgpIRDy5dojwIdE0UO31Pe8+dxkwBbm1DFjZX3OKvwvVDDZIBDSvbK61zD1f", + "0HSRiHsqFMkbLz0XEWIyWpGTb5uvFJne0quRj7ayCCczEqKv33TY01yfz52dk/r+10XWUwsrJD2hqAog", + "vgoxb9dHU0zffCuVTybUOa8z9S71a7Z3OSQut9KfCEdCO5cjpLAqQkJR0AF9FbiZl5mcBcCjIdHtS2Wi", + "+m4DCF50u3rmZKZONWOyczI8xT7QPOgS/reI10r+AhJijkpUOXc68oLh4/Lu0sG0WBLvUrbr013+t4fn", + "9BmmD2p0RxY8W4LxcELpO81hPRR1i+pdKyuztBqN2ySPX8pPZXlwXUdS/PUqr2qEhLJY7k70DyyxjGkU", + "dIJRR0h4J6cTsEqs5/SV/C3fgEOhXK8IqVezrc5y2ze2s67cXEmdMkXpP3NZjiHJ0hx+QqlwFmvSHW2A", + "CByF+lL/aAq5sBz4QnHukPBI9IOogqEGCc0Ks7MO+BgGVopNKlMRScBRiMAlhjrXYltAlzVSI/9r5lIW", + "NbfaLlSa2/Q2i6dMSnOjur33/MU9ZFLWAj4wN5OiGOnJvD8k8z4vc2IgD6vLmiSjlC6hWEiDwzn2N0B+", + "A+AlxKG0Hk2O6JxYDQxkn7e571TorPEOVGmU67u946D1JvuZ1ds8aeau1CPgE8hBgMaYIAbkLmuIp5ir", + "oBxKRQm43Lsca4SR3QarOulRXL7b8jMK3ZhSL/dyxqFITK1iKy2E2bW5R4N0b3ny9T67UBKaG0qpW4Fv", + "fRV/9BvWPykLctNKKA7JLASLjphLkXZD9P1zR5K7NAyd775zT+PDwyjYsWperCnXIfdTVDEIiXRx8Fx9", + "HY/747Tumuj0+6ql8WHtT91WcJPMCN3QA2pYQ6Pcf7NqGnfK1bfvMZWOAFyvrTSZXMyTNLljy1t2U+aE", + "mLlXmxaW7Q36bWBN4NySsic5ghaqK9s/ABtWmdP+gehLXYa4WVHWFMZYSm0t3Nz9YTqk5RqoKaja2z/t", + "/3zYarf6H9K/Hh/+/PHd4cFtlFVtKs/LBOgPJDa/rbBcT99IGiZr0PI8cePqKeWA+w6C7bUJtBubkL9y", + "fA28vHV4SGVHWZ6xV2rRtr7a/1wq9l4m7G7kMuYpu+XQ+76i7hwR5OGF4PcVfTcPvO+e17r3q+fvK+Z+", + "QKzsCMDvMfZePOy+E56+Xf/p3sLuxix8X9H2A5IjZ+h9Ux9F9KDP/0nWlu/2Ej5p7f16LlhTEeSKd99H", + "PgyBruYoe2u3Ehq29loTzuO9ra1QvDCJGN971X3V3YIx3pqmpG1dbrfKx6cPIv8zolvvkhGiRKLusxi6", + "2LxGu3hihWgUhohW9nOezlJpr/L47CCD4attRzORLBNv19yWqXc1lruaV7fmvIen3Jx6aAqvnL4/AT6i", + "HI9l1SfV+k+np4MTkMSMUwSn4BJR9Vhxhu5uP/tqcfr1PeoK5HWKpnEomslBJKyRud++WaeN+lq2C3UT", + "eF3781bJ1Xh2Ula35QBeXJ9f//8BAAD///K/dDXs2QEA", } // 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 3b986c23bc..129254b8bd 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -27,6 +27,7 @@ import ( api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/upstreamref" ) // APIValidator validates API configurations using rule-based validation @@ -37,6 +38,9 @@ type APIValidator struct { versionRegex *regexp.Regexp // urlFriendlyNameRegex matches URL-safe characters for API names urlFriendlyNameRegex *regexp.Regexp + // upstreamRefRegex enforces the schema pattern for API-level and per-op + // upstream refs; definition names are checked by upstreamDefinitionNameRegex + upstreamRefRegex *regexp.Regexp // policyValidator validates policy references and parameters policyValidator *PolicyValidator } @@ -47,6 +51,7 @@ func NewAPIValidator() *APIValidator { pathParamRegex: regexp.MustCompile(`\{[a-zA-Z0-9_]+\}`), versionRegex: regexp.MustCompile(`^v?\d+(\.\d+)?(\.\d+)?$`), urlFriendlyNameRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_\. ]+$`), + upstreamRefRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`), } } @@ -178,6 +183,36 @@ func (v *APIValidator) validateUpstreamUrl(label string, upUrl *string) []Valida return errors } +// validateUpstreamRefName enforces the shared UpstreamReference name contract +// (max 100 characters, ^[a-zA-Z0-9\-_]+$) on definition names and refs. The +// message names the field from the trailing segment of its path. +func (v *APIValidator) validateUpstreamRefName(field, value string) []ValidationError { + name := fieldName(field) + if len(value) > 100 { + return []ValidationError{{ + Field: field, + Message: fmt.Sprintf("%s must not exceed %d characters", name, 100), + }} + } + if !v.upstreamRefRegex.MatchString(value) { + return []ValidationError{{ + Field: field, + Message: name + " must match pattern " + v.upstreamRefRegex.String(), + }} + } + return nil +} + +// fieldName returns the trailing path segment of a validation field path +// (for example "spec.operations[2].upstream.main.ref" yields "ref") so error +// messages can name the field without a caller-supplied label. +func fieldName(field string) string { + if i := strings.LastIndex(field, "."); i >= 0 { + return field[i+1:] + } + return field +} + func (v *APIValidator) validateUpstreamRef(label string, ref *string, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError { var errors []ValidationError @@ -192,7 +227,10 @@ func (v *APIValidator) validateUpstreamRef(label string, ref *string, upstreamDe refName := strings.TrimSpace(*ref) - // Check if upstream definitions are provided + if errs := v.validateUpstreamRefName("spec.upstream."+label+".ref", refName); errs != nil { + return errs + } + if upstreamDefinitions == nil || len(*upstreamDefinitions) == 0 { errors = append(errors, ValidationError{ Field: "spec.upstream." + label + ".ref", @@ -201,16 +239,8 @@ 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 { + // Resolve via the shared upstreamref helper so API-level, per-op, and translator lookups match. + 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), @@ -466,7 +496,7 @@ func (v *APIValidator) validateRestData(spec *api.APIConfigData) []ValidationErr errors = append(errors, v.validateResilience("spec.resilience", spec.Resilience)...) // Validate operations - errors = append(errors, v.validateOperations(spec.Context, spec.Operations)...) + errors = append(errors, v.validateOperations(spec.Context, spec.Operations, spec.UpstreamDefinitions)...) return errors } @@ -565,7 +595,7 @@ func (v *APIValidator) ValidateContext(context string) []ValidationError { // gateway-controller/pkg/xds/translator.go) — that namespace is reserved for the // gateway's own /ready and /healthy direct-response routes, and must never be // reachable by anything an API defines. -func (v *APIValidator) validateOperations(context string, operations []api.Operation) []ValidationError { +func (v *APIValidator) validateOperations(context string, operations []api.Operation, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError { var errors []ValidationError if len(operations) == 0 { @@ -637,6 +667,9 @@ func (v *APIValidator) validateOperations(context string, operations []api.Opera // Validate operation-level resilience block errors = append(errors, v.validateResilience(fmt.Sprintf("spec.operations[%d].resilience", i), op.Resilience)...) + + // Validate per-operation upstream override (main / sandbox) + errors = append(errors, v.validateOperationUpstream(i, op.Upstream, upstreamDefinitions)...) } return errors @@ -651,6 +684,59 @@ func joinContextPath(context, opPath string) string { return strings.TrimSuffix(context, "/") + "/" + strings.TrimPrefix(opPath, "/") } +// validateOperationUpstream validates the ref-only per-operation main/sandbox +// overrides; each present ref must name an entry in upstreamDefinitions. +func (v *APIValidator) validateOperationUpstream(opIdx int, up *api.OperationUpstream, 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.validateOperationUpstreamRef(opIdx, "main", up.Main.Ref, upstreamDefinitions) + errors = append(errors, errs...) + } + if up.Sandbox != nil { + errs := v.validateOperationUpstreamRef(opIdx, "sandbox", up.Sandbox.Ref, upstreamDefinitions) + errors = append(errors, errs...) + } + return errors +} + +// validateOperationUpstreamRef validates a single operation-level upstream ref. +// The ref must resolve to a named entry in upstreamDefinitions. +func (v *APIValidator) validateOperationUpstreamRef(opIdx int, env, ref string, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError { + field := fmt.Sprintf("spec.operations[%d].upstream.%s.ref", opIdx, env) + + refName := strings.TrimSpace(ref) + if refName == "" { + return []ValidationError{{ + Field: field, + Message: "Upstream ref is required", + }} + } + + if errs := v.validateUpstreamRefName(field, refName); errs != nil { + return errs + } + + // Resolve via the shared upstreamref helper (same lookup as the translators). + 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 05b8ffd5c2..3c544fad81 100644 --- a/gateway/gateway-controller/pkg/config/validator_test.go +++ b/gateway/gateway-controller/pkg/config/validator_test.go @@ -911,6 +911,106 @@ func TestValidateUpstreamDefinitions_NonPositiveConnectTimeout(t *testing.T) { } } +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), compound + // durations, and leading signs that the published schema does not allow; these must + // be rejected as invalid format, not silently accepted. + for _, badTimeout := range []string{"5ns", "100us", "1h30m", "+5s", "-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, "Invalid timeout format") + } +} + +// TestValidateUpstreamDefinitions_NameRules covers the definition-name contract +// (max 100 chars, pattern ^[a-zA-Z0-9\-_]+$) so a valid name stays referenceable +// from a per-op upstream override. +func TestValidateUpstreamDefinitions_NameRules(t *testing.T) { + validator := NewAPIValidator() + + validUpstreams := []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://backend:8080"}, + } + + tests := []struct { + name string + defName string + wantMsg string // empty means the name is accepted + }{ + {"over-length is rejected", strings.Repeat("a", 101), "must be 1-100 characters"}, + {"space is rejected", "bad name", "letters, numbers, hyphens, underscores"}, + {"dot is rejected", "has.dot", "letters, numbers, hyphens, underscores"}, + {"colon is rejected", "has:colon", "letters, numbers, hyphens, underscores"}, + {"slash is rejected", "has/slash", "letters, numbers, hyphens, underscores"}, + {"valid name is accepted", "valid-name_123", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + definitions := &[]api.UpstreamDefinition{ + {Name: tt.defName, Upstreams: validUpstreams}, + } + errors := validator.validateUpstreamDefinitions(definitions) + if tt.wantMsg == "" { + assert.Empty(t, errors) + return + } + require.Len(t, errors, 1) + assert.Equal(t, "spec.upstreamDefinitions[0].name", errors[0].Field) + assert.Contains(t, errors[0].Message, tt.wantMsg) + }) + } +} + func TestValidateUpstreamRef_ValidRef(t *testing.T) { validator := NewAPIValidator() @@ -992,3 +1092,294 @@ 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 upstream definition. +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.OperationUpstream{ + Main: opRef("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.OperationUpstream{ + Main: opRef(""), + } + + 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 + assert.Contains(t, e.Message, "Upstream ref is required", + "empty ref should be rejected with the required-ref reason") + 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 +// upstream definition is rejected. +func TestValidateOperationUpstream_UnknownRef(t *testing.T) { + validator := NewAPIValidator() + up := &api.OperationUpstream{ + Main: opRef("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") { + found = true + assert.Contains(t, e.Message, "not found in upstreamDefinitions", + "unknown ref should be rejected with the not-found reason") + break + } + } + assert.True(t, found, "expected unknown-ref error scoped to main, 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.OperationUpstream{} + + 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 'at least one' error at wrapper level, got %+v", errors) +} + +// TestValidateOperationUpstream_SandboxUnknownRef asserts the sandbox sub-field is +// validated too (the existence check runs for sandbox), with a sandbox-scoped field path. +func TestValidateOperationUpstream_SandboxUnknownRef(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.OperationUpstream{ + Sandbox: opRef("missing-cluster"), + } + + 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.sandbox") { + found = true + assert.Contains(t, e.Message, "not found in upstreamDefinitions", + "unknown sandbox ref should be rejected with the not-found reason") + break + } + } + assert.True(t, found, "expected unknown-ref error scoped to sandbox, 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.OperationUpstream{ + Main: opRef(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") && + 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.OperationUpstream{ + Main: opRef(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") && + 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.OperationUpstream{ + Main: opRef(exactRef), + } + errors = validator.validateOperationUpstream(0, up, definitions) + assert.Empty(t, errors, "ref of exactly 100 chars must pass") +} + +// TestValidate_PerOpRef_FullFlow exercises the complete entry path +// Validate -> validateRestData -> validateOperations -> validateOperationUpstream, +// confirming a per-op ref error surfaces from the public Validate API with the +// operation-scoped field path (not just the helper in isolation). +func TestValidate_PerOpRef_FullFlow(t *testing.T) { + validator := NewAPIValidator() + config := &api.RestAPI{ + ApiVersion: api.RestAPIApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "per-op-ref-api-v1.0"}, + Spec: api.APIConfigData{ + DisplayName: "PerOpRefAPI", + Version: "v1.0", + Context: "/per-op", + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: func() *string { s := "http://example.com"; return &s }()}, + }, + Operations: []api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), + Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("missing-cluster"), + }, + }, + }, + }, + } + + errors := validator.Validate(config) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.main") && + strings.Contains(e.Message, "not found") { + found = true + break + } + } + assert.True(t, found, "expected per-op ref error via full Validate, got %+v", errors) +} + +// TestValidate_APILevelRefPatternAndLength asserts the API-level upstream ref +// shares the name-pattern and length contract enforced for per-op refs. +func TestValidate_APILevelRefPatternAndLength(t *testing.T) { + validator := NewAPIValidator() + + base := func(ref string) *api.RestAPI { + r := ref + return &api.RestAPI{ + ApiVersion: api.RestAPIApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "api-ref-v1.0"}, + Spec: api.APIConfigData{ + DisplayName: "APIRef", + Version: "v1.0", + Context: "/api-ref", + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{Main: api.Upstream{Ref: &r}}, + Operations: []api.Operation{{Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/x")}}, + }, + } + } + + hasRefErr := func(errs []ValidationError, msgSub string) bool { + for _, e := range errs { + if e.Field == "spec.upstream.main.ref" && strings.Contains(e.Message, msgSub) { + return true + } + } + return false + } + + t.Run("bad pattern is rejected with a pattern error", func(t *testing.T) { + errs := validator.Validate(base("bad/ref")) + assert.True(t, hasRefErr(errs, "must match pattern"), "API-level ref with bad characters should give a pattern error, got %+v", errs) + }) + + t.Run("over-length ref is rejected with a length error", func(t *testing.T) { + errs := validator.Validate(base(strings.Repeat("a", 101))) + assert.True(t, hasRefErr(errs, "must not exceed 100 characters"), "API-level ref over 100 chars should give a length error, got %+v", errs) + }) +} + +// opRef builds the inline per-operation upstream target holding a ref. +func opRef(ref string) *struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` +} { + return &struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` + }{Ref: ref} +} diff --git a/gateway/gateway-controller/pkg/transform/llm_test.go b/gateway/gateway-controller/pkg/transform/llm_test.go new file mode 100644 index 0000000000..81b94a5866 --- /dev/null +++ b/gateway/gateway-controller/pkg/transform/llm_test.go @@ -0,0 +1,63 @@ +/* + * 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 transform + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" +) + +// TestLLMTransformer_ClusterNameKeyedOnLLMUUID verifies that an LLM config receives the +// same identity-based cluster name as a REST API. LLMTransformer converts the config to a +// RestAPI and delegates to RestAPITransformer; this pins that the LLM config's UUID is +// carried through the extra hop, so the cluster name is clusterkey.HashedName(env, UUID) +// and not keyed on anything LLM-specific. +func TestLLMTransformer_ClusterNameKeyedOnLLMUUID(t *testing.T) { + // A RestAPI Configuration is supplied directly, so Transform uses it as-is and skips + // the provider transform; no storage backend is needed for this path. + base := makeRestAPIWithOps([]api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}, + }) + restAPI := base.Configuration.(api.RestAPI) + cfg := &models.StoredConfig{ + UUID: "test-llm-api", + Kind: "LlmProxy", + Configuration: restAPI, + } + + // Construct directly with only restTransformer set: the RestAPI-direct path does not + // use the provider transformer or storage, so this avoids an unrelated db dependency. + transformer := &LLMTransformer{ + restTransformer: NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}), + } + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute, "main route must exist") + assert.Equal(t, clusterkey.HashedName("main", cfg.UUID), mainRoute.Upstream.ClusterKey, + "LLM cluster name must be the identity name keyed on the LLM config UUID") +} diff --git a/gateway/gateway-controller/pkg/transform/restapi.go b/gateway/gateway-controller/pkg/transform/restapi.go index 953f053802..a76fd69275 100644 --- a/gateway/gateway-controller/pkg/transform/restapi.go +++ b/gateway/gateway-controller/pkg/transform/restapi.go @@ -32,6 +32,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" @@ -131,10 +133,10 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim mainUpstreamInfo := mainUpstream.UpstreamInfo() // Determine vhosts to create routes for. - // Sandbox is active when a sandbox upstream is configured via either url or ref. - hasSandbox := apiData.Upstream.Sandbox != nil && - ((apiData.Upstream.Sandbox.Url != nil && strings.TrimSpace(*apiData.Upstream.Sandbox.Url) != "") || - (apiData.Upstream.Sandbox.Ref != nil && strings.TrimSpace(*apiData.Upstream.Sandbox.Ref) != "")) + // Sandbox is active when a sandbox upstream is configured via either url or ref, + // or when any operation carries a per-op sandbox ref. + apiSandboxHasContent := upstreamref.HasContent(apiData.Upstream.Sandbox) + hasSandbox := upstreamref.SandboxActive(apiData.Upstream.Sandbox, apiData.Operations) // Check if dynamic cluster selection should be used. Enabled whenever the API has named // upstream definitions (so a policy can select one) OR a sandbox upstream (so a policy can @@ -177,6 +179,16 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim return nil, fmt.Errorf("invalid API-level resilience: %w", err) } + // Per-op sandbox routes carry no HostRewrite; inherit the API-level sandbox + // setting when present, else the main setting (matches the xDS path). + sandboxAutoHostRewrite := mainAutoHostRewrite + if apiSandboxHasContent { + sandboxAutoHostRewrite = true + if apiData.Upstream.Sandbox.HostRewrite != nil && *apiData.Upstream.Sandbox.HostRewrite == api.Manual { + sandboxAutoHostRewrite = false + } + } + // Build routes and policy chains for each operation for i, op := range apiData.Operations { // Operation-level resilience overrides API-level (per field); nil leaves the @@ -187,11 +199,6 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim } routeTimeout := buildRouteTimeout(opTimeout, apiTimeout, opIdleTimeout, apiIdleTimeout) - vhosts := append([]string{}, mainVhosts...) - if hasSandbox { - vhosts = append(vhosts, effectiveSandboxVHost) - } - // Resolve the effective matching criteria (simple top-level form or the richer match // block) once per operation. Header matchers and their discriminator are vhost- // independent; the discriminator keeps the route key unique across operations that @@ -203,28 +210,69 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim headerMatches := routeHeaderMatches(op) discriminator := xds.HeaderMatchDiscriminator(headerMatches) - for _, vhost := range vhosts { + mainSlot := routeSlot{ + clusterKey: mainUpstream.ClusterKey, + useClusterHeader: useClusterHeader, + defaultCluster: defaultCluster, + autoHostRewrite: mainAutoHostRewrite, + defaultUpstream: mainUpstreamInfo, + } + // The sandbox slot starts as the main slot; only autoHostRewrite differs + // until a per-op sandbox ref overrides it below (API-level sandbox routes + // are re-pointed by the sandbox patch after this loop). + sandboxSlot := mainSlot + sandboxSlot.autoHostRewrite = sandboxAutoHostRewrite + + if op.Upstream != nil { + if op.Upstream.Main != nil { + if err := mainSlot.applyPerOpRef("main", cfg.Kind, cfg.UUID, method, opPath, op.Upstream.Main.Ref, apiData.UpstreamDefinitions); err != nil { + return nil, err + } + } + if op.Upstream.Sandbox != nil { + if err := sandboxSlot.applyPerOpRef("sandbox", cfg.Kind, cfg.UUID, method, opPath, op.Upstream.Sandbox.Ref, apiData.UpstreamDefinitions); err != nil { + return nil, err + } + } + } + + vhosts := append([]string{}, mainVhosts...) + // Add the sandbox vhost only when this op has sandbox config (API-level + // fallback or a per-op override); otherwise it would route to the main cluster. + sbIdx := -1 + if apiSandboxHasContent || (op.Upstream != nil && op.Upstream.Sandbox != nil) { + vhosts = append(vhosts, effectiveSandboxVHost) + sbIdx = len(vhosts) - 1 + } + + for vi, vhost := range vhosts { routeKey := xds.GenerateRouteNameWithDiscriminator(method, apiData.Context, apiData.Version, opPath, vhost, discriminator) - // Build route. Default is this route's own upstream (main's, until the sandbox - // patch below overwrites it for sandbox-vhost routes) — the single field exposed - // to the policy engine as the route's compiled-in upstream, regardless of slot. - routeMainInfo := mainUpstreamInfo + // The sandbox vhost, when present, is appended last; dispatch on position + // so equal vhost strings cannot misroute. + slot := mainSlot + if vi == sbIdx { + slot = sandboxSlot + } + + // Build route. Default is this route's own upstream (the slot's) — the single + // field exposed to the policy engine as the route's compiled-in upstream. + routeInfo := slot.defaultUpstream rdcRoute := &models.Route{ Method: method, Path: xds.ConstructFullPath(apiData.Context, apiData.Version, opPath), OperationPath: opPath, Vhost: vhost, - AutoHostRewrite: mainAutoHostRewrite, + AutoHostRewrite: slot.autoHostRewrite, MatchHeaders: headerMatches, PathMatchType: pathMatchType, Order: i, Timeout: routeTimeout, Upstream: models.RouteUpstream{ - ClusterKey: mainUpstream.ClusterKey, - UseClusterHeader: useClusterHeader, - DefaultCluster: defaultCluster, - Default: &routeMainInfo, + ClusterKey: slot.clusterKey, + UseClusterHeader: slot.useClusterHeader, + DefaultCluster: slot.defaultCluster, + Default: &routeInfo, }, } rdc.Routes[routeKey] = rdcRoute @@ -242,7 +290,9 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim if len(def.Upstreams) == 0 || def.Upstreams[0].Url == "" { continue } - defClusterKey := "upstream_" + cfg.Kind + "_" + cfg.UUID + "_" + SanitizeUpstreamDefinitionName(def.Name) + defClusterKey := clusterkey.DefinitionName(cfg.Kind, cfg.UUID, def.Name) + // Base path comes solely from the explicit basePath field; upstreamDefinitions + // URLs are host[:port] only (a path in the URL is rejected during validation). basePath := "/" if def.BasePath != nil && *def.BasePath != "" { basePath = *def.BasePath @@ -291,8 +341,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 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) @@ -304,10 +355,15 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim sbAutoHostRewrite = false } - // Update sandbox vhost routes to point to sandbox cluster. The route key must be - // derived with the same header-match discriminator used when the routes were built - // above, otherwise header-matched routes would not be found and re-pointed. + // Update sandbox vhost routes to point to sandbox cluster, except ops with + // their own per-op sandbox override (already wired in the main loop). The route + // key must be derived with the same header-match discriminator used when the + // routes were built above, otherwise header-matched routes would not be found + // and re-pointed. for _, op := range apiData.Operations { + if op.Upstream != nil && op.Upstream.Sandbox != nil { + continue + } discriminator := xds.HeaderMatchDiscriminator(routeHeaderMatches(op)) routeKey := xds.GenerateRouteNameWithDiscriminator(op.EffectiveMethod(), apiData.Context, apiData.Version, op.EffectivePath(), effectiveSandboxVHost, discriminator) if r, exists := rdc.Routes[routeKey]; exists { @@ -447,9 +503,8 @@ 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 name Envoy knows the cluster by, used by the policy + // engine for the x-target-upstream header. It is always set equal to ClusterKey. EnvoyClusterName string // BasePath is the URL path component of the upstream (e.g. "/anything/foo"). BasePath string @@ -510,7 +565,9 @@ func (t *RestAPITransformer) addUpstreamCluster( connectTimeout = ct } - clusterKey := fmt.Sprintf("upstream_%s_%s_%d", upstreamName, parsedURL.Hostname(), port) + // URL-stable cluster name so a URL edit updates the same cluster instead of + // renaming it. ClusterKey and EnvoyClusterName are intentionally identical. + clusterKey := clusterkey.HashedName(upstreamName, rdc.Metadata.UUID) rdc.UpstreamClusters[clusterKey] = &models.UpstreamCluster{ BasePath: basePath, @@ -522,20 +579,53 @@ func (t *RestAPITransformer) addUpstreamCluster( ConnectTimeout: connectTimeout, } + // ClusterKey and EnvoyClusterName must stay identical or the default upstream + // path yields a 503 because Envoy cannot find the selected cluster. return &upstreamClusterResult{ ClusterKey: clusterKey, - EnvoyClusterName: sanitizeEnvoyClusterName(parsedURL.Host, parsedURL.Scheme), + EnvoyClusterName: clusterKey, BasePath: basePath, URL: fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host), }, 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 +// routeSlot carries the per-vhost route settings for one operation. +type routeSlot struct { + clusterKey string + useClusterHeader bool + defaultCluster string + autoHostRewrite bool + defaultUpstream policyenginev1.UpstreamInfo +} + +// applyPerOpRef points the slot at the referenced definition's cluster, keeping +// cluster_header on with that cluster as the default so a dynamic-endpoint policy +// can still steer the operation. autoHostRewrite keeps the API-level setting; +// per-op targets are ref-only with no HostRewrite field. +func (s *routeSlot) applyPerOpRef(env, kind, apiID, method, path, ref string, upstreamDefinitions *[]api.UpstreamDefinition) error { + def, err := upstreamref.FindByName(ref, upstreamDefinitions) + if err != nil { + return fmt.Errorf("per-op %s upstream for %s %s: %w", env, method, path, err) + } + if len(def.Upstreams) == 0 || def.Upstreams[0].Url == "" { + return fmt.Errorf("per-op %s upstream for %s %s: upstream definition '%s' has no URLs configured", env, method, path, strings.TrimSpace(ref)) + } + defClusterKey := clusterkey.DefinitionName(kind, apiID, def.Name) + basePath := "/" + if def.BasePath != nil && *def.BasePath != "" { + basePath = *def.BasePath + } + s.clusterKey = defClusterKey + s.useClusterHeader = true + s.defaultCluster = defClusterKey + // This route's own compiled-in upstream is the referenced definition — + // exposed to the policy engine as the route's default upstream. + s.defaultUpstream = policyenginev1.UpstreamInfo{ + ClusterName: defClusterKey, + URL: strings.TrimSpace(def.Upstreams[0].Url), + BasePath: basePath, + } + return nil } // lookupUpstreamDefinition returns the upstream definition named ref (after trimming @@ -584,22 +674,20 @@ 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 via the shared upstreamref helper and return the definition's + // basePath so the caller rewrites the upstream path correctly. + 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 configured", 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) } @@ -618,13 +706,6 @@ func ResolvePort(u *url.URL) int { return 80 } -// SanitizeUpstreamDefinitionName replaces dots and colons for Envoy cluster name compatibility. -func SanitizeUpstreamDefinitionName(name string) string { - name = strings.ReplaceAll(name, ".", "_") - name = strings.ReplaceAll(name, ":", "_") - return name -} - // convertAPIPolicyToSDK converts an api.Policy to policyenginev1.PolicyInstance. func convertAPIPolicyToSDK(p api.Policy, attachedTo policyv1alpha.Level, resolvedVersion string) policyenginev1.PolicyInstance { paramsMap := make(map[string]interface{}) diff --git a/gateway/gateway-controller/pkg/transform/restapi_test.go b/gateway/gateway-controller/pkg/transform/restapi_test.go index 9a8606f2e4..a8d2109899 100644 --- a/gateway/gateway-controller/pkg/transform/restapi_test.go +++ b/gateway/gateway-controller/pkg/transform/restapi_test.go @@ -29,11 +29,21 @@ 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. func ptrStr(s string) *string { return &s } +// opRef builds the inline per-operation upstream target holding a ref. +func opRef(ref string) *struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` +} { + return &struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` + }{Ref: ref} +} + // testRouterCfg returns a minimal RouterConfig for transformer tests. func testRouterCfg() *config.RouterConfig { return &config.RouterConfig{ @@ -335,29 +345,6 @@ func TestRestAPITransformer_EmptyVersionUsesResolvedVersionInChain(t *testing.T) "resolved major version should be stored in the chain, not the original empty string") } -// TestSanitizeUpstreamDefinitionName verifies that dots and colons are replaced -// for Envoy cluster name compatibility. -func TestSanitizeUpstreamDefinitionName(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"my-upstream", "my-upstream"}, - {"my.upstream", "my_upstream"}, - {"my:upstream", "my_upstream"}, - {"host.example.com:8080", "host_example_com_8080"}, - {"", ""}, - {"a.b.c:d", "a_b_c_d"}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := SanitizeUpstreamDefinitionName(tt.input) - assert.Equal(t, tt.expected, got) - }) - } -} - // TestResolveUpstreamURL verifies URL resolution from direct URL, ref, or missing config. func TestResolveUpstreamURL(t *testing.T) { refName := "my-def" @@ -437,6 +424,443 @@ 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: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("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.Equal(t, clusterkey.DefinitionName("RestApi", cfg.UUID, "user-svc-cluster"), mainRoute.Upstream.ClusterKey, + "main vhost should use the referenced definition cluster") + // 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: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Sandbox: opRef("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.Equal(t, clusterkey.DefinitionName("RestApi", cfg.UUID, "user-svc-test-cluster"), sandboxRoute.Upstream.ClusterKey, + "sandbox vhost should use the referenced definition cluster") +} + +// 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: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("user-svc-cluster"), + Sandbox: opRef("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.Equal(t, clusterkey.DefinitionName("RestApi", cfg.UUID, "user-svc-cluster"), mainRoute.Upstream.ClusterKey, + "main vhost should use its referenced definition cluster") + assert.Equal(t, clusterkey.DefinitionName("RestApi", cfg.UUID, "user-svc-test-cluster"), sandboxRoute.Upstream.ClusterKey, + "sandbox vhost should use its referenced definition cluster") + 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: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/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 upstream definition reuse exactly +// ONE definition cluster (no per-op 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: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), Upstream: &api.OperationUpstream{Main: opRef("shared-svc")}}, + {Method: api.Ptr(api.OperationMethod("POST")), Path: api.Ptr("/users"), Upstream: &api.OperationUpstream{Main: opRef("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 per-op 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. + shared := 0 + for k := range rdc.UpstreamClusters { + 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: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("shared-svc-cluster"), + }, + }, + }) + cfgA.UUID = "api-aaa" + + cfgB := makeRestAPIWithOps([]api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("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: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Sandbox: opRef("user-svc-cluster"), + }, + }, + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/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: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Sandbox: opRef("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)") +} + +// TestRestAPITransformer_PerOpRouteCarriesDefinitionDefault asserts every route's +// Upstream.Default carries its own compiled-in upstream: the referenced definition +// for a per-op route, the API-level main for a plain route, and the API-level +// sandbox for a patched sandbox route. The policy engine reads this field as the +// route's default upstream, so a nil or wrong value breaks no-policy fallbacks. +func TestRestAPITransformer_PerOpRouteCarriesDefinitionDefault(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("user-svc-cluster"), + }, + }, + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/orders")}, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + perOp := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, perOp) + require.NotNil(t, perOp.Upstream.Default, "per-op route must expose a default upstream to the policy engine") + assert.Equal(t, clusterkey.DefinitionName("RestApi", cfg.UUID, "user-svc-cluster"), perOp.Upstream.Default.ClusterName, + "per-op route default must be the referenced definition cluster") + assert.Equal(t, "http://user-svc:8080", perOp.Upstream.Default.URL) + assert.Equal(t, "/", perOp.Upstream.Default.BasePath, + "definition without basePath must default to '/'") + + plain := rdc.Routes["GET|/test/orders|main.local"] + require.NotNil(t, plain) + require.NotNil(t, plain.Upstream.Default) + assert.Equal(t, clusterkey.HashedName("main", cfg.UUID), plain.Upstream.Default.ClusterName, + "plain route default must be the API-level main cluster") + + sandbox := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, sandbox) + require.NotNil(t, sandbox.Upstream.Default) + assert.Equal(t, clusterkey.HashedName("sandbox", cfg.UUID), sandbox.Upstream.Default.ClusterName, + "patched sandbox route default must be the API-level sandbox cluster, not main's") +} + // TestResolvePort checks port resolution with explicit, default-http and default-https. func TestResolvePort(t *testing.T) { tests := []struct { @@ -469,10 +893,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" - // The default cluster must be the name Envoy knows the cluster by, which in the - // RDC path is the rdc.UpstreamClusters map key (ClusterKey), i.e. - // "upstream_sandbox__" — not the sanitized "cluster__" form. - const expectedSandboxCluster = "upstream_sandbox_sandbox-backend_9080" t.Run("without upstreamDefinitions the sandbox route still uses cluster_header defaulting to the sandbox cluster", func(t *testing.T) { transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, defs) @@ -486,8 +906,8 @@ func TestRestAPITransformer_SandboxRouteClusterHeader(t *testing.T) { r, exists := rdc.Routes[sandboxRouteKey] require.True(t, exists, "sandbox route should exist") assert.True(t, r.Upstream.UseClusterHeader) - assert.Equal(t, expectedSandboxCluster, r.Upstream.DefaultCluster, - "sandbox route must default to the sandbox cluster, not main") + assert.True(t, strings.HasPrefix(r.Upstream.DefaultCluster, "sandbox_"), + "sandbox route must default to the URL-stable sandbox cluster (sandbox_), not main; got %q", r.Upstream.DefaultCluster) }) t.Run("with upstreamDefinitions the sandbox route uses cluster_header defaulting to the sandbox cluster", func(t *testing.T) { @@ -503,8 +923,8 @@ func TestRestAPITransformer_SandboxRouteClusterHeader(t *testing.T) { r, exists := rdc.Routes[sandboxRouteKey] require.True(t, exists, "sandbox route should exist") assert.True(t, r.Upstream.UseClusterHeader) - assert.Equal(t, expectedSandboxCluster, r.Upstream.DefaultCluster, - "sandbox route must default to the sandbox cluster, not main") + assert.True(t, strings.HasPrefix(r.Upstream.DefaultCluster, "sandbox_"), + "sandbox route must default to the URL-stable sandbox cluster (sandbox_), not main; got %q", r.Upstream.DefaultCluster) }) } @@ -949,3 +1369,225 @@ func TestRestAPITransformer_ConnectTimeoutFromDefinition(t *testing.T) { } }) } + +// TestRestAPITransformer_APILevelClusterNameShape asserts the URL-stable cluster +// naming contract for API-level main and sandbox upstreams: +// - cluster names are "_<64-hex>": main and sandbox share the sha256(apiID) digest, distinguished by the env prefix +// - 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: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + // Expected name is hard-coded (full sha256("test-api")), not computed via + // clusterkey.HashedName, so a change to the hashing function is caught here. + expectedMain := "main_2a28373e2cacc6ea903d8c7e52dd3c49f8a87f95ec65ba1156de7e6564ca9524" + expectedSandbox := "sandbox_2a28373e2cacc6ea903d8c7e52dd3c49f8a87f95ec65ba1156de7e6564ca9524" + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute, "main route must exist") + assert.Equal(t, expectedMain, mainRoute.Upstream.ClusterKey, + "main cluster name should be _ derived from sha256(apiID)") + + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, sandboxRoute, "sandbox route must exist") + assert.Equal(t, expectedSandbox, sandboxRoute.Upstream.ClusterKey, + "sandbox cluster name should be _ derived from sha256(apiID)") + + _, mainExists := rdc.UpstreamClusters[expectedMain] + require.True(t, mainExists, "main cluster %q must be registered in UpstreamClusters", expectedMain) + _, sandboxExists := rdc.UpstreamClusters[expectedSandbox] + require.True(t, sandboxExists, "sandbox cluster %q must be registered in UpstreamClusters", expectedSandbox) +} + +// TestRestAPITransformer_APILevelDefaultClusterMatchesRealCluster verifies that +// route.Upstream.DefaultCluster matches a cluster registered in +// rdc.UpstreamClusters whenever UseClusterHeader is enabled. The policy engine +// writes DefaultCluster into the x-target-upstream header and Envoy looks up +// the cluster by that value; if the name does not match a registered cluster, +// Envoy returns a cluster-not-found 503. +func TestRestAPITransformer_APILevelDefaultClusterMatchesRealCluster(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/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 a cluster-not-found 503 when the policy engine writes x-target-upstream)", + mainRoute.Upstream.DefaultCluster) + assert.Equal(t, mainRoute.Upstream.ClusterKey, mainRoute.Upstream.DefaultCluster, + "DefaultCluster and ClusterKey must be the same string") +} + +// TestRestAPITransformer_APILevelURLStableAcrossURLEdit asserts that editing the +// API-level main upstream URL does NOT change the cluster name. This is the +// URL-stable contract: the route keeps pointing at the same named cluster and +// name-keyed stats stay continuous across URL edits. +func TestRestAPITransformer_APILevelURLStableAcrossURLEdit(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + cfgA := makeRestAPIWithOps([]api.Operation{{Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}}) + rdcA, err := transformer.Transform(cfgA) + require.NoError(t, err) + + cfgB := makeRestAPIWithOps([]api.Operation{{Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}}) + specB := cfgB.Configuration.(api.RestAPI) + specB.Spec.Upstream.Main.Url = ptrStr("http://api-main-v2:9090") + cfgB.Configuration = specB + rdcB, err := transformer.Transform(cfgB) + require.NoError(t, err) + + nameA := rdcA.Routes["GET|/test/users|main.local"].Upstream.ClusterKey + nameB := rdcB.Routes["GET|/test/users|main.local"].Upstream.ClusterKey + assert.Equal(t, nameA, nameB, + "API-level main cluster name must not depend on URL "+ + "(URL-stable contract: the name must survive URL edits)") +} + +// TestRestAPITransformer_APILevelMainOnlyHasNoSandboxCluster verifies that an +// API with no sandbox upstream registers no sandbox_ cluster and creates +// no sandbox route. The optional env must not leave a route pointing at a +// cluster absent from UpstreamClusters (which would surface as a cluster-not-found 503). +func TestRestAPITransformer_APILevelMainOnlyHasNoSandboxCluster(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}, + }) + spec := cfg.Configuration.(api.RestAPI) + spec.Spec.Upstream.Sandbox = nil // main-only API + cfg.Configuration = spec + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + // Expected name is hard-coded (full sha256("test-api")), not computed via + // clusterkey.HashedName, so a change to the hashing function is caught here. + expectedMain := "main_2a28373e2cacc6ea903d8c7e52dd3c49f8a87f95ec65ba1156de7e6564ca9524" + expectedSandbox := "sandbox_2a28373e2cacc6ea903d8c7e52dd3c49f8a87f95ec65ba1156de7e6564ca9524" + + _, mainExists := rdc.UpstreamClusters[expectedMain] + require.True(t, mainExists, "main cluster %q must still be registered", expectedMain) + + _, sandboxExists := rdc.UpstreamClusters[expectedSandbox] + assert.False(t, sandboxExists, + "sandbox cluster %q must not be registered when no sandbox upstream is configured", expectedSandbox) + + _, sandboxRouteExists := rdc.Routes["GET|/test/users|sandbox.local"] + assert.False(t, sandboxRouteExists, + "no sandbox route should exist for a main-only API") +} + +// TestRestAPITransformer_ClusterNameUsesSharedHelper locks the cross-builder +// naming contract: the transform path names the cluster exactly +// clusterkey.HashedName(env, cfg.UUID), the same helper and argument the xDS +// translator uses (pinned on that side in pkg/xds tests), so the two builders +// cannot drift to different names for the same API. +func TestRestAPITransformer_ClusterNameUsesSharedHelper(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + assert.Equal(t, clusterkey.HashedName("main", cfg.UUID), + rdc.Routes["GET|/test/users|main.local"].Upstream.ClusterKey) + assert.Equal(t, clusterkey.HashedName("sandbox", cfg.UUID), + rdc.Routes["GET|/test/users|sandbox.local"].Upstream.ClusterKey) +} + +// TestRestAPITransformer_APILevelPolicyPrecedesOperationLevelInChain pins that +// buildPolicyChain places API-level policies before operation-level ones, so an +// operation-level policy is the last write and wins over an API-level one in the kernel. +func TestRestAPITransformer_APILevelPolicyPrecedesOperationLevelInChain(t *testing.T) { + defs := map[string]models.PolicyDefinition{ + "api-pol|v1.0.0": {Name: "api-pol", Version: "v1.0.0"}, + "op-pol|v1.0.0": {Name: "op-pol", Version: "v1.0.0"}, + } + + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, defs) + cfg := makeRestAPIStoredConfig( + []api.Policy{{Name: "api-pol", Version: ""}}, + []api.Policy{{Name: "op-pol", Version: ""}}, + ) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + require.NotNil(t, rdc) + + routeKey := "GET|/test/hello|main.local" + chain, ok := rdc.PolicyChains[routeKey] + require.True(t, ok) + require.Len(t, chain.Policies, 2) + assert.Equal(t, "api-pol", chain.Policies[0].Name, + "API-level policy must come first in the chain") + assert.Equal(t, "op-pol", chain.Policies[1].Name, + "operation-level policy must come after the API-level policy so it wins as the last write in the kernel") +} + +// TestRestAPITransformer_PerOpMainKeptWhenVhostsEqual pins that a per-op main override +// survives when the main and sandbox vhosts are the same string and no sandbox upstream +// exists; the route dispatch must key on the vhost's role, not its name. +func TestRestAPITransformer_PerOpMainKeptWhenVhostsEqual(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("user-svc-cluster"), + }, + }, + }) + restAPI := cfg.Configuration.(api.RestAPI) + restAPI.Spec.Upstream.Sandbox = nil + same := "same.local" + restAPI.Spec.Vhosts = &struct { + Main string `json:"main" yaml:"main"` + Sandbox *string `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{Main: same, Sandbox: &same} + cfg.Configuration = restAPI + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + route := rdc.Routes["GET|/test/users|same.local"] + require.NotNil(t, route, "main route must exist") + want := clusterkey.DefinitionName("RestApi", cfg.UUID, "user-svc-cluster") + assert.Equal(t, want, route.Upstream.ClusterKey, + "per-op main override must survive equal main/sandbox vhosts") + assert.Equal(t, want, route.Upstream.DefaultCluster, + "cluster_header default must be the per-op cluster, not the API-level one") +} 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 0000000000..be1cb6d71e --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package clusterkey produces deterministic Envoy cluster names for the +// gateway-controller, shared by the RDC transformer and the xDS translator so +// they name clusters identically. +package clusterkey + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" +) + +// Hash returns the full SHA-256 hash in hex representation. +func Hash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +// HashedName joins a prefix string and the full SHA-256 hash of value with an underscore. +func HashedName(prefix, value string) string { + return prefix + "_" + Hash(value) +} + +// DefinitionName returns the full Envoy cluster name for an upstream definition, +// formatted as "upstream___". Dots and colons in the +// definition name are replaced so the result is a valid Envoy cluster name. The +// RDC transformer and the xDS translator use this so they name definition +// clusters identically. +func DefinitionName(kind, apiID, defName string) string { + return constants.UpstreamDefinitionClusterPrefix + kind + "_" + apiID + "_" + sanitizeDefName(defName) +} + +// sanitizeDefName replaces dots and colons, which are not allowed in Envoy cluster names. +func sanitizeDefName(name string) string { + name = strings.ReplaceAll(name, ".", "_") + name = strings.ReplaceAll(name, ":", "_") + return name +} 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 0000000000..47ecfcb8a4 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey_test.go @@ -0,0 +1,100 @@ +/* + * 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" +) + +// hexShape64 matches exactly 64 lowercase hex characters - the cluster-key +// fragment shape produced by Hash. +var hexShape64 = regexp.MustCompile("^[a-f0-9]{64}$") + +// TestHash validates the Hash helper: deterministic, distinct, and full SHA-256. +func TestHash(t *testing.T) { + t.Run("deterministic for identical input", func(t *testing.T) { + a := Hash("api-1") + b := Hash("api-1") + assert.Equal(t, a, b, "same input must produce same hash") + assert.Regexp(t, hexShape64, a, "hash must be exactly 64 lowercase hex characters") + }) + + t.Run("different input produces different hash", func(t *testing.T) { + a := Hash("api-1") + b := Hash("api-2") + assert.NotEqual(t, a, b) + }) + + // Known-answer vectors pin the algorithm to full SHA-256. + t.Run("known-answer vectors", func(t *testing.T) { + assert.Equal(t, "f9811b73ac5d1a8db842634fc0f871e03207ae44105fc9c2b7f1985e70f90d5a", Hash("api-1")) + assert.Equal(t, "2a28373e2cacc6ea903d8c7e52dd3c49f8a87f95ec65ba1156de7e6564ca9524", Hash("test-api")) + assert.Equal(t, "54a9b3e5ce2b6ccb97168e5948a66f48e084213b38eb8c7dc01c6f624a63c2f2", Hash("0190b3e2-7b1c-7c2a-9b3d-1a2b3c4d5e6f")) + }) + + t.Run("empty input is deterministic", func(t *testing.T) { + assert.Equal(t, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", Hash("")) + }) +} + +// TestHashedName validates the full hashed name contract. +func TestHashedName(t *testing.T) { + t.Run("joins prefix to fragment", func(t *testing.T) { + assert.Equal(t, "main_"+Hash("api-1"), HashedName("main", "api-1")) + assert.Equal(t, "sandbox_"+Hash("api-1"), HashedName("sandbox", "api-1")) + }) + + t.Run("main and sandbox share the fragment, differ by prefix", func(t *testing.T) { + main := HashedName("main", "api-1") + sandbox := HashedName("sandbox", "api-1") + assert.NotEqual(t, main, sandbox) + assert.Equal(t, "main_f9811b73ac5d1a8db842634fc0f871e03207ae44105fc9c2b7f1985e70f90d5a", main) + assert.Equal(t, "sandbox_f9811b73ac5d1a8db842634fc0f871e03207ae44105fc9c2b7f1985e70f90d5a", sandbox) + }) +} + +// TestDefinitionName validates the upstream-definition cluster-name contract: the +// "upstream_" prefix, kind and API ID scoping, and dot/colon sanitization. The RDC +// transformer and the xDS translator both go through this helper, so per-op +// definition cluster names cannot drift. +func TestDefinitionName(t *testing.T) { + t.Run("format and scoping", func(t *testing.T) { + assert.Equal(t, "upstream_RestApi_api-1_my-upstream", DefinitionName("RestApi", "api-1", "my-upstream")) + }) + + t.Run("sanitizes dots and colons", func(t *testing.T) { + tests := []struct { + defName string + expected string + }{ + {"my.upstream", "upstream_RestApi_api-1_my_upstream"}, + {"my:upstream", "upstream_RestApi_api-1_my_upstream"}, + {"host.example.com:8080", "upstream_RestApi_api-1_host_example_com_8080"}, + {"a.b.c:d", "upstream_RestApi_api-1_a_b_c_d"}, + } + for _, tt := range tests { + t.Run(tt.defName, func(t *testing.T) { + assert.Equal(t, tt.expected, DefinitionName("RestApi", "api-1", tt.defName)) + }) + } + }) +} 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 0000000000..4b740a5927 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref.go @@ -0,0 +1,97 @@ +/* + * 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 strings.TrimSpace(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 +} + +// HasContent returns true if the API-level upstream has non-empty configuration. +func HasContent(up *api.Upstream) bool { + if up == nil { + return false + } + return (up.Url != nil && strings.TrimSpace(*up.Url) != "") || + (up.Ref != nil && strings.TrimSpace(*up.Ref) != "") +} + +// SandboxActive returns true if the sandbox environment is active for the API. +// It is active if the API-level sandbox has content OR if any operation-level override has a sandbox ref. +func SandboxActive(sandbox *api.Upstream, ops []api.Operation) bool { + if HasContent(sandbox) { + return true + } + for _, op := range ops { + if op.Upstream != nil && op.Upstream.Sandbox != nil { + if strings.TrimSpace(op.Upstream.Sandbox.Ref) != "" { + return true + } + } + } + return false +} 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 0000000000..9940dda054 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref_test.go @@ -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. + */ + +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_ValidMinutesAndHours(t *testing.T) { + m := "2m" + d, err := ParseConnectTimeout(&m) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, 2*time.Minute, *d) + + h := "1h" + d, err = ParseConnectTimeout(&h) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, 1*time.Hour, *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 ptrStr(s string) *string { + return &s +} + +func TestHasContent(t *testing.T) { + assert.False(t, HasContent(nil)) + assert.False(t, HasContent(&api.Upstream{})) + assert.False(t, HasContent(&api.Upstream{Url: ptrStr(""), Ref: ptrStr("")})) + assert.False(t, HasContent(&api.Upstream{Url: ptrStr(" ")})) + assert.False(t, HasContent(&api.Upstream{Ref: ptrStr(" ")})) + + assert.True(t, HasContent(&api.Upstream{Url: ptrStr("http://foo")})) + assert.True(t, HasContent(&api.Upstream{Ref: ptrStr("foo-svc")})) +} + +func TestSandboxActive(t *testing.T) { + // API-level sandbox has content -> active + assert.True(t, SandboxActive(&api.Upstream{Url: ptrStr("http://foo")}, nil)) + + // API-level sandbox is empty, but operations have sandbox override -> active + ops := []api.Operation{ + { + Upstream: &api.OperationUpstream{ + Sandbox: &struct { + Ref api.UpstreamReference "json:\"ref\" yaml:\"ref\"" + }{Ref: "op-sandbox-svc"}, + }, + }, + } + assert.True(t, SandboxActive(nil, ops)) + + // Both empty -> inactive + assert.False(t, SandboxActive(nil, []api.Operation{{}})) +} diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index ae27e6832d..f73ad1ab74 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -37,6 +37,8 @@ import ( "github.com/wso2/api-platform/common/collector" 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" @@ -1024,7 +1026,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 } @@ -1097,42 +1099,90 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* if err != nil { return nil, nil, fmt.Errorf("invalid resilience for operation %s %s: %w", op.EffectiveMethod(), op.EffectivePath(), err) } - opTimeoutCfg := combineRouteResilience(mainTimeout, apiTimeout, apiIdleTimeout, opTimeout, opIdleTimeout) + + params := routeParams{ + clusterName: mainClusterName, + urlPath: parsedMainURL.Path, + timeout: mainTimeout, + useClusterHeader: useClusterHeader, + } + if op.Upstream != nil && op.Upstream.Main != nil { + if err := t.applyPerOpRef(¶ms, "main", cfg.Kind, cfg.UUID, op.EffectiveMethod(), op.EffectivePath(), op.Upstream.Main.Ref, apiData.UpstreamDefinitions); err != nil { + return nil, nil, err + } + } + opTimeoutCfg := combineRouteResilience(params.timeout, apiTimeout, apiIdleTimeout, opTimeout, opIdleTimeout) r := t.createRoute(cfg.UUID, apiData.DisplayName, apiData.Version, apiData.Context, op.EffectiveMethod(), op.EffectivePath(), - mainClusterName, parsedMainURL.Path, effectiveMainVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Main.HostRewrite, apiProjectID, opTimeoutCfg, useClusterHeader, upstreamDefPaths) + params.clusterName, params.urlPath, effectiveMainVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Main.HostRewrite, apiProjectID, opTimeoutCfg, params.useClusterHeader, 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 - } + apiSandboxHasContent := upstreamref.HasContent(apiData.Upstream.Sandbox) + hasSandbox := upstreamref.SandboxActive(apiData.Upstream.Sandbox, apiData.Operations) + if hasSandbox { + var sbClusterName string + var parsedSbURL *url.URL + var sbTimeout *resolvedTimeout + var sbRouteHostRewrite *api.UpstreamHostRewrite + + if apiSandboxHasContent { + sbClusterName, parsedSbURL, sbTimeout, err = t.resolveUpstreamCluster(cfg.UUID, "sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) + if err != nil { + return nil, nil, err + } - // Timeout for sandbox upstream cluster - var sbUpstreamClusterConnectTimeout *time.Duration - if sbTimeout != nil { - sbUpstreamClusterConnectTimeout = sbTimeout.Connect - } + // 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) + 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. Mirrors main's useClusterHeader (dynamic cluster selection - // is on whenever upstreamDefinitions exist or a sandbox upstream is configured). + // is on whenever upstreamDefinitions exist or a sandbox upstream is configured); + // a per-op sandbox ref reuses its definition cluster. sbRoutesList := make([]*route.Route, 0) + sbURLPath := "" + if parsedSbURL != nil { + sbURLPath = parsedSbURL.Path + } for _, op := range apiData.Operations { + // Skip ops without per-op sandbox when there's no API-level sandbox + if !apiSandboxHasContent && (op.Upstream == nil || op.Upstream.Sandbox == nil) { + continue + } + opTimeout, opIdleTimeout, err := ResolveResilience(op.Resilience) if err != nil { return nil, nil, fmt.Errorf("invalid resilience for operation %s %s: %w", op.EffectiveMethod(), op.EffectivePath(), err) } - opTimeoutCfg := combineRouteResilience(sbTimeout, apiTimeout, apiIdleTimeout, opTimeout, opIdleTimeout) + + params := routeParams{ + clusterName: sbClusterName, + urlPath: sbURLPath, + timeout: sbTimeout, + useClusterHeader: useClusterHeader, + } + if op.Upstream != nil && op.Upstream.Sandbox != nil { + if err := t.applyPerOpRef(¶ms, "sandbox", cfg.Kind, cfg.UUID, op.EffectiveMethod(), op.EffectivePath(), op.Upstream.Sandbox.Ref, apiData.UpstreamDefinitions); err != nil { + return nil, nil, err + } + } + opTimeoutCfg := combineRouteResilience(params.timeout, apiTimeout, apiIdleTimeout, opTimeout, opIdleTimeout) r := t.createRoute(cfg.UUID, apiData.DisplayName, apiData.Version, apiData.Context, op.EffectiveMethod(), op.EffectivePath(), - sbClusterName, parsedSbURL.Path, effectiveSandboxVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Sandbox.HostRewrite, apiProjectID, opTimeoutCfg, useClusterHeader, upstreamDefPaths) + params.clusterName, params.urlPath, effectiveSandboxVHost, cfg.Kind, templateHandle, providerName, sbRouteHostRewrite, apiProjectID, opTimeoutCfg, params.useClusterHeader, upstreamDefPaths) sbRoutesList = append(sbRoutesList, r) } routesList = append(routesList, sbRoutesList...) @@ -1147,13 +1197,9 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* return nil, nil, fmt.Errorf("upstream definition '%s' has no URLs configured", def.Name) } - // Sanitize definition name for use in Envoy cluster name - // Envoy cluster names must not contain dots or colons - sanitizedDefName := sanitizeUpstreamDefinitionName(def.Name) - // Use the definition name as cluster name, scoped by kind and API ID to avoid conflicts // Format: upstream___ - defClusterName := constants.UpstreamDefinitionClusterPrefix + cfg.Kind + "_" + cfg.UUID + "_" + sanitizedDefName + defClusterName := clusterkey.DefinitionName(cfg.Kind, cfg.UUID, def.Name) // Parse the first URL from the definition rawURL := def.Upstreams[0].Url @@ -1193,9 +1239,11 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* return routesList, clusters, nil } -// 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) { +// resolveUpstreamCluster validates an upstream (main or sandbox) and resolves the +// inputs its caller needs to create the cluster: clusterName, parsedURL, and +// timeout (can be nil). The cluster name is "_", +// URL-stable for the API's lifetime. +func (t *Translator) resolveUpstreamCluster(apiID, upstreamName string, up *api.Upstream, upstreamDefinitions *[]api.UpstreamDefinition) (string, *url.URL, *resolvedTimeout, error) { var rawURL string var timeout *resolvedTimeout var refBasePath *string @@ -1255,12 +1303,65 @@ func (t *Translator) resolveUpstreamCluster(upstreamName string, up *api.Upstrea parsedURL.Path = *refBasePath } - // Generate cluster name - clusterName := t.sanitizeClusterName(parsedURL.Host, parsedURL.Scheme) + // Generate cluster name from URL-stable hash (URL intentionally excluded). + clusterName := clusterkey.HashedName(upstreamName, apiID) return clusterName, parsedURL, timeout, nil } +// resolvePerOpDefinitionCluster resolves a per-op ref to the EXISTING +// upstream-definition cluster (created for every definition) and its base path, +// so a per-op route reuses that cluster instead of minting its own. +func (t *Translator) resolvePerOpDefinitionCluster(kind, apiID, ref string, upstreamDefinitions *[]api.UpstreamDefinition) (string, string, *resolvedTimeout, error) { + refName := strings.TrimSpace(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) + } + + timeout, err := resolveTimeoutFromDefinition(definition) + if err != nil { + return "", "", nil, fmt.Errorf("invalid timeout in upstream definition '%s': %w", refName, err) + } + + basePath := "/" + if definition.BasePath != nil && *definition.BasePath != "" { + basePath = *definition.BasePath + } + clusterName := clusterkey.DefinitionName(kind, apiID, definition.Name) + return clusterName, basePath, timeout, nil +} + +// routeParams carries the per-route upstream settings for one operation. +type routeParams struct { + clusterName string + urlPath string + timeout *resolvedTimeout + useClusterHeader bool +} + +// applyPerOpRef points the params at the referenced definition's cluster (urlPath +// carries its base path) and keeps cluster_header on so a dynamic-endpoint policy +// can still steer the operation; when no policy overrides it, the policy engine +// falls back to this cluster. +func (t *Translator) applyPerOpRef(p *routeParams, env, kind, apiID, method, path, ref string, upstreamDefinitions *[]api.UpstreamDefinition) error { + defClusterName, defBasePath, defTimeout, err := t.resolvePerOpDefinitionCluster(kind, apiID, ref, upstreamDefinitions) + if err != nil { + return fmt.Errorf("per-op %s upstream for %s %s: %w", env, method, path, err) + } + p.clusterName = defClusterName + p.urlPath = defBasePath + p.timeout = defTimeout + p.useClusterHeader = true + return nil +} + // SharedRouteConfigName is the name of the shared route configuration used by both HTTP and HTTPS listeners const SharedRouteConfigName = "shared_route_config" @@ -2593,22 +2694,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 { - sanitized := strings.ReplaceAll(name, ".", "_") - sanitized = strings.ReplaceAll(sanitized, ":", "_") - return sanitized -} - // createAccessLogConfig creates access log configuration based on format (JSON or text) to stdout func (t *Translator) createAccessLogConfig() ([]*accesslog.AccessLog, error) { var accessLogs []*accesslog.AccessLog @@ -3191,39 +3276,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 timeout parsing uses the shared parser. 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) } // parseDurationAllowZero parses a duration string (e.g. "15s", "0s") into a *time.Duration. diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index 46321f7cdc..a5501d1fa9 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -46,6 +46,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) { @@ -170,10 +171,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, clusterkey.HashedName("main", "test-api"), clusterName, + "cluster name should be the URL-stable hash of the apiID, independent of URL") assert.NotNil(t, parsedURL) assert.Equal(t, "http", parsedURL.Scheme) assert.Equal(t, "backend:8080", parsedURL.Host) @@ -207,10 +209,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, clusterkey.HashedName("main", "test-api"), clusterName, + "cluster name should be the URL-stable hash of the apiID, independent of URL") assert.NotNil(t, parsedURL) assert.Equal(t, "http", parsedURL.Scheme) assert.Equal(t, "backend-1:9000", parsedURL.Host) @@ -240,10 +243,11 @@ func TestResolveUpstreamCluster_WithRef_NoTimeout(t *testing.T) { }, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("main", upstream, definitions) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) require.NoError(t, err) - assert.Equal(t, "cluster_http_backend_8080", clusterName) + assert.Equal(t, clusterkey.HashedName("main", "test-api"), clusterName, + "cluster name should be the URL-stable hash of the apiID, independent of URL") assert.NotNil(t, parsedURL) assert.Nil(t, timeout, "No timeout in definition should result in nil timeout") } @@ -268,7 +272,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") @@ -299,7 +303,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") @@ -321,7 +325,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") @@ -331,7 +335,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") @@ -344,7 +348,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") @@ -1060,52 +1064,6 @@ func TestSortRoutesByPriority_LegacyExactBeatsWildcardRegex(t *testing.T) { assert.Equal(t, wildcardKey, sorted[1].GetName()) } -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 @@ -2378,7 +2336,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) @@ -2397,7 +2355,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) @@ -2415,7 +2373,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") } @@ -3088,3 +3046,454 @@ func TestTranslateRuntimeConfig_PeerHostnameOnEveryEndpoint(t *testing.T) { } assert.Equal(t, 4, checked, "expected to have checked all 4 endpoints across both clusters (1 + 3)") } + +// TestResolveUpstreamCluster_NameStableAcrossURLs asserts the URL-stable +// contract at the API level. Two distinct URLs that share the same API ID and +// env must resolve to the same cluster name, so a URL edit updates the same +// named cluster instead of removing one cluster name and adding another. +func TestResolveUpstreamCluster_NameStableAcrossURLs(t *testing.T) { + translator := &Translator{} + a := &api.Upstream{Url: strPtr("http://api-main:8080")} + b := &api.Upstream{Url: strPtr("http://api-main:9090")} + + nameA, _, _, err := translator.resolveUpstreamCluster("test-api", "main", a, nil) + require.NoError(t, err) + nameB, _, _, err := translator.resolveUpstreamCluster("test-api", "main", b, nil) + require.NoError(t, err) + + assert.Equal(t, nameA, nameB, + "API-level cluster name must not depend on URL - same API and env must produce the same cluster") +} + +// TestResolveUpstreamCluster_NameNotURLDerived locks the move off the old +// URL-sanitized scheme: the cluster name must carry no URL information (no +// "cluster_" prefix, no host), only the env-prefixed identity hash. A +// regression to URL-derived naming would reintroduce connection draining on +// URL edits. +func TestResolveUpstreamCluster_NameNotURLDerived(t *testing.T) { + translator := &Translator{} + upstream := &api.Upstream{Url: strPtr("http://api.example.com:8080/v1")} + + name, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, nil) + require.NoError(t, err) + + assert.Equal(t, clusterkey.HashedName("main", "test-api"), name) + assert.False(t, strings.HasPrefix(name, "cluster_"), + "cluster name must not use the old URL-derived scheme") + assert.NotContains(t, name, "api.example.com", + "cluster name must not contain the backend host") +} + +// TestResolveUpstreamCluster_MainSandboxNeverCollide proves env separation: +// the same apiID with env=main vs env=sandbox must produce distinct cluster +// names so both vhosts can coexist. The names share the hash fragment (same +// API, so an operator can pair them at a glance); the env prefix provides +// the distinction. +func TestResolveUpstreamCluster_MainSandboxNeverCollide(t *testing.T) { + translator := &Translator{} + up := &api.Upstream{Url: strPtr("http://api-main:8080")} + + mainName, _, _, err := translator.resolveUpstreamCluster("test-api", "main", up, nil) + require.NoError(t, err) + sandboxName, _, _, err := translator.resolveUpstreamCluster("test-api", "sandbox", up, nil) + require.NoError(t, err) + + assert.NotEqual(t, mainName, sandboxName, + "main and sandbox cluster names must differ (the env prefix distinguishes them)") + assert.Equal(t, strings.TrimPrefix(mainName, "main_"), strings.TrimPrefix(sandboxName, "sandbox_"), + "main and sandbox must share the hash fragment so an API's cluster pair is correlatable") +} + +// A per-operation ref reuses the referenced upstream definition'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" + 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", "my-svc", 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") +} + +// TestTranslateConfigs_PerOpMainReusesDefinitionCluster asserts that a per-op main +// override reuses the referenced upstream-definition cluster on the legacy xDS path. +func TestTranslateConfigs_PerOpMainReusesDefinitionCluster(t *testing.T) { + translator := createTestTranslator() + + 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", + }, + 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: "premium-svc", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://premium-svc:8080"}}}, + }, + Operations: []api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/premium"), + Upstream: &api.OperationUpstream{ + Main: opRef("premium-svc"), + }, + }, + }, + } + cfg := &models.StoredConfig{ + UUID: "main-op-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "main-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] + require.NotEmpty(t, clusters, "expected at least one cluster") + + var defClusterName string + for _, c := range clusters { + name := c.(*cluster.Cluster).GetName() + if strings.HasPrefix(name, "upstream_") && strings.Contains(name, "premium-svc") { + defClusterName = name + } + } + require.NotEmpty(t, defClusterName, + "expected the referenced upstream-definition cluster (upstream_..._premium-svc) to be emitted for the per-op main route") +} + +// TestTranslateConfigs_PerOpSandboxClusterEmitted asserts that the legacy xDS path +// emits the referenced upstream-definition cluster for a per-op sandbox upstream +// override, so the sandbox route can reuse it. +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: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Sandbox: opRef("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 REUSES the referenced definition's cluster + // (upstream___user-svc-sb-cluster). + var defClusterName string + for _, c := range clusters { + name := c.(*cluster.Cluster).GetName() + if strings.HasPrefix(name, "upstream_") && strings.Contains(name, "user-svc-sb-cluster") { + defClusterName = name + } + } + require.NotEmpty(t, defClusterName, + "expected the referenced upstream-definition cluster (upstream_..._user-svc-sb-cluster) to be emitted for reuse") + require.NotEmpty(t, routeConfigs, "expected sandbox route configuration to exist") +} + +// TestTranslateConfigs_PerOpRoutesUseClusterHeaderAndDefinitionBasePath asserts that a +// per-op main or sandbox ref produces an Envoy route wired for cluster_header dynamic +// routing (so a dynamic-endpoint policy can still steer it), strips the target-upstream +// header before forwarding, and rewrites the path with the referenced definition's base +// path. This covers the translateAPIConfig -> createRoute path, beyond just asserting the +// definition cluster is emitted. +func TestTranslateConfigs_PerOpRoutesUseClusterHeaderAndDefinitionBasePath(t *testing.T) { + assertPerOpRoute := func(t *testing.T, r *route.Route, defBasePath string) { + t.Helper() + require.NotNil(t, r, "expected the per-op route to be generated") + ra := r.GetRoute() + require.NotNil(t, ra, "per-op route must have a route action") + ch, ok := ra.ClusterSpecifier.(*route.RouteAction_ClusterHeader) + require.True(t, ok, "per-op route must use cluster_header dynamic routing, not a static cluster") + assert.Equal(t, constants.TargetUpstreamHeader, ch.ClusterHeader, + "per-op route must route via the target-upstream cluster header") + assert.Contains(t, r.RequestHeadersToRemove, constants.TargetUpstreamHeader, + "per-op route must strip the target-upstream header before forwarding upstream") + assert.Contains(t, ra.GetRegexRewrite().GetSubstitution(), defBasePath, + "per-op route must rewrite the path with the referenced definition base path") + } + + t.Run("per-op main ref", func(t *testing.T) { + translator := createTestTranslator() + 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"}, + 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: "premium-svc", BasePath: strPtr("/premium-svc"), Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://premium-svc:8080"}}}, + }, + Operations: []api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/premium"), + Upstream: &api.OperationUpstream{ + Main: opRef("premium-svc"), + }}, + }, + } + cfg := &models.StoredConfig{ + UUID: "main-route-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "main-route-api"}, + Spec: apiData, + }, + } + + resources, err := translator.TranslateConfigs([]*models.StoredConfig{cfg}, "test-correlation") + require.NoError(t, err) + + var premiumRoute *route.Route + for _, rc := range resources[resource.RouteType] { + for _, vh := range rc.(*route.RouteConfiguration).GetVirtualHosts() { + for _, rt := range vh.GetRoutes() { + if strings.Contains(rt.GetMatch().GetSafeRegex().GetRegex(), "premium") { + premiumRoute = rt + } + } + } + } + assertPerOpRoute(t, premiumRoute, "/premium-svc") + }) + + t.Run("per-op sandbox ref without API-level sandbox", func(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: "sb-svc", BasePath: strPtr("/sb-svc"), Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://sb-svc:8080"}}}, + }, + Operations: []api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Sandbox: opRef("sb-svc"), + }}, + }, + } + cfg := &models.StoredConfig{ + UUID: "sandbox-route-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "sandbox-route-api"}, + Spec: apiData, + }, + } + + resources, err := translator.TranslateConfigs([]*models.StoredConfig{cfg}, "test-correlation") + require.NoError(t, err) + + var sandboxRoute *route.Route + for _, rc := range resources[resource.RouteType] { + for _, vh := range rc.(*route.RouteConfiguration).GetVirtualHosts() { + matchesSandbox := false + for _, d := range vh.GetDomains() { + if strings.Contains(d, "sandbox.local") { + matchesSandbox = true + break + } + } + if !matchesSandbox { + continue + } + for _, rt := range vh.GetRoutes() { + if strings.Contains(rt.GetMatch().GetSafeRegex().GetRegex(), "users") { + sandboxRoute = rt + } + } + } + } + assertPerOpRoute(t, sandboxRoute, "/sb-svc") + }) +} + +// opRef builds the inline per-operation upstream target holding a ref. +func opRef(ref string) *struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` +} { + return &struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` + }{Ref: ref} +} + +// TestTranslateConfigs_PerOpSandboxSkipsOpsWithoutOverride asserts that when sandbox +// is active only through per-op refs (no API-level sandbox upstream), operations +// WITHOUT their own sandbox override get no sandbox-vhost route: routing them there +// would point at a cluster that does not exist. +func TestTranslateConfigs_PerOpSandboxSkipsOpsWithoutOverride(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: "sb-svc", BasePath: strPtr("/sb-svc"), Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://sb-svc:8080"}}}, + }, + Operations: []api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Sandbox: opRef("sb-svc"), + }}, + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/orders")}, + }, + } + cfg := &models.StoredConfig{ + UUID: "sandbox-skip-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "sandbox-skip-api"}, + Spec: apiData, + }, + } + + resources, err := translator.TranslateConfigs([]*models.StoredConfig{cfg}, "test-correlation") + require.NoError(t, err) + + var sandboxUsers, sandboxOrders, mainOrders bool + for _, rc := range resources[resource.RouteType] { + for _, vh := range rc.(*route.RouteConfiguration).GetVirtualHosts() { + isSandbox := false + for _, d := range vh.GetDomains() { + if strings.Contains(d, "sandbox.local") { + isSandbox = true + break + } + } + for _, rt := range vh.GetRoutes() { + regex := rt.GetMatch().GetSafeRegex().GetRegex() + switch { + case isSandbox && strings.Contains(regex, "users"): + sandboxUsers = true + case isSandbox && strings.Contains(regex, "orders"): + sandboxOrders = true + case !isSandbox && strings.Contains(regex, "orders"): + mainOrders = true + } + } + } + } + assert.True(t, sandboxUsers, "op with a per-op sandbox ref must get a sandbox-vhost route") + assert.False(t, sandboxOrders, "op without a sandbox override must NOT get a sandbox-vhost route") + assert.True(t, mainOrders, "op without overrides must keep its main-vhost route") +} diff --git a/gateway/gateway-controller/tests/integration/storage_test.go b/gateway/gateway-controller/tests/integration/storage_test.go index 7abd809f29..33452afb68 100644 --- a/gateway/gateway-controller/tests/integration/storage_test.go +++ b/gateway/gateway-controller/tests/integration/storage_test.go @@ -34,6 +34,15 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" ) +// opRef builds the inline per-operation upstream target holding a ref. +func opRef(ref string) *struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` +} { + return &struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` + }{Ref: ref} +} + // setupTestDB creates a temporary SQLite database for testing func setupTestDB(t *testing.T) (storage.Storage, string, func()) { t.Helper() @@ -762,3 +771,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.RestAPIApiVersionGatewayApiPlatformWso2Comv1, + 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.Ptr(api.OperationMethodGET), + Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("users-svc"), + Sandbox: opRef("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 the round trip") + 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 the round trip") + 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 0df3ef16b1..7d29ce892f 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go @@ -411,6 +411,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 +// upstream definition 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{}, "", testMaxDecompressedBytes, testMaxDecompressedBytes) + + 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 // ============================================================================= @@ -1050,3 +1103,43 @@ func TestTranslateRequestHeaderActionsWithBodyMerge_DynamicEndpoint(t *testing.T assert.NotContains(t, extProc.Fields, "request_transformation.target_path") }) } + +// TestTranslateRequestHeaderActions_DynamicEndpointSanitizesClusterName pins the exact +// x-target-upstream name for a definition name containing dots or colons, locking it byte-for-byte to +// the controller's clusterkey.DefinitionName so the two modules' cluster names cannot drift. +func TestTranslateRequestHeaderActions_DynamicEndpointSanitizesClusterName(t *testing.T) { + kernel := NewKernel() + chainExecutor := executor.NewChainExecutor(nil, nil, nil) + server := NewExternalProcessorServer(kernel, chainExecutor, config.TracingConfig{}, "", testMaxDecompressedBytes, testMaxDecompressedBytes) + execCtx := newPolicyExecutionContext(server, "test-route", ®istry.PolicyChain{}) + execCtx.sharedCtx = &policy.SharedContext{APIKind: "RestApi", APIId: "api-123"} + execCtx.requestBodyCtx = &policy.RequestContext{ + Path: "/api/whoami", + SharedContext: execCtx.sharedCtx, + } + execCtx.apiContext = "/api" + execCtx.upstreamBasePath = "/sandbox" + execCtx.upstreamDefinitionPaths = map[string]string{"host.example.com:8080": "/alternate"} + + targetUpstream := "host.example.com:8080" + result := &executor.RequestHeaderExecutionResult{ + Results: []executor.RequestHeaderPolicyResult{ + {Action: policy.UpstreamRequestHeaderModifications{UpstreamName: &targetUpstream}}, + }, + } + + resp, err := TranslateRequestHeaderActions(result, ®istry.PolicyChain{}, execCtx) + require.NoError(t, err) + require.NotNil(t, resp) + + hm := resp.GetRequestHeaders().GetResponse().GetHeaderMutation() + require.NotNil(t, hm) + var headerValue string + for _, h := range hm.SetHeaders { + if h.Header.Key == constants.TargetUpstreamHeader { + headerValue = string(h.Header.RawValue) + } + } + assert.Equal(t, "upstream_RestApi_api-123_host_example_com_8080", headerValue, + "dots and colons in the definition name must be replaced with underscores to match the controller's cluster name") +} diff --git a/gateway/it/features/per-op-upstream.feature b/gateway/it/features/per-op-upstream.feature new file mode 100644 index 0000000000..6cf995d3bb --- /dev/null +++ b/gateway/it/features/per-op-upstream.feature @@ -0,0 +1,1560 @@ +# -------------------------------------------------------------------- +# 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 +Feature: Per-Operation Upstream + As an API developer + I want per-operation upstream refs to override the API-level upstream, with API-level + URL edits staying cluster-stable + So that different operations can route to different backends without disruptive redeploys + + Background: + Given the gateway services are running + + # ===== from per-op-upstream-basic.feature ===== + 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/v1 + 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/v1 + 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/v1 + 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 + - method: GET + path: /orders + """ + 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" + + 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/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-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/v1 + 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/v1 + 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/v1 + 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/v1 + 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 the new path + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + 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/v1 + 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/v1 + 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 + + # ===== from per-op-upstream-ref.feature ===== + Scenario: Per-operation main refs route to different backend services on different ports + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + 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://echo-backend:80 + basePath: /anything + 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" + And I wait for the endpoint "http://localhost:8080/per-op/v1.0/orders" 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 "url" should be "http://echo-backend/anything/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: 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/v1 + 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: 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/v1 + 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/v1 + 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 + + Scenario: Request-rewrite policy composes with a per-op ref + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-ref-rewrite-api-v1.0 + spec: + displayName: Per-Op-Ref-Rewrite-API + version: v1.0 + context: /per-op-ref-rewrite/$version + vhosts: + main: per-op-ref-rewrite-main.local + upstreamDefinitions: + - name: ref-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /ref-svc + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /whoami + upstream: + main: + ref: ref-svc + policies: + - name: request-rewrite + version: v1 + params: + pathRewrite: + type: ReplaceFullPath + replaceFullPath: /rewritten + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-ref-rewrite/v1.0/whoami" to be ready with host "per-op-ref-rewrite-main.local" + + When I clear all headers + And I set request host to "per-op-ref-rewrite-main.local" + And I send a GET request to "http://localhost:8080/per-op-ref-rewrite/v1.0/whoami" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/ref-svc/rewritten" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-ref-rewrite-api-v1.0" + Then the response should be successful + + # ===== from per-op-upstream-validation.feature ===== + 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/v1 + 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 status code should be 400 + 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/v1 + 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 status code should be 400 + 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/v1 + 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 status code should be 400 + 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/v1 + 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 status code should be 400 + 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/v1 + 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 status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "must match pattern" + + # Zero does not disable a connect timeout: the transformer requires a positive value, so + # the validator rejects it at deploy time rather than accepting a definition that cannot + # be translated afterwards. + Scenario: Zero connect timeout in an upstreamDefinition is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-zero-timeout-api-v1.0 + spec: + displayName: Per-Op-Val-Zero-Timeout-API + version: v1.0 + context: /per-op-val-zero-timeout/$version + vhosts: + main: per-op-val-zero-timeout-main.local + upstreamDefinitions: + - name: slow-svc + timeout: + connect: 0s + 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 status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "must be positive" + + # ===== from api-level-url-stable.feature ===== + Scenario: API-level main upstream URL update (host and path change) routes to new backend (URL-stable cluster naming) + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-main-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Main-API + version: v1.0 + context: /api-level-url-stable-main/$version + vhosts: + main: api-level-url-stable-main.local + upstream: + main: + url: http://sample-backend:9080/version-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" to be ready with host "api-level-url-stable-main.local" + + When I clear all headers + And I set request host to "api-level-url-stable-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-a/endpoint" + + # Envoy admin: the API-level cluster must use the identity-derived name + # (main_) and there must be no URL-derived (cluster__) + # cluster. The URL-derived form is what the pre-change naming produced, so + # this assertion fails on the old naming scheme. The exact name set is + # captured so the post-update step can prove the NAME survived the update. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And I capture the Envoy cluster names prefixed "main_" + + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-main-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-main-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Main-API + version: v1.0 + context: /api-level-url-stable-main/$version + vhosts: + main: api-level-url-stable-main.local + upstream: + main: + # The host changes too (container alias of the same backend), proving + # the cluster survives a HOST edit, not only a path edit. The old + # URL-derived naming kept its name across path edits but renamed the + # cluster on any host or scheme change. + url: http://it-sample-backend:9080/version-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" to be ready with host "api-level-url-stable-main.local" + + When I clear all headers + And I set request host to "api-level-url-stable-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-b/endpoint" + + # After the HOST change the exact cluster-name set must be UNCHANGED: + # this proves the same main_ cluster survived the host edit (a + # rename to a different main_ would fail the unchanged step). The + # old naming would have minted a new cluster_http_it-sample-backend_9080 + # cluster here and dropped the previous one. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "main_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-main-api-v1.0" + Then the response should be successful + + Scenario: API-level sandbox upstream URL update (host and path change) routes to new backend + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-sandbox-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Sandbox-API + version: v1.0 + context: /api-level-url-stable-sandbox/$version + vhosts: + main: api-level-url-stable-sandbox-main.local + sandbox: api-level-url-stable-sandbox-sb.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/sandbox-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" to be ready with host "api-level-url-stable-sandbox-sb.local" + + When I clear all headers + And I set request host to "api-level-url-stable-sandbox-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/sandbox-a/endpoint" + + # Capture the sandbox cluster-name set so the post-update step can prove + # the sandbox_ name survived the URL update. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And I capture the Envoy cluster names prefixed "sandbox_" + + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-sandbox-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-sandbox-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Sandbox-API + version: v1.0 + context: /api-level-url-stable-sandbox/$version + vhosts: + main: api-level-url-stable-sandbox-main.local + sandbox: api-level-url-stable-sandbox-sb.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + # The sandbox host changes too (container alias of the same + # backend), so this update exercises a host edit on the sandbox + # cluster, not only a path edit. + url: http://it-sample-backend:9080/sandbox-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" to be ready with host "api-level-url-stable-sandbox-sb.local" + + When I clear all headers + And I set request host to "api-level-url-stable-sandbox-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/sandbox-b/endpoint" + + # Envoy admin: the sandbox cluster must use the identity-derived name + # (sandbox_); no URL-derived cluster may exist, and the exact name + # set must be unchanged across the host edit (identity proof). Fails on + # the old URL-derived naming scheme. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "sandbox_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-sandbox-api-v1.0" + Then the response should be successful + + Scenario: API-level upstream ref resolves to the referenced upstreamDefinitions entry + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-default-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Default-API + version: v1.0 + context: /api-level-url-stable-default/$version + vhosts: + main: api-level-url-stable-default.local + upstreamDefinitions: + - name: backend-default + basePath: /api-main + upstreams: + - url: http://sample-backend:9080 + upstream: + main: + ref: backend-default + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-default/v1.0/endpoint" to be ready with host "api-level-url-stable-default.local" + + When I clear all headers + And I set request host to "api-level-url-stable-default.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-default/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/endpoint" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-default-api-v1.0" + Then the response should be successful + + Scenario: API-level main and sandbox on the same backend host get separate identity-named clusters + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-collision-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Collision-API + version: v1.0 + context: /api-level-url-stable-collision/$version + vhosts: + main: api-level-url-stable-collision-main.local + sandbox: api-level-url-stable-collision-sb.local + upstream: + main: + url: http://sample-backend:9080/collision-main + sandbox: + url: http://sample-backend:9080/collision-sandbox + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" to be ready with host "api-level-url-stable-collision-main.local" + + # Main and sandbox share the same backend host:port but must route to their + # own base paths. The old URL-derived naming keyed the cluster on host and + # scheme only, so main and sandbox collapsed into one shared cluster here; + # identity naming gives each its own. + When I clear all headers + And I set request host to "api-level-url-stable-collision-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/collision-main/endpoint" + + When I clear all headers + And I set request host to "api-level-url-stable-collision-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/collision-sandbox/endpoint" + + # Envoy admin: an identity-named main_ and a sandbox_ cluster + # must both exist (they do not collide), and no URL-derived cluster may + # exist. Under the old naming both upstreams shared one cluster__ + # cluster, so this assertion fails on the previous scheme. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-collision-api-v1.0" + Then the response should be successful + + Scenario: Two APIs sharing the same backend host route independently through their own identity-named clusters + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-shared-a-v1.0 + spec: + displayName: API-Level-URL-Stable-Shared-A + version: v1.0 + context: /api-level-url-stable-shared-a/$version + vhosts: + main: api-level-url-stable-shared-a.local + upstream: + main: + url: http://sample-backend:9080/shared-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" to be ready with host "api-level-url-stable-shared-a.local" + + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-shared-b-v1.0 + spec: + displayName: API-Level-URL-Stable-Shared-B + version: v1.0 + context: /api-level-url-stable-shared-b/$version + vhosts: + main: api-level-url-stable-shared-b.local + upstream: + main: + url: http://sample-backend:9080/shared-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-shared-b/v1.0/endpoint" to be ready with host "api-level-url-stable-shared-b.local" + + # Two distinct APIs point at the same backend host:port. The old URL-derived + # naming made them share one cluster__ cluster; identity naming + # keys each cluster on its API ID, so the two APIs route independently to their + # own base paths under identity-named clusters and no URL-derived cluster exists. + When I clear all headers + And I set request host to "api-level-url-stable-shared-a.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-a/endpoint" + + When I clear all headers + And I set request host to "api-level-url-stable-shared-b.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-b/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-b/endpoint" + + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + + # Delete API-B and confirm API-A still routes, proving the two APIs own + # independent clusters (deleting one does not disturb the other). + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-shared-b-v1.0" + Then the response should be successful + + When I clear all headers + And I set request host to "api-level-url-stable-shared-a.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-a/endpoint" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-shared-a-v1.0" + Then the response should be successful + + Scenario: API-level main upstream scheme and port change keeps the same identity-named cluster + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-scheme-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Scheme-API + version: v1.0 + context: /api-level-url-stable-scheme/$version + vhosts: + main: api-level-url-stable-scheme.local + upstream: + main: + url: http://sample-backend:9080/version-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-scheme/v1.0/endpoint" to be ready with host "api-level-url-stable-scheme.local" + + # Capture the identity-derived cluster name while the upstream is plain http. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And I capture the Envoy cluster names prefixed "main_" + + # Change the upstream scheme (http -> https) AND port (9080 -> 9443) in one + # edit. The old URL-derived naming embedded scheme and port in the cluster + # name (cluster___), so this edit would have minted a new + # cluster_https_ cluster and dropped the previous one. Identity-based naming + # must keep the SAME main_ and never produce a cluster_https_. TLS + # routing itself is not asserted (there is no TLS echo backend), so there is no + # endpoint-readiness wait here; the cluster-set check below observes a settle + # window instead to cover xDS propagation. The cluster + # name is stable independent of upstream reachability. + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-scheme-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-scheme-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Scheme-API + version: v1.0 + context: /api-level-url-stable-scheme/$version + vhosts: + main: api-level-url-stable-scheme.local + upstream: + main: + url: https://sample-backend:9443/version-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + + # The main_ name set must be UNCHANGED after the scheme/port edit, and + # no URL-derived cluster_https_ may appear. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "main_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-scheme-api-v1.0" + Then the response should be successful + + # ===== per-operation upstream on match-form operations (Gateway-API-style method + path.value + headers) ===== + Scenario: Per-operation main ref on a match-form operation routes to the ref'd backend + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-match-basic-api-v1.0 + spec: + displayName: Per-Op-Match-Basic-API + version: v1.0 + context: /per-op-match-basic/$version + vhosts: + main: per-op-match-basic-main.local + upstreamDefinitions: + - name: match-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /match-main + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - match: + method: GET + path: + value: /users + upstream: + main: + ref: match-main-svc + - match: + method: GET + path: + value: /orders + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-match-basic/v1.0/users" to be ready with host "per-op-match-basic-main.local" + + When I clear all headers + And I set request host to "per-op-match-basic-main.local" + And I send a GET request to "http://localhost:8080/per-op-match-basic/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/match-main/users" + + When I clear all headers + And I set request host to "per-op-match-basic-main.local" + And I send a GET request to "http://localhost:8080/per-op-match-basic/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-match-basic-api-v1.0" + Then the response should be successful + + Scenario: Header matcher on a match-form operation selects the per-operation ref + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-match-hdr-api-v1.0 + spec: + displayName: Per-Op-Match-Hdr-API + version: v1.0 + context: /per-op-match-hdr/$version + vhosts: + main: per-op-match-hdr-main.local + upstreamDefinitions: + - name: match-canary-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /canary + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - match: + method: GET + path: + value: /items + headers: + - name: x-variant + value: canary + upstream: + main: + ref: match-canary-svc + - match: + method: GET + path: + value: /items + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-match-hdr/v1.0/items" to be ready with host "per-op-match-hdr-main.local" + + When I clear all headers + And I set request host to "per-op-match-hdr-main.local" + And I set header "x-variant" to "canary" + And I send a GET request to "http://localhost:8080/per-op-match-hdr/v1.0/items" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/canary/items" + + When I clear all headers + And I set request host to "per-op-match-hdr-main.local" + And I send a GET request to "http://localhost:8080/per-op-match-hdr/v1.0/items" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/items" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-match-hdr-api-v1.0" + Then the response should be successful + + Scenario: Per-operation sandbox ref on a match-form operation routes sandbox traffic + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-match-sb-api-v1.0 + spec: + displayName: Per-Op-Match-SB-API + version: v1.0 + context: /per-op-match-sb/$version + vhosts: + main: per-op-match-sb-main.local + sandbox: per-op-match-sb-sandbox.local + upstreamDefinitions: + - name: match-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /match-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - match: + method: GET + path: + value: /users + upstream: + sandbox: + ref: match-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-match-sb/v1.0/users" to be ready with host "per-op-match-sb-sandbox.local" + + When I clear all headers + And I set request host to "per-op-match-sb-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-match-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 "/match-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-match-sb-api-v1.0" + Then the response should be successful diff --git a/gateway/it/steps_envoy_admin.go b/gateway/it/steps_envoy_admin.go new file mode 100644 index 0000000000..4006a3b743 --- /dev/null +++ b/gateway/it/steps_envoy_admin.go @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package it + +import ( + "context" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "github.com/cucumber/godog" +) + +// envoyAdminURL is the Envoy admin endpoint exposed by the IT compose stack. +const envoyAdminURL = "http://localhost:9901" + +// envoyAdminClient bounds admin queries so a stalled admin endpoint fails the +// step instead of hanging until the suite timeout. +var envoyAdminClient = &http.Client{Timeout: 10 * time.Second} + +// rememberedClusterSets holds cluster-name sets captured during a scenario, +// keyed by name prefix. Cleared before each scenario. Safe under godog's +// default sequential execution; it would need per-scenario state if scenario +// parallelism is ever enabled. +var rememberedClusterSets = map[string][]string{} + +// fetchEnvoyClusterNames returns the sorted, de-duplicated set of cluster +// names with the given prefix, parsed from the Envoy admin /clusters output +// (each line has the form "::::"). +func fetchEnvoyClusterNames(prefix string) ([]string, error) { + resp, err := envoyAdminClient.Get(envoyAdminURL + "/clusters") + if err != nil { + return nil, fmt.Errorf("failed to query Envoy admin /clusters: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Envoy admin /clusters returned status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read Envoy admin /clusters response: %w", err) + } + seen := map[string]bool{} + for _, line := range strings.Split(string(body), "\n") { + name, _, ok := strings.Cut(line, "::") + if !ok { + continue + } + if strings.HasPrefix(name, prefix) { + seen[name] = true + } + } + names := make([]string, 0, len(seen)) + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + return names, nil +} + +// RegisterEnvoyAdminSteps registers steps that assert Envoy cluster identity +// via the admin endpoint. Capturing the exact cluster-name set before an API +// update and asserting it is unchanged afterwards proves the cluster NAME +// survived the update; substring checks on /clusters alone cannot prove that +// (an implementation renaming one hashed cluster to another would still pass +// a "contains prefix" check). +func RegisterEnvoyAdminSteps(ctx *godog.ScenarioContext) { + ctx.Before(func(c context.Context, sc *godog.Scenario) (context.Context, error) { + rememberedClusterSets = map[string][]string{} + return c, nil + }) + + ctx.Step(`^I capture the Envoy cluster names prefixed "([^"]*)"$`, func(prefix string) error { + names, err := fetchEnvoyClusterNames(prefix) + if err != nil { + return err + } + if len(names) == 0 { + return fmt.Errorf("no Envoy clusters with prefix %q found to capture", prefix) + } + rememberedClusterSets[prefix] = names + return nil + }) + + // The set is observed over a settle window rather than once: an update + // propagates to Envoy asynchronously, so a single immediate read could pass + // against the pre-update state and miss a cluster rename that lands moments + // later. Any change inside the window fails immediately. + ctx.Step(`^the Envoy cluster names prefixed "([^"]*)" should be unchanged$`, func(prefix string) error { + captured, ok := rememberedClusterSets[prefix] + if !ok { + return fmt.Errorf("no captured cluster set for prefix %q; use the capture step first", prefix) + } + deadline := time.Now().Add(6 * time.Second) + for { + current, err := fetchEnvoyClusterNames(prefix) + if err != nil { + return err + } + if strings.Join(captured, ",") != strings.Join(current, ",") { + return fmt.Errorf("Envoy cluster set with prefix %q changed across the update: before=%v after=%v (cluster identity must be stable)", prefix, captured, current) + } + if time.Now().After(deadline) { + return nil + } + time.Sleep(500 * time.Millisecond) + } + }) +} diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index beff2452dc..7d35e4f361 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -151,6 +151,7 @@ func getFeaturePaths() []string { "features/upstream-connect-timeout.feature", "features/backend-timeout.feature", "features/llm-backend-timeout.feature", + "features/per-op-upstream.feature", // Runs late: it restarts the gateway-controller (reject/reconnect scenario), so keep it // after features that assume an uninterrupted controller. Verifies the DP->CP artifact push. "features/dp-to-cp.feature", @@ -356,6 +357,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) { RegisterSubscriptionSteps(ctx, testState, httpSteps) RegisterSecretSteps(ctx, testState, httpSteps) RegisterTemplateSteps(ctx, testState, httpSteps) + RegisterEnvoyAdminSteps(ctx) RegisterDPToCPSteps(ctx, testState) } diff --git a/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md b/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md index 86d3d23e94..03c5e845ae 100644 --- a/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md +++ b/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md @@ -661,8 +661,8 @@ For each API Configuration, create a RouteConfiguration: - **Routes**: One route per operation mapping `{method, path}` to cluster #### Cluster Creation -For each unique upstream URL, create a Cluster: -- **Name**: `cluster_{sanitized_upstream_url}` (e.g., `cluster_api_weather_com`) +For each API-level upstream (main/sandbox), create a Cluster: +- **Name**: `{env}_{hash}` where `hash` is the full hexadecimal SHA-256 digest of the API ID (e.g., `main_f9811b73ac5d1a8db842634fc0f871e03207ae44105fc9c2b7f1985e70f90d5a`). Main and sandbox share the digest and are distinguished by the env prefix. The name is derived from the API's identity, not the URL, so a URL edit never renames the cluster. Host, port, or scheme changes are applied as an update to the same named cluster (warmed and swapped); path-only changes touch just the route rewrite. Routes and name-keyed stats stay continuous either way. - **Type**: `STRICT_DNS` or `LOGICAL_DNS` - **Load Assignment**: Endpoint with upstream host and port @@ -691,9 +691,9 @@ data: - **Listener**: `listener_http_8080` listening on `0.0.0.0:8080` - **Route**: `route_weather_api_v1_0` - Match: `GET /weather/{country_code}/{city}` - - Action: Forward to `cluster_api_weather_com` + - Action: Forward to `main_` (the API's main upstream cluster) - Prefix Rewrite: Prepend `/api/v2` → final path: `/api/v2/{country_code}/{city}` -- **Cluster**: `cluster_api_weather_com` +- **Cluster**: `main_` (identity-derived name, stable across URL edits) - Host: `api.weather.com:443` - TLS: Enabled (HTTPS upstream)