diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 20052938f..84875cdbf 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -176,9 +176,23 @@ jobs: --set idp.idpds.deployment.image=${{ secrets.AZURE_ACR_NAME }}.azurecr.io/apk-idp-domain-service:${{ github.sha }} \ --set idp.idpui.deployment.image=${{ secrets.AZURE_ACR_NAME }}.azurecr.io/apk-idp-ui:${{ github.sha }} \ --set wso2.apk.dp.ratelimiter.deployment.image=${{ secrets.AZURE_ACR_NAME }}.azurecr.io/apk-ratelimiter:${{ github.sha }} \ - --set wso2.apk.dp.gateway.httpListener.enabled=true + --set wso2.apk.dp.gateway.httpListener.enabled=true \ + --set wso2.apk.dp.gatewayRuntime.deployment.router.configs.enableIntelligentRouting=true kubectl get pods -n apk-integration-test kubectl get svc -n apk-integration-test + - name: Archieve Logs + shell: sh + run: | + cd apk-repo + mkdir -p apk-repo/test/integration/podlogs + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=adapter -f > apk-repo/test/integration/podlogs/adapter.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=commoncontroller -f > apk-repo/test/integration/podlogs/common-controller.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=gateway -c enforcer -f > apk-repo/test/integration/podlogs/enforcer.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=gateway -c router -f > apk-repo/test/integration/podlogs/router.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=configdeployer-ds -f> apk-repo/test/integration/podlogs/config-deployer.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=idp-ds -f > apk-repo/test/integration/podlogs/idpds.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=idp-ui -f> apk-repo/test/integration/podlogs/idpui.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=ratelimiter -f> apk-repo/test/integration/podlogs/ratelimiter.log & - name: Run test cases shell: sh run: | @@ -214,7 +228,12 @@ jobs: with: report_paths: 'apk-repo/test/postman-tests/build/*.xml' fail_on_test_failures: true - + - name: Archieve Logs + if: always() + uses: actions/upload-artifact@v2 + with: + name: apk-integration-test-go-logs + path: 'apk-repo/test/integration/podlogs/*.log' runs_cucumber_integration_tests_on_pull_request: if: github.event_name == 'pull_request_target' && contains(github.event.label.name, 'trigger-action') needs: [build_adapter, build_common_controller, build_enforcer, build_router, build_config,build_idpds,build_idpui,build_ratelimiter] @@ -283,9 +302,23 @@ jobs: --set wso2.apk.dp.gatewayRuntime.deployment.router.readinessProbe.failureThreshold=10 \ --set idp.idpds.deployment.image=${{ secrets.AZURE_ACR_NAME }}.azurecr.io/apk-idp-domain-service:${{ github.sha }} \ --set idp.idpui.deployment.image=${{ secrets.AZURE_ACR_NAME }}.azurecr.io/apk-idp-ui:${{ github.sha }} \ - --set wso2.apk.dp.ratelimiter.deployment.image=${{ secrets.AZURE_ACR_NAME }}.azurecr.io/apk-ratelimiter:${{ github.sha }} + --set wso2.apk.dp.ratelimiter.deployment.image=${{ secrets.AZURE_ACR_NAME }}.azurecr.io/apk-ratelimiter:${{ github.sha }} \ + --set wso2.apk.dp.gatewayRuntime.deployment.router.configs.enableIntelligentRouting=true kubectl get pods -n apk-integration-test kubectl get svc -n apk-integration-test + - name: Archieve Logs + shell: sh + run: | + cd apk-repo + mkdir -p apk-repo/test/integration/podlogs + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=adapter -f > apk-repo/test/integration/podlogs/adapter.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=commoncontroller -f > apk-repo/test/integration/podlogs/common-controller.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=gateway -c enforcer -f > apk-repo/test/integration/podlogs/enforcer.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=gateway -c router -f > apk-repo/test/integration/podlogs/router.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=configdeployer-ds -f> apk-repo/test/integration/podlogs/config-deployer.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=idp-ds -f > apk-repo/test/integration/podlogs/idpds.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=idp-ui -f> apk-repo/test/integration/podlogs/idpui.log & + kubectl logs -n apk-integration-test -l app.kubernetes.io/app=ratelimiter -f> apk-repo/test/integration/podlogs/ratelimiter.log & - name: Run test cases shell: sh run: | @@ -321,3 +354,10 @@ jobs: with: report_paths: 'apk-repo/test/cucumber-tests/build/test-output/junitreports/*.xml' fail_on_test_failures: true + - name: Archieve Logs + if: always() + uses: actions/upload-artifact@v2 + with: + name: apk-integration-test-cucmber-logs + path: 'apk-repo/test/integration/podlogs/*.log' + \ No newline at end of file diff --git a/adapter/config/default_config.go b/adapter/config/default_config.go index df213701c..c16823a2c 100644 --- a/adapter/config/default_config.go +++ b/adapter/config/default_config.go @@ -139,6 +139,7 @@ var defaultConfig = &Config{ CaCertFilePath: "/home/wso2/security/truststore/ratelimiter.crt", SSLCertSANHostname: "", }, + EnableIntelligentRouting: false, }, Enforcer: enforcer{ Management: management{ diff --git a/adapter/config/types.go b/adapter/config/types.go index 5741dab3e..b4a4f77ee 100644 --- a/adapter/config/types.go +++ b/adapter/config/types.go @@ -115,9 +115,10 @@ type envoy struct { Connection connection PayloadPassingToEnforcer payloadPassingToEnforcer // If configured true, router appends the immediate downstream ip address to the x-forward-for header - UseRemoteAddress bool - Filters filters - RateLimit rateLimit + UseRemoteAddress bool + Filters filters + RateLimit rateLimit + EnableIntelligentRouting bool } type connectionTimeouts struct { diff --git a/adapter/internal/discovery/xds/semantic_versioning.go b/adapter/internal/discovery/xds/semantic_versioning.go new file mode 100644 index 000000000..6a94e19d2 --- /dev/null +++ b/adapter/internal/discovery/xds/semantic_versioning.go @@ -0,0 +1,383 @@ +/* + * Copyright (c) 2024, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 xds + +import ( + "strconv" + "strings" + + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + envoy_type_matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" + "github.com/wso2/apk/adapter/config" + logger "github.com/wso2/apk/adapter/internal/loggers" + logging "github.com/wso2/apk/adapter/internal/logging" + "github.com/wso2/apk/adapter/internal/oasparser/model" + semantic_version "github.com/wso2/apk/adapter/pkg/semanticversion" +) + +// GetVersionMatchRegex returns the regex to match the full version string +func GetVersionMatchRegex(version string) string { + // Match "." character in the version by replacing it with "\\." + return strings.ReplaceAll(version, ".", "\\.") +} + +// GetMajorMinorVersionRangeRegex generates major and minor version compatible range regex for the given version +func GetMajorMinorVersionRangeRegex(semVersion semantic_version.SemVersion) string { + majorVersion := strconv.Itoa(semVersion.Major) + minorVersion := strconv.Itoa(semVersion.Minor) + if semVersion.Patch == nil { + return "v" + majorVersion + "(?:\\." + minorVersion + ")?" + } + patchVersion := strconv.Itoa(*semVersion.Patch) + return "v" + majorVersion + "(?:\\." + minorVersion + "(?:\\." + patchVersion + ")?)?" +} + +// GetMinorVersionRangeRegex generates minor version compatible range regex for the given version +func GetMinorVersionRangeRegex(semVersion semantic_version.SemVersion) string { + if semVersion.Patch == nil { + return GetVersionMatchRegex(semVersion.Version) + } + majorVersion := strconv.Itoa(semVersion.Major) + minorVersion := strconv.Itoa(semVersion.Minor) + patchVersion := strconv.Itoa(*semVersion.Patch) + return "v" + majorVersion + "\\." + minorVersion + "(?:\\." + patchVersion + ")?" +} + +// GetMajorVersionRange generates major version range for the given version +func GetMajorVersionRange(semVersion semantic_version.SemVersion) string { + return "v" + strconv.Itoa(semVersion.Major) +} + +// GetMinorVersionRange generates minor version range for the given version +func GetMinorVersionRange(semVersion semantic_version.SemVersion) string { + return "v" + strconv.Itoa(semVersion.Major) + "." + strconv.Itoa(semVersion.Minor) +} + +func updateRoutingRulesOnAPIUpdate(organizationID, apiIdentifier, apiName, apiVersion, vHost string) { + + apiSemVersion, err := semantic_version.ValidateAndGetVersionComponents(apiVersion, apiName) + // If the version validation is not success, we just proceed without intelligent version + // Valid version pattern: vx.y.z or vx.y where x, y and z are non-negative integers and v is a prefix + if err != nil && apiSemVersion == nil { + return + } + + apiRangeIdentifier := generateIdentifierForAPIWithoutVersion(vHost, apiName) + // Check the major and minor version ranges of the current API + existingMajorRangeLatestSemVersion, isMajorRangeRegexAvailable := + orgIDLatestAPIVersionMap[organizationID][apiRangeIdentifier][GetMajorVersionRange(*apiSemVersion)] + existingMinorRangeLatestSemVersion, isMinorRangeRegexAvailable := + orgIDLatestAPIVersionMap[organizationID][apiRangeIdentifier][GetMinorVersionRange(*apiSemVersion)] + + // Check whether the current API is the latest version in the major and minor version ranges + isLatestMajorVersion := !isMajorRangeRegexAvailable || existingMajorRangeLatestSemVersion.Compare(*apiSemVersion) + isLatestMinorVersion := !isMinorRangeRegexAvailable || existingMinorRangeLatestSemVersion.Compare(*apiSemVersion) + + // Remove the existing regexes from the path specifier when latest major and/or minor version is available + if (isMajorRangeRegexAvailable || isMinorRangeRegexAvailable) && (isLatestMajorVersion || isLatestMinorVersion) { + // Organization's all apis + for _, envoyInternalAPI := range orgAPIMap[organizationID] { + // API's all versions in the same vHost + if envoyInternalAPI.adapterInternalAPI.GetTitle() == apiName && isVHostMatched(organizationID, vHost) { + + if (isMajorRangeRegexAvailable && envoyInternalAPI.adapterInternalAPI.GetVersion() == existingMajorRangeLatestSemVersion.Version) || + (isMinorRangeRegexAvailable && envoyInternalAPI.adapterInternalAPI.GetVersion() == existingMinorRangeLatestSemVersion.Version) { + + for _, route := range envoyInternalAPI.routes { + regex := route.GetMatch().GetSafeRegex().GetRegex() + regexRewritePattern := route.GetRoute().GetRegexRewrite().GetPattern().GetRegex() + existingMinorRangeLatestVersionRegex := GetVersionMatchRegex(existingMinorRangeLatestSemVersion.Version) + existingMajorRangeLatestVersionRegex := GetVersionMatchRegex(existingMajorRangeLatestSemVersion.Version) + if isMinorRangeRegexAvailable && envoyInternalAPI.adapterInternalAPI.GetVersion() == existingMinorRangeLatestSemVersion.Version && isLatestMinorVersion { + regex = strings.Replace(regex, GetMinorVersionRangeRegex(existingMinorRangeLatestSemVersion), existingMinorRangeLatestVersionRegex, 1) + regex = strings.Replace(regex, GetMajorMinorVersionRangeRegex(existingMajorRangeLatestSemVersion), existingMajorRangeLatestVersionRegex, 1) + regexRewritePattern = strings.Replace(regexRewritePattern, GetMinorVersionRangeRegex(existingMinorRangeLatestSemVersion), existingMinorRangeLatestVersionRegex, 1) + regexRewritePattern = strings.Replace(regexRewritePattern, GetMajorMinorVersionRangeRegex(existingMajorRangeLatestSemVersion), existingMajorRangeLatestVersionRegex, 1) + } + if isMajorRangeRegexAvailable && envoyInternalAPI.adapterInternalAPI.GetVersion() == existingMajorRangeLatestSemVersion.Version && isLatestMajorVersion { + regex = strings.Replace(regex, GetMajorMinorVersionRangeRegex(existingMajorRangeLatestSemVersion), GetMinorVersionRangeRegex(existingMajorRangeLatestSemVersion), 1) + regexRewritePattern = strings.Replace(regexRewritePattern, GetMajorMinorVersionRangeRegex(existingMajorRangeLatestSemVersion), GetMinorVersionRangeRegex(existingMajorRangeLatestSemVersion), 1) + } + pathSpecifier := &routev3.RouteMatch_SafeRegex{ + SafeRegex: &envoy_type_matcherv3.RegexMatcher{ + Regex: regex, + }, + } + route.Match.PathSpecifier = pathSpecifier + action := route.Action.(*routev3.Route_Route) + action.Route.RegexRewrite.Pattern.Regex = regexRewritePattern + route.Action = action + } + } + } + } + } + + if isLatestMajorVersion || isLatestMinorVersion { + // Update local memory map with the latest version ranges + majorVersionRange := GetMajorVersionRange(*apiSemVersion) + minorVersionRange := GetMinorVersionRange(*apiSemVersion) + if _, orgExists := orgIDLatestAPIVersionMap[organizationID]; !orgExists { + orgIDLatestAPIVersionMap[organizationID] = make(map[string]map[string]semantic_version.SemVersion) + } + if _, apiRangeExists := orgIDLatestAPIVersionMap[organizationID][apiRangeIdentifier]; !apiRangeExists { + orgIDLatestAPIVersionMap[organizationID][apiRangeIdentifier] = make(map[string]semantic_version.SemVersion) + } + + latestVersions := orgIDLatestAPIVersionMap[organizationID][apiRangeIdentifier] + latestVersions[minorVersionRange] = *apiSemVersion + if isLatestMajorVersion { + latestVersions[majorVersionRange] = *apiSemVersion + } + + // Add the major and/or minor version range matching regexes to the path specifier when + // latest major and/or minor version is available + apiRoutes := getRoutesForAPIIdentifier(organizationID, apiIdentifier) + + for _, route := range apiRoutes { + regex := route.GetMatch().GetSafeRegex().GetRegex() + regexRewritePattern := route.GetRoute().GetRegexRewrite().GetPattern().GetRegex() + apiVersionRegex := GetVersionMatchRegex(apiVersion) + + if isLatestMajorVersion { + regex = strings.Replace(regex, apiVersionRegex, GetMajorMinorVersionRangeRegex(*apiSemVersion), 1) + regexRewritePattern = strings.Replace(regexRewritePattern, apiVersionRegex, GetMajorMinorVersionRangeRegex(*apiSemVersion), 1) + } else if isLatestMinorVersion { + regex = strings.Replace(regex, apiVersionRegex, GetMinorVersionRangeRegex(*apiSemVersion), 1) + regexRewritePattern = strings.Replace(regexRewritePattern, apiVersionRegex, GetMinorVersionRangeRegex(*apiSemVersion), 1) + } + pathSpecifier := &routev3.RouteMatch_SafeRegex{ + SafeRegex: &envoy_type_matcherv3.RegexMatcher{ + Regex: regex, + }, + } + + route.Match.PathSpecifier = pathSpecifier + action := &routev3.Route_Route{} + action = route.Action.(*routev3.Route_Route) + action.Route.RegexRewrite.Pattern.Regex = regexRewritePattern + route.Action = action + } + + } +} + +func updateRoutingRulesOnAPIDelete(organizationID, apiIdentifier string, api model.AdapterInternalAPI) { + // Update the intelligent routing if the deleting API is the latest version of the API range + // and the API range has other versions + vhost, err := ExtractVhostFromAPIIdentifier(apiIdentifier) + if err != nil { + logger.LoggerXds.ErrorC(logging.PrintError(logging.Error1411, logging.MAJOR, + "Error extracting vhost from API identifier: %v for Organization %v. Ignore deploying the API, error: %v", + apiIdentifier, organizationID, err)) + } + apiRangeIdentifier := generateIdentifierForAPIWithoutVersion(vhost, api.GetTitle()) + + latestAPIVersionMap, latestAPIVersionMapExists := orgIDLatestAPIVersionMap[organizationID][apiRangeIdentifier] + if !latestAPIVersionMapExists { + return + } + deletingAPISemVersion, _ := semantic_version.ValidateAndGetVersionComponents(api.GetVersion(), api.GetTitle()) + if deletingAPISemVersion == nil { + return + } + majorVersionRange := GetMajorVersionRange(*deletingAPISemVersion) + newLatestMajorRangeAPIIdentifier := "" + + if deletingAPIsMajorRangeLatestAPISemVersion, ok := latestAPIVersionMap[majorVersionRange]; ok { + if deletingAPIsMajorRangeLatestAPISemVersion.Version == api.GetVersion() { + newLatestMajorRangeAPI := &semantic_version.SemVersion{ + Version: "", + Major: deletingAPISemVersion.Major, + Minor: 0, + Patch: nil, + } + for currentAPIIdentifier, envoyInternalAPI := range orgAPIMap[organizationID] { + // Iterate all the API versions other than the deleting API itself + if envoyInternalAPI.adapterInternalAPI.GetTitle() == api.GetTitle() && currentAPIIdentifier != apiIdentifier { + currentAPISemVersion, _ := semantic_version.ValidateAndGetVersionComponents(envoyInternalAPI.adapterInternalAPI.GetVersion(), envoyInternalAPI.adapterInternalAPI.GetTitle()) + if currentAPISemVersion != nil { + if currentAPISemVersion.Major == deletingAPISemVersion.Major { + if newLatestMajorRangeAPI.Compare(*currentAPISemVersion) { + newLatestMajorRangeAPI = currentAPISemVersion + newLatestMajorRangeAPIIdentifier = currentAPIIdentifier + } + } + } + } + } + if newLatestMajorRangeAPIIdentifier != "" { + orgIDLatestAPIVersionMap[organizationID][apiRangeIdentifier][majorVersionRange] = *newLatestMajorRangeAPI + apiRoutes := getRoutesForAPIIdentifier(organizationID, newLatestMajorRangeAPIIdentifier) + for _, route := range apiRoutes { + regex := route.GetMatch().GetSafeRegex().GetRegex() + regexRewritePattern := route.GetRoute().GetRegexRewrite().GetPattern().GetRegex() + newLatestMajorRangeAPIVersionRegex := GetVersionMatchRegex(newLatestMajorRangeAPI.Version) + // Remove any available minor version range regexes and apply the minor range regex + regex = strings.Replace( + regex, + GetMinorVersionRangeRegex(*newLatestMajorRangeAPI), + newLatestMajorRangeAPIVersionRegex, + 1, + ) + regexRewritePattern = strings.Replace( + regexRewritePattern, + GetMinorVersionRangeRegex(*newLatestMajorRangeAPI), + newLatestMajorRangeAPIVersionRegex, + 1, + ) + regex = strings.Replace( + regex, + newLatestMajorRangeAPIVersionRegex, + GetMajorMinorVersionRangeRegex(*newLatestMajorRangeAPI), + 1, + ) + regexRewritePattern = strings.Replace( + regexRewritePattern, + newLatestMajorRangeAPIVersionRegex, + GetMajorMinorVersionRangeRegex(*newLatestMajorRangeAPI), + 1, + ) + pathSpecifier := &routev3.RouteMatch_SafeRegex{ + SafeRegex: &envoy_type_matcherv3.RegexMatcher{ + Regex: regex, + }, + } + + route.Match.PathSpecifier = pathSpecifier + action := &routev3.Route_Route{} + action = route.Action.(*routev3.Route_Route) + action.Route.RegexRewrite.Pattern.Regex = regexRewritePattern + route.Action = action + } + } else { + delete(orgIDLatestAPIVersionMap[organizationID][apiRangeIdentifier], majorVersionRange) + } + } + } + minorVersionRange := GetMinorVersionRange(*deletingAPISemVersion) + + if deletingAPIsMinorRangeLatestAPI, ok := latestAPIVersionMap[minorVersionRange]; ok { + if deletingAPIsMinorRangeLatestAPI.Version == api.GetVersion() { + newLatestMinorRangeAPI := &semantic_version.SemVersion{ + Version: "", + Major: deletingAPISemVersion.Major, + Minor: deletingAPISemVersion.Minor, + Patch: nil, + } + newLatestMinorRangeAPIIdentifier := "" + for currentAPIIdentifier, envoyInternalAPI := range orgAPIMap[organizationID] { + // Iterate all the API versions other than the deleting API itself + if envoyInternalAPI.adapterInternalAPI.GetTitle() == api.GetTitle() && currentAPIIdentifier != apiIdentifier { + currentAPISemVersion, _ := semantic_version.ValidateAndGetVersionComponents(envoyInternalAPI.adapterInternalAPI.GetVersion(), envoyInternalAPI.adapterInternalAPI.GetTitle()) + if currentAPISemVersion != nil { + if currentAPISemVersion.Major == deletingAPISemVersion.Major && + currentAPISemVersion.Minor == deletingAPISemVersion.Minor { + if newLatestMinorRangeAPI.Compare(*currentAPISemVersion) { + newLatestMinorRangeAPI = currentAPISemVersion + newLatestMinorRangeAPIIdentifier = currentAPIIdentifier + } + } + } + } + } + if newLatestMinorRangeAPIIdentifier != "" && newLatestMinorRangeAPIIdentifier != newLatestMajorRangeAPIIdentifier { + orgIDLatestAPIVersionMap[organizationID][apiRangeIdentifier][minorVersionRange] = *newLatestMinorRangeAPI + apiRoutes := getRoutesForAPIIdentifier(organizationID, newLatestMinorRangeAPIIdentifier) + for _, route := range apiRoutes { + regex := route.GetMatch().GetSafeRegex().GetRegex() + newLatestMinorRangeAPIVersionRegex := GetVersionMatchRegex(newLatestMinorRangeAPI.Version) + regex = strings.Replace( + regex, + newLatestMinorRangeAPIVersionRegex, + GetMinorVersionRangeRegex(*newLatestMinorRangeAPI), + 1, + ) + pathSpecifier := &routev3.RouteMatch_SafeRegex{ + SafeRegex: &envoy_type_matcherv3.RegexMatcher{ + Regex: regex, + }, + } + regexRewritePattern := route.GetRoute().GetRegexRewrite().GetPattern().GetRegex() + regexRewritePattern = strings.Replace( + regexRewritePattern, + newLatestMinorRangeAPIVersionRegex, + GetMinorVersionRangeRegex(*newLatestMinorRangeAPI), + 1, + ) + route.Match.PathSpecifier = pathSpecifier + action := &routev3.Route_Route{} + action = route.Action.(*routev3.Route_Route) + action.Route.RegexRewrite.Pattern.Regex = regexRewritePattern + route.Action = action + } + } else { + delete(orgIDLatestAPIVersionMap[organizationID][apiRangeIdentifier], minorVersionRange) + } + } + } + + if orgAPIMap, apiAvailable := orgIDLatestAPIVersionMap[organizationID][apiRangeIdentifier]; apiAvailable && len(orgAPIMap) == 0 { + delete(orgIDLatestAPIVersionMap[organizationID], apiRangeIdentifier) + if orgMap := orgIDLatestAPIVersionMap[organizationID]; len(orgMap) == 0 { + delete(orgIDLatestAPIVersionMap, organizationID) + } + } + +} + +func isVHostMatched(organizationID, vHost string) bool { + + if apis, ok := orgIDAPIvHostsMap[organizationID]; ok { + + for _, vHosts := range apis { + for _, vHostEntry := range vHosts { + if vHostEntry == vHost { + return true + } + } + } + } + return false +} + +func getRoutesForAPIIdentifier(organizationID, apiIdentifier string) []*routev3.Route { + + var routes []*routev3.Route + if _, ok := orgAPIMap[organizationID]; ok { + if _, ok := orgAPIMap[organizationID][apiIdentifier]; ok { + routes = orgAPIMap[organizationID][apiIdentifier].routes + } + } + + return routes +} + +func isSemanticVersioningEnabled(apiName, apiVersion string) bool { + + conf := config.ReadConfigs() + if !conf.Envoy.EnableIntelligentRouting { + return false + } + + apiSemVersion, err := semantic_version.ValidateAndGetVersionComponents(apiVersion, apiName) + if err != nil && apiSemVersion == nil { + return false + } + + return true +} diff --git a/adapter/internal/discovery/xds/semantic_versioning_test.go b/adapter/internal/discovery/xds/semantic_versioning_test.go new file mode 100644 index 000000000..d08d27718 --- /dev/null +++ b/adapter/internal/discovery/xds/semantic_versioning_test.go @@ -0,0 +1,684 @@ +/* + * Copyright (c) 2024, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 xds + +import ( + "reflect" + "regexp" + "testing" + + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + envoy_type_matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" + "github.com/wso2/apk/adapter/config" + "github.com/wso2/apk/adapter/internal/oasparser/model" + semantic_version "github.com/wso2/apk/adapter/pkg/semanticversion" +) + +func TestGetVersionMatchRegex(t *testing.T) { + tests := []struct { + name string + version string + expectedResult string + }{ + { + name: "Version with single digit components", + version: "1.2.3", + expectedResult: "1\\.2\\.3", + }, + { + name: "Version with multi-digit components", + version: "123.456.789", + expectedResult: "123\\.456\\.789", + }, + { + name: "Version with alpha components", + version: "v1.0-alpha", + expectedResult: "v1\\.0-alpha", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetVersionMatchRegex(tt.version) + + if result != tt.expectedResult { + t.Errorf("Expected regex: %s, Got: %s", tt.expectedResult, result) + } + + // Test if the regex works correctly + match, err := regexp.MatchString(result, tt.version) + if err != nil { + t.Errorf("Error when matching regex: %v", err) + } + if !match { + t.Errorf("Regex failed to match the version: %s %s", tt.version, result) + } + }) + } +} + +func TestGetMajorMinorVersionRangeRegex(t *testing.T) { + tests := []struct { + name string + semVersion semantic_version.SemVersion + expectedResult string + }{ + { + name: "Major and minor version only", + semVersion: semantic_version.SemVersion{Major: 1, Minor: 2}, + expectedResult: "v1(?:\\.2)?", + }, + { + name: "Major, minor, and patch version", + semVersion: semantic_version.SemVersion{Major: 1, Minor: 2, Patch: PtrInt(3)}, + expectedResult: "v1(?:\\.2(?:\\.3)?)?", + }, + { + name: "Major version only", + semVersion: semantic_version.SemVersion{Major: 1}, + expectedResult: "v1(?:\\.0)?", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetMajorMinorVersionRangeRegex(tt.semVersion) + + if result != tt.expectedResult { + t.Errorf("Expected regex: %s, Got: %s", tt.expectedResult, result) + } + }) + } +} + +func TestGetMinorVersionRangeRegex(t *testing.T) { + tests := []struct { + name string + semVersion semantic_version.SemVersion + expectedResult string + }{ + { + name: "Major, minor, and patch version", + semVersion: semantic_version.SemVersion{Version: "v1.2.3", Major: 1, Minor: 2, Patch: PtrInt(3)}, + expectedResult: "v1\\.2(?:\\.3)?", + }, + { + name: "Major and minor version only", + semVersion: semantic_version.SemVersion{Version: "v1.2", Major: 1, Minor: 2}, + expectedResult: "v1\\.2", + }, + { + name: "Major version only", + semVersion: semantic_version.SemVersion{Version: "v1", Major: 1}, + expectedResult: "v1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetMinorVersionRangeRegex(tt.semVersion) + + if result != tt.expectedResult { + t.Errorf("Expected regex: %s, Got: %s", tt.expectedResult, result) + } + }) + } +} + +func TestGetMajorVersionRange(t *testing.T) { + tests := []struct { + name string + semVersion semantic_version.SemVersion + expectedResult string + }{ + { + name: "Major and minor version 1.2.3", + semVersion: semantic_version.SemVersion{Version: "v1.2.3", Major: 1, Minor: 2, Patch: PtrInt(3)}, + expectedResult: "v1", + }, + { + name: "Major version 2", + semVersion: semantic_version.SemVersion{Major: 2}, + expectedResult: "v2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetMajorVersionRange(tt.semVersion) + + if result != tt.expectedResult { + t.Errorf("Expected result: %s, Got: %s", tt.expectedResult, result) + } + }) + } +} + +func TestGetMinorVersionRange(t *testing.T) { + tests := []struct { + name string + semVersion semantic_version.SemVersion + expectedResult string + }{ + { + name: "Major and minor version 1.2", + semVersion: semantic_version.SemVersion{Major: 1, Minor: 2}, + expectedResult: "v1.2", + }, + { + name: "Major and minor version 1.2.3", + semVersion: semantic_version.SemVersion{Version: "v1.2.3", Major: 1, Minor: 2, Patch: PtrInt(3)}, + expectedResult: "v1.2", + }, + { + name: "Major only", + semVersion: semantic_version.SemVersion{Major: 10}, + expectedResult: "v10.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetMinorVersionRange(tt.semVersion) + + if result != tt.expectedResult { + t.Errorf("Expected result: %s, Got: %s", tt.expectedResult, result) + } + }) + } +} + +func TestIsSemanticVersioningEnabled(t *testing.T) { + + conf := config.ReadConfigs() + + tests := []struct { + name string + apiName string + apiVersion string + intelligentRoutingEnabled bool + expectedResult bool + }{ + { + name: "Semantic versioning enabled and valid version provided", + apiName: "TestAPI", + apiVersion: "v1.2.3", + intelligentRoutingEnabled: true, + expectedResult: true, + }, + { + name: "Semantic versioning enabled and valid version provided", + apiName: "TestAPI", + apiVersion: "v1.2", + intelligentRoutingEnabled: true, + expectedResult: true, + }, + { + name: "Semantic versioning enabled and version only contains major version", + apiName: "TestAPI", + apiVersion: "v1", + intelligentRoutingEnabled: true, + expectedResult: false, + }, + { + name: "Semantic versioning enabled and invalid version provided", + apiName: "TestAPI", + apiVersion: "1.2.3", + intelligentRoutingEnabled: true, + expectedResult: false, + }, + { + name: "Semantic versioning disabled and valid version provided", + apiName: "TestAPI", + apiVersion: "v1.2.3", + intelligentRoutingEnabled: false, + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + conf.Envoy.EnableIntelligentRouting = tt.intelligentRoutingEnabled + result := isSemanticVersioningEnabled(tt.apiName, tt.apiVersion) + + if result != tt.expectedResult { + t.Errorf("Expected result: %v, Got: %v", tt.expectedResult, result) + } + }) + } +} + +func TestIsVHostMatched(t *testing.T) { + // Mock orgIDAPIvHostsMap for testing + orgIDAPIvHostsMap = map[string]map[string][]string{ + "org1": { + "api1": {"example.com", "api.example.com"}, + "api2": {"test.com"}, + }, + "org2": { + "api3": {"example.org"}, + "api4": {"test.org"}, + }, + } + + tests := []struct { + name string + organizationID string + vHost string + expectedResult bool + }{ + { + name: "Matching vHost in org1", + organizationID: "org1", + vHost: "example.com", + expectedResult: true, + }, + { + name: "Matching vHost in org2", + organizationID: "org2", + vHost: "example.org", + expectedResult: true, + }, + { + name: "Non-matching vHost in org1", + organizationID: "org1", + vHost: "nonexistent.com", + expectedResult: false, + }, + { + name: "Non-matching vHost in org2", + organizationID: "org2", + vHost: "nonexistent.org", + expectedResult: false, + }, + { + name: "VHost not found for organization", + organizationID: "org3", + vHost: "example.com", + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isVHostMatched(tt.organizationID, tt.vHost) + + if result != tt.expectedResult { + t.Errorf("Expected result: %v, Got: %v", tt.expectedResult, result) + } + }) + } +} + +func TestGetRoutesForAPIIdentifier(t *testing.T) { + + orgAPIMap = map[string]map[string]*EnvoyInternalAPI{ + "org1": { + "gw.com:apiID1": &EnvoyInternalAPI{ + routes: []*routev3.Route{ + { + Name: "route1", + }, + { + Name: "route2", + }, + }, + }, + "gw.com:apiID2": &EnvoyInternalAPI{ + routes: []*routev3.Route{ + { + Name: "route3", + }, + }, + }, + }, + "org2": { + "test.gw.com:apiID1": &EnvoyInternalAPI{ + routes: []*routev3.Route{ + { + Name: "route4", + }, + }, + }, + }, + } + + tests := []struct { + name string + organizationID string + apiIdentifier string + expectedRoutes []*routev3.Route + expectedNumRoute int + }{ + { + name: "Existing organization and API identifier", + organizationID: "org1", + apiIdentifier: "gw.com:apiID1", + expectedRoutes: []*routev3.Route{ + { + Name: "route1", + }, + { + Name: "route2", + }, + }, + expectedNumRoute: 2, + }, + { + name: "Non-existing organization", + organizationID: "org3", + apiIdentifier: "dev.gw.com:apiID1", + expectedRoutes: []*routev3.Route{}, + expectedNumRoute: 0, + }, + { + name: "Non-existing API identifier", + organizationID: "org1", + apiIdentifier: "api3", + expectedRoutes: []*routev3.Route{}, + expectedNumRoute: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getRoutesForAPIIdentifier(tt.organizationID, tt.apiIdentifier) + + if len(result) != tt.expectedNumRoute { + t.Errorf("Expected number of routes: %d, Got: %d", tt.expectedNumRoute, len(result)) + } + + if len(result) > 0 { + if !reflect.DeepEqual(result, tt.expectedRoutes) { + t.Errorf("Expected routes: %v, Got: %v", tt.expectedRoutes, result) + } + } + }) + } +} + +func TestUpdateRoutingRulesOnAPIUpdate(t *testing.T) { + + var apiID1 model.AdapterInternalAPI + apiID1.SetName("Test API") + apiID1.SetVersion("v1.0") + apiID1ResourcePath := "^/test-api/v1\\.0/orders([/]{0,1})" + + var apiID2 model.AdapterInternalAPI + apiID2.SetName("Mock API") + apiID2.SetVersion("v1.1") + apiID2ResourcePath := "^/mock-api/v1\\.1/orders([/]{0,1})" + + var apiID3 model.AdapterInternalAPI + apiID3.SetName("Test API") + apiID3.SetVersion("v1.1") + apiID3ResourcePath := "^/test-api/v1\\.1/orders([/]{0,1})" + + orgAPIMap = map[string]map[string]*EnvoyInternalAPI{ + "org1": { + "gw.com:apiID1": &EnvoyInternalAPI{ + adapterInternalAPI: apiID1, + routes: generateRoutes(apiID1ResourcePath), + }, + "gw.com:apiID2": &EnvoyInternalAPI{ + adapterInternalAPI: apiID2, + routes: generateRoutes(apiID2ResourcePath), + }, + "gw.com:apiID3": &EnvoyInternalAPI{ + adapterInternalAPI: apiID3, + routes: generateRoutes(apiID3ResourcePath), + }, + }, + } + + orgIDAPIvHostsMap = map[string]map[string][]string{ + "org1": { + "api1": {"gw.com", "api.example.com"}, + "api2": {"test.com"}, + }, + } + + tests := []struct { + name string + organizationID string + apiIdentifier string + apiName string + apiVersion string + vHost string + expectedRegex string + expectedRewrite string + finalRegex string + finalRewrite string + }{ + { + name: "Create an API with major version", + organizationID: "org1", + apiIdentifier: "gw.com:apiID1", + apiName: "Test API", + apiVersion: "v1.0", + vHost: "gw.com", + expectedRegex: "^/test-api/v1(?:\\.0)?/orders([/]{0,1})", + expectedRewrite: "^/test-api/v1(?:\\.0)?/orders([/]{0,1})", + finalRegex: apiID1ResourcePath, + finalRewrite: apiID1ResourcePath, + }, + { + name: "Create an API with major and minor version", + organizationID: "org1", + apiIdentifier: "gw.com:apiID2", + apiName: "Mock API", + apiVersion: "v1.1", + vHost: "gw.com", + expectedRegex: "^/mock-api/v1(?:\\.1)?/orders([/]{0,1})", + expectedRewrite: "^/mock-api/v1(?:\\.1)?/orders([/]{0,1})", + finalRegex: "^/mock-api/v1(?:\\.1)?/orders([/]{0,1})", + finalRewrite: "^/mock-api/v1(?:\\.1)?/orders([/]{0,1})", + }, + { + name: "Create an API with major and minor version", + organizationID: "org1", + apiIdentifier: "gw.com:apiID3", + apiName: "Test API", + apiVersion: "v1.1", + vHost: "gw.com", + expectedRegex: "^/test-api/v1(?:\\.1)?/orders([/]{0,1})", + expectedRewrite: "^/test-api/v1(?:\\.1)?/orders([/]{0,1})", + finalRegex: "^/test-api/v1(?:\\.1)?/orders([/]{0,1})", + finalRewrite: "^/test-api/v1(?:\\.1)?/orders([/]{0,1})", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + updateRoutingRulesOnAPIUpdate(tt.organizationID, tt.apiIdentifier, tt.apiName, tt.apiVersion, tt.vHost) + api1 := orgAPIMap[tt.organizationID][tt.apiIdentifier] + routes := api1.routes + + if routes[0].GetMatch().GetSafeRegex().GetRegex() != tt.expectedRegex { + t.Errorf("Expected regex: %s, Got: %s", tt.expectedRegex, routes[0].GetMatch().GetSafeRegex().GetRegex()) + } + if routes[0].GetRoute().GetRegexRewrite().GetPattern().GetRegex() != tt.expectedRewrite { + t.Errorf("Expected rewrite pattern: %s, Got: %s", tt.expectedRewrite, routes[0].GetRoute().GetRegexRewrite().GetPattern().GetRegex()) + } + }) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + api1 := orgAPIMap[tt.organizationID][tt.apiIdentifier] + routes := api1.routes + + if routes[0].GetMatch().GetSafeRegex().GetRegex() != tt.finalRegex { + t.Errorf("Expected final regex: %s, Got: %s", tt.finalRegex, routes[0].GetMatch().GetSafeRegex().GetRegex()) + } + if routes[0].GetRoute().GetRegexRewrite().GetPattern().GetRegex() != tt.finalRewrite { + t.Errorf("Expected final rewrite pattern: %s, Got: %s", tt.finalRewrite, routes[0].GetRoute().GetRegexRewrite().GetPattern().GetRegex()) + } + }) + } +} + +func generateRoutes(resourcePath string) []*routev3.Route { + + var routes []*routev3.Route + match := &routev3.RouteMatch{ + PathSpecifier: &routev3.RouteMatch_SafeRegex{ + SafeRegex: &envoy_type_matcherv3.RegexMatcher{ + Regex: resourcePath, + }, + }, + } + + action := &routev3.Route_Route{ + Route: &routev3.RouteAction{ + RegexRewrite: &envoy_type_matcherv3.RegexMatchAndSubstitute{ + Pattern: &envoy_type_matcherv3.RegexMatcher{ + Regex: resourcePath, + }, + Substitution: "/bar", + }, + }, + } + + route := routev3.Route{ + Name: "example-route", + Match: match, + Action: action, + Metadata: nil, + Decorator: nil, + } + + return append(routes, &route) +} + +func TestUpdateRoutingRulesOnAPIDelete(t *testing.T) { + + orgIDLatestAPIVersionMap = map[string]map[string]map[string]semantic_version.SemVersion{ + "org3": { + "gw.com:Test API": { + "v1": { + Version: "v1.0", + Major: 1, + Minor: 0, + Patch: nil, + }, + }, + }, + "org4": { + "gw.com:Mock API": { + "v1.0": { + Version: "v1.0", + Major: 1, + Minor: 0, + Patch: nil, + }, + "v1.5": { + Version: "v1.5", + Major: 1, + Minor: 5, + Patch: nil, + }, + "v1": { + Version: "v1.5", + Major: 1, + Minor: 5, + Patch: nil, + }, + }, + }, + } + + var apiID1 model.AdapterInternalAPI + apiID1.SetName("Test API") + apiID1.SetVersion("v1.0") + apiID1ResourcePath := "^/test-api/v1\\.0/orders([/]{0,1})" + + var apiID2 model.AdapterInternalAPI + apiID2.SetName("Mock API") + apiID2.SetVersion("v1.0") + apiID2ResourcePath := "^/mock-api/v1\\.0/orders([/]{0,1})" + + var apiID3 model.AdapterInternalAPI + apiID3.SetName("Mock API") + apiID3.SetVersion("v1.5") + apiID3ResourcePath := "^/mock-api/v1(?:\\.5)?/orders([/]{0,1})" + + orgAPIMap = map[string]map[string]*EnvoyInternalAPI{ + "org3": { + "gw.com:apiID1": &EnvoyInternalAPI{ + adapterInternalAPI: apiID1, + routes: generateRoutes(apiID1ResourcePath), + }, + }, + "org4": { + "gw.com:apiID2": &EnvoyInternalAPI{ + adapterInternalAPI: apiID2, + routes: generateRoutes(apiID2ResourcePath), + }, + "gw.com:apiID3": &EnvoyInternalAPI{ + adapterInternalAPI: apiID3, + routes: generateRoutes(apiID3ResourcePath), + }, + }, + } + + tests := []struct { + name string + organizationID string + apiIdentifier string + api model.AdapterInternalAPI + deleteVersion string + }{ + { + name: "Delete latest major version", + organizationID: "org3", + apiIdentifier: "gw.com:apiID1", + api: apiID1, + deleteVersion: "v1.0", + }, + { + name: "Delete latest minor version", + organizationID: "org4", + apiIdentifier: "gw.com:apiID3", + api: apiID3, + deleteVersion: "v1.5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + updateRoutingRulesOnAPIDelete(tt.organizationID, tt.apiIdentifier, tt.api) + + if _, ok := orgIDLatestAPIVersionMap[tt.organizationID]; ok { + if _, ok := orgIDLatestAPIVersionMap[tt.organizationID][tt.apiIdentifier]; ok { + if _, ok := orgIDLatestAPIVersionMap[tt.organizationID][tt.apiIdentifier][tt.deleteVersion]; ok { + t.Errorf("API deletion is not successful: %s", tt.deleteVersion) + } + } + } + }) + } +} + +// PtrInt returns a pointer to an integer value +func PtrInt(i int) *int { + return &i +} diff --git a/adapter/internal/discovery/xds/server.go b/adapter/internal/discovery/xds/server.go index e6a139f4b..f85195bbf 100644 --- a/adapter/internal/discovery/xds/server.go +++ b/adapter/internal/discovery/xds/server.go @@ -53,6 +53,7 @@ import ( wso2_cache "github.com/wso2/apk/adapter/pkg/discovery/protocol/cache/v3" wso2_resource "github.com/wso2/apk/adapter/pkg/discovery/protocol/resource/v3" eventhubTypes "github.com/wso2/apk/adapter/pkg/eventhub/types" + semantic_version "github.com/wso2/apk/adapter/pkg/semanticversion" "github.com/wso2/apk/adapter/pkg/utils/stringutils" gwapiv1b1 "sigs.k8s.io/gateway-api/apis/v1beta1" ) @@ -102,6 +103,7 @@ var ( orgIDvHostBasepathMap map[string]map[string]string // organizationID -> Vhost:basepath -> Vhost:API_UUID orgIDAPIvHostsMap map[string]map[string][]string // organizationID -> UUID -> prod/sand -> Envoy Vhost Array map + orgIDLatestAPIVersionMap map[string]map[string]map[string]semantic_version.SemVersion // organizationID -> Vhost:APIName -> Version Range -> Latest API Version // Envoy Label as map key // TODO(amali) use this without generating all again. gatewayLabelConfigMap map[string]*EnvoyGatewayConfig // GW-Label -> EnvoyGatewayConfig struct map @@ -155,6 +157,7 @@ func init() { orgAPIMap = make(map[string]map[string]*EnvoyInternalAPI) orgIDAPIvHostsMap = make(map[string]map[string][]string) // organizationID -> UUID-prod/sand -> Envoy Vhost Array map orgIDvHostBasepathMap = make(map[string]map[string]string) + orgIDLatestAPIVersionMap = make(map[string]map[string]map[string]semantic_version.SemVersion) enforcerLabelMap = make(map[string]*EnforcerInternalAPI) // currently subscriptions, configs, applications, applicationPolicies, subscriptionPolicies, @@ -239,11 +242,16 @@ func DeleteAPICREvent(labels []string, apiUUID string, organizationID string) er // deleteAPI deletes an API, its resources and updates the caches of given environments func deleteAPI(apiIdentifier string, environments []string, organizationID string) error { apiUUID, _ := ExtractUUIDFromAPIIdentifier(apiIdentifier) + var api *EnvoyInternalAPI + if _, orgExists := orgAPIMap[organizationID]; orgExists { - if _, apiExists := orgAPIMap[organizationID][apiIdentifier]; !apiExists { + if oldAPI, apiExists := orgAPIMap[organizationID][apiIdentifier]; apiExists { + api = oldAPI + } else { logger.LoggerXds.Infof("Unable to delete API: %v from Organization: %v. API Does not exist. API_UUID: %v", apiIdentifier, organizationID, apiUUID) return errors.New(constants.NotFound) } + } else { logger.LoggerXds.Infof("Unable to delete API: %v from Organization: %v. Organization Does not exist. API_UUID: %v", apiIdentifier, organizationID, apiUUID) return errors.New(constants.NotFound) @@ -252,6 +260,10 @@ func deleteAPI(apiIdentifier string, environments []string, organizationID strin existingLabels := orgAPIMap[organizationID][apiIdentifier].envoyLabels toBeDelEnvs, toBeKeptEnvs := getEnvironmentsToBeDeleted(existingLabels, environments) + if isSemanticVersioningEnabled(api.adapterInternalAPI.GetTitle(), api.adapterInternalAPI.GetVersion()) { + updateRoutingRulesOnAPIDelete(organizationID, apiIdentifier, api.adapterInternalAPI) + } + var isAllowedToDelete bool updatedLabelsMap := make(map[string]struct{}) for _, val := range toBeDelEnvs { @@ -598,6 +610,11 @@ func GenerateHashedAPINameVersionIDWithoutVhost(name, version string) string { return generateHashValue(name, version) } +// GenerateIdentifierForAPIWithoutVersion generates an identifier unique to the API despite of the version +func generateIdentifierForAPIWithoutVersion(vhost, name string) string { + return fmt.Sprint(vhost, apiKeyFieldSeparator, name) +} + func generateHashValue(apiName string, apiVersion string) string { apiNameVersionHash := sha1.New() apiNameVersionHash.Write([]byte(apiName + ":" + apiVersion)) @@ -674,7 +691,7 @@ func UpdateAPICache(vHosts []string, newLabels []string, listener string, sectio updatedLabelsMap := make(map[string]struct{}, 0) - // Remove internal mappigs for old vHosts + // Remove internal mappings for old vHosts for _, oldvhost := range oldvHosts { apiIdentifier := GenerateIdentifierForAPIWithUUID(oldvhost, adapterInternalAPI.UUID) if orgMap, orgExists := orgAPIMap[adapterInternalAPI.GetOrganizationID()]; orgExists { @@ -725,7 +742,14 @@ func UpdateAPICache(vHosts []string, newLabels []string, listener string, sectio endpointAddresses: endpoints, enforcerAPI: oasParser.GetEnforcerAPI(adapterInternalAPI, vHost), } + + apiVersion := adapterInternalAPI.GetVersion() + apiName := adapterInternalAPI.GetTitle() + if isSemanticVersioningEnabled(apiName, apiVersion) { + updateRoutingRulesOnAPIUpdate(adapterInternalAPI.OrganizationID, apiIdentifier, apiName, apiVersion, vHost) + } } + return updatedLabelsMap, nil } diff --git a/adapter/internal/oasparser/envoyconf/routes_with_clusters.go b/adapter/internal/oasparser/envoyconf/routes_with_clusters.go index 379ba81c9..2addbd7a2 100644 --- a/adapter/internal/oasparser/envoyconf/routes_with_clusters.go +++ b/adapter/internal/oasparser/envoyconf/routes_with_clusters.go @@ -44,7 +44,8 @@ import ( upstreams "github.com/envoyproxy/go-control-plane/envoy/extensions/upstreams/http/v3" envoy_type_matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" "github.com/envoyproxy/go-control-plane/pkg/wellknown" - + "github.com/golang/protobuf/ptypes/any" + "github.com/golang/protobuf/ptypes/wrappers" "github.com/wso2/apk/adapter/config" "github.com/wso2/apk/adapter/internal/interceptor" logger "github.com/wso2/apk/adapter/internal/loggers" @@ -53,9 +54,6 @@ import ( "github.com/wso2/apk/adapter/internal/oasparser/model" "github.com/wso2/apk/adapter/internal/svcdiscovery" dpv1alpha2 "github.com/wso2/apk/common-go-libs/apis/dp/v1alpha2" - - "github.com/golang/protobuf/ptypes/any" - "github.com/golang/protobuf/ptypes/wrappers" "google.golang.org/protobuf/proto" gwapiv1b1 "sigs.k8s.io/gateway-api/apis/v1beta1" ) @@ -124,10 +122,10 @@ func CreateRoutesWithClusters(adapterInternalAPI *model.AdapterInternalAPI, inte } else { methods = append(methods, "GET") } - routeP := CreateAPIDefinitionEndpoint(adapterInternalAPI.GetXWso2Basepath(), vHost, methods, false, adapterInternalAPI.GetVersion(), adapterInternalAPI.GetAPIDefinitionEndpoint()) + routeP := CreateAPIDefinitionEndpoint(adapterInternalAPI, vHost, methods, false) routes = append(routes, routeP) if (adapterInternalAPI).IsDefaultVersion { - defaultDefRoutes := CreateAPIDefinitionEndpoint(adapterInternalAPI.GetXWso2Basepath(), vHost, methods, true, adapterInternalAPI.GetVersion(), adapterInternalAPI.GetAPIDefinitionEndpoint()) + defaultDefRoutes := CreateAPIDefinitionEndpoint(adapterInternalAPI, vHost, methods, true) routes = append(routes, defaultDefRoutes) } var endpointForAPIDefinitions []model.Endpoint @@ -864,7 +862,12 @@ func createRoutes(params *routeCreateParams) (routes []*routev3.Route, err error xWso2Basepath = removeFirstOccurrence(xWso2Basepath, "/"+version) resourcePath = removeFirstOccurrence(resource.GetPath(), "/"+version) } + + if pathMatchType != gwapiv1b1.PathMatchExact { + resourcePath = strings.Replace(resourcePath, basePath, regexp.QuoteMeta(basePath), 1) + } routePath := generateRoutePath(resourcePath, pathMatchType) + // route path could be empty only if there is no basePath for API or the endpoint available, // and resourcePath is also an empty string. // Empty check is added to run the gateway in failsafe mode, as if the decorator string is @@ -1186,7 +1189,11 @@ func CreateAPIDefinitionRoute(basePath string, vHost string, methods []string, i } // CreateAPIDefinitionEndpoint generates a route for the api defition endpoint -func CreateAPIDefinitionEndpoint(basePath string, vHost string, methods []string, isDefaultversion bool, version string, providedAPIDefinitionPath string) *routev3.Route { +func CreateAPIDefinitionEndpoint(adapterInternalAPI *model.AdapterInternalAPI, vHost string, methods []string, isDefaultversion bool) *routev3.Route { + + basePath := adapterInternalAPI.GetXWso2Basepath() + version := adapterInternalAPI.GetVersion() + providedAPIDefinitionPath := adapterInternalAPI.GetAPIDefinitionEndpoint() endpoint := providedAPIDefinitionPath rewritePath := basePath + "/" + vHost + "?" + apiDefinitionQueryParam basePath = strings.TrimSuffix(basePath, "/") @@ -1205,9 +1212,14 @@ func CreateAPIDefinitionEndpoint(basePath string, vHost string, methods []string matchPath = basePathWithoutVersion + endpoint } + matchPath = strings.Replace(matchPath, basePath, regexp.QuoteMeta(basePath), 1) + routePath := generateRoutePath(matchPath, gwapiv1b1.PathMatchRegularExpression) + match = &routev3.RouteMatch{ - PathSpecifier: &routev3.RouteMatch_Path{ - Path: matchPath, + PathSpecifier: &routev3.RouteMatch_SafeRegex{ + SafeRegex: &envoy_type_matcherv3.RegexMatcher{ + Regex: routePath, + }, }, Headers: generateHTTPMethodMatcher(methodRegex, apiDefinitionClusterName), } @@ -1240,7 +1252,7 @@ func CreateAPIDefinitionEndpoint(basePath string, vHost string, methods []string }, }, ClusterSpecifier: directClusterSpecifier, - PrefixRewrite: rewritePath, + RegexRewrite: generateRegexMatchAndSubstitute(routePath, rewritePath, gwapiv1b1.PathMatchExact), }, } @@ -1374,7 +1386,7 @@ func generateRoutePath(resourcePath string, pathMatchType gwapiv1b1.PathMatchTyp case gwapiv1b1.PathMatchPathPrefix: fallthrough default: - return fmt.Sprintf("^%s((?:/.*)*)", regexp.QuoteMeta(newPath)) + return fmt.Sprintf("^%s((?:/.*)*)", newPath) } } diff --git a/adapter/internal/oasparser/envoyconf/routes_with_clusters_test.go b/adapter/internal/oasparser/envoyconf/routes_with_clusters_test.go index a341222ec..03ca8957c 100644 --- a/adapter/internal/oasparser/envoyconf/routes_with_clusters_test.go +++ b/adapter/internal/oasparser/envoyconf/routes_with_clusters_test.go @@ -173,7 +173,7 @@ func TestCreateRoutesWithClustersWithExactAndRegularExpressionRules(t *testing.T assert.Equal(t, 3, len(routes), "Created number of routes are incorrect.") assert.Contains(t, []string{"^/test-api/2\\.0\\.0/exact-path-api/2\\.0\\.0/\\(\\.\\*\\)/exact-path([/]{0,1})"}, routes[1].GetMatch().GetSafeRegex().Regex) - assert.Contains(t, []string{"^/test-api/2.0.0/regex-path/2.0.0/userId/([^/]+)/orderId/([^/]+)([/]{0,1})"}, routes[2].GetMatch().GetSafeRegex().Regex) + assert.Contains(t, []string{"^/test-api/2\\.0\\.0/regex-path/2.0.0/userId/([^/]+)/orderId/([^/]+)([/]{0,1})"}, routes[2].GetMatch().GetSafeRegex().Regex) assert.NotEqual(t, routes[1].GetMatch().GetSafeRegex().Regex, routes[2].GetMatch().GetSafeRegex().Regex, "The route regex for the two paths should not be the same") } diff --git a/adapter/pkg/loggers/logger.go b/adapter/pkg/loggers/logger.go index c8f754261..178a01f4d 100644 --- a/adapter/pkg/loggers/logger.go +++ b/adapter/pkg/loggers/logger.go @@ -32,20 +32,22 @@ When you add a new logger instance add the related package name as a constant // package name constants const ( - pkgAuth = "github.com/wso2/apk/adapter/pkg/auth" - pkgSync = "github.com/wso2/apk/adapter/pkg/synchronizer" - pkgTLSUtils = "github.com/wso2/apk/adapter/pkg/utils/tlsutils" - pkgHealth = "github.com/wso2/apk/adapter/pkg/health" - pkgSoapUtils = "github.com/wso2/apk/adapter/pkg/utils/soaputils" + pkgAuth = "github.com/wso2/apk/adapter/pkg/auth" + pkgSync = "github.com/wso2/apk/adapter/pkg/synchronizer" + pkgTLSUtils = "github.com/wso2/apk/adapter/pkg/utils/tlsutils" + pkgHealth = "github.com/wso2/apk/adapter/pkg/health" + pkgSoapUtils = "github.com/wso2/apk/adapter/pkg/utils/soaputils" + pkgSemanticVersion = "github.com/wso2/product-microgateway/adapter/pkg/semanticversion" ) // logger package references var ( - LoggerAuth logging.Log - LoggerSync logging.Log - LoggerTLSUtils logging.Log - LoggerHealth logging.Log - LoggerSoapUtils logging.Log + LoggerAuth logging.Log + LoggerSync logging.Log + LoggerTLSUtils logging.Log + LoggerHealth logging.Log + LoggerSoapUtils logging.Log + LoggerSemanticVersion logging.Log ) func init() { @@ -59,5 +61,6 @@ func UpdateLoggers() { LoggerTLSUtils = logging.InitPackageLogger(pkgTLSUtils) LoggerHealth = logging.InitPackageLogger(pkgHealth) LoggerSoapUtils = logging.InitPackageLogger(pkgSoapUtils) + LoggerSemanticVersion = logging.InitPackageLogger(pkgSemanticVersion) logrus.Info("Updated loggers") } diff --git a/adapter/pkg/semanticversion/semantic_version.go b/adapter/pkg/semanticversion/semantic_version.go new file mode 100644 index 000000000..3750e5940 --- /dev/null +++ b/adapter/pkg/semanticversion/semantic_version.go @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2024, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 semanticversion + +import ( + "errors" + "fmt" + "strconv" + "strings" + + logger "github.com/wso2/apk/adapter/pkg/loggers" +) + +// SemVersion is the struct to store the version components of an API +type SemVersion struct { + Version string + Major int + Minor int + Patch *int +} + +// ValidateAndGetVersionComponents validates version string and extracts version components +func ValidateAndGetVersionComponents(version string, apiName string) (*SemVersion, error) { + versionComponents := strings.Split(version, ".") + + // If the versionComponents length is less than 2, return error + if len(versionComponents) < 2 || !strings.HasPrefix(versionComponents[0], "v") { + logger.LoggerSemanticVersion.Errorf("API version validation failed for API: %v. API Version: %v", apiName, version) + errMessage := "Invalid version: " + version + " for API: " + apiName + + ". API version should be in the format x.y.z, x.y, vx.y.z or vx.y where x,y,z are non-negative integers" + + " and v is version prefix" + return nil, errors.New(errMessage) + } + + majorVersionStr := strings.TrimPrefix(versionComponents[0], "v") + + majorVersion, majorVersionConvErr := strconv.Atoi(majorVersionStr) + minorVersion, minorVersionConvErr := strconv.Atoi(versionComponents[1]) + if majorVersionConvErr != nil || majorVersion < 0 { + logger.LoggerSemanticVersion.Errorf(fmt.Sprintf("API major version should be a non-negative integer in API: %v. API Version: %v", apiName, version), majorVersionConvErr) + return nil, errors.New("invalid version format") + } + + if minorVersionConvErr != nil || minorVersion < 0 { + logger.LoggerSemanticVersion.Errorf(fmt.Sprintf("API minor version should be a non-negative integer in API: %v. API Version: %v", apiName, version), minorVersionConvErr) + return nil, errors.New("invalid version format") + } + + if len(versionComponents) == 2 { + return &SemVersion{ + Version: version, + Major: majorVersion, + Minor: minorVersion, + Patch: nil, + }, nil + } + + patchVersion, patchVersionConvErr := strconv.Atoi(versionComponents[2]) + if patchVersionConvErr != nil { + logger.LoggerSemanticVersion.Errorf(fmt.Sprintf("API patch version should be an integer in API: %v. API Version: %v", apiName, version), patchVersionConvErr) + return nil, errors.New("invalid version format") + } + return &SemVersion{ + Version: version, + Major: majorVersion, + Minor: minorVersion, + Patch: &patchVersion, + }, nil +} + +// Compare - compares two semantic versions and returns true +// if `version` is greater or equal than `baseVersion` +func (baseVersion SemVersion) Compare(version SemVersion) bool { + // Compare major version + if baseVersion.Major != version.Major { + return baseVersion.Major < version.Major + } + + // Compare minor version + if baseVersion.Minor != version.Minor { + return baseVersion.Minor < version.Minor + } + + // Compare patch version + if baseVersion.Patch != nil && version.Patch != nil { + return *baseVersion.Patch <= *version.Patch + } else if baseVersion.Patch != nil { + return false + } else if version.Patch != nil { + return true + } + + // Versions are equal + return true +} diff --git a/adapter/pkg/semanticversion/semantic_version_test.go b/adapter/pkg/semanticversion/semantic_version_test.go new file mode 100644 index 000000000..53a7e1bf2 --- /dev/null +++ b/adapter/pkg/semanticversion/semantic_version_test.go @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2024, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 semanticversion + +import ( + "errors" + "testing" +) + +func TestSemVersionCompare(t *testing.T) { + tests := []struct { + name string + baseVersion SemVersion + compareVersion SemVersion + expected bool + }{ + { + name: "Same versions", + baseVersion: SemVersion{Major: 1, Minor: 2, Patch: PtrInt(3)}, + compareVersion: SemVersion{Major: 1, Minor: 2, Patch: PtrInt(3)}, + expected: true, + }, + { + name: "Base version major is greater", + baseVersion: SemVersion{Major: 2, Minor: 1, Patch: PtrInt(3)}, + compareVersion: SemVersion{Major: 1, Minor: 2, Patch: PtrInt(3)}, + expected: false, + }, + { + name: "Base version minor is greater", + baseVersion: SemVersion{Major: 1, Minor: 3, Patch: PtrInt(3)}, + compareVersion: SemVersion{Major: 1, Minor: 4, Patch: PtrInt(3)}, + expected: true, + }, + { + name: "Base version patch is greater", + baseVersion: SemVersion{Major: 1, Minor: 2, Patch: PtrInt(4)}, + compareVersion: SemVersion{Major: 1, Minor: 2, Patch: PtrInt(3)}, + expected: false, + }, + { + name: "Compare version major is greater", + baseVersion: SemVersion{Major: 1, Minor: 2, Patch: PtrInt(3)}, + compareVersion: SemVersion{Major: 2, Minor: 2, Patch: PtrInt(3)}, + expected: true, + }, + { + name: "Compare version minor is greater", + baseVersion: SemVersion{Major: 1, Minor: 2, Patch: PtrInt(3)}, + compareVersion: SemVersion{Major: 1, Minor: 3, Patch: PtrInt(3)}, + expected: true, + }, + { + name: "Compare version patch is greater", + baseVersion: SemVersion{Major: 1, Minor: 2, Patch: PtrInt(3)}, + compareVersion: SemVersion{Major: 1, Minor: 2, Patch: PtrInt(4)}, + expected: true, + }, + { + name: "Base version patch is nil", + baseVersion: SemVersion{Major: 1, Minor: 2}, + compareVersion: SemVersion{Major: 1, Minor: 2, Patch: PtrInt(4)}, + expected: true, + }, + { + name: "Compare version patch is nil", + baseVersion: SemVersion{Major: 1, Minor: 2, Patch: PtrInt(3)}, + compareVersion: SemVersion{Major: 1, Minor: 2}, + expected: false, + }, + { + name: "Both patch versions are nil", + baseVersion: SemVersion{Major: 1, Minor: 2}, + compareVersion: SemVersion{Major: 1, Minor: 2}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.baseVersion.Compare(tt.compareVersion) + if result != tt.expected { + t.Errorf("Expected %v, but got %v", tt.expected, result) + } + }) + } +} + +func TestValidateAndGetVersionComponents(t *testing.T) { + tests := []struct { + name string + version string + apiName string + expectedResult *SemVersion + expectedError error + }{ + { + name: "Valid version format", + version: "v1.2.3", + apiName: "TestAPI", + expectedResult: &SemVersion{Version: "v1.2.3", Major: 1, Minor: 2, Patch: PtrInt(3)}, + expectedError: nil, + }, + { + name: "Valid version format without patch", + version: "v1.2", + apiName: "TestAPI", + expectedResult: &SemVersion{Version: "v1.2", Major: 1, Minor: 2, Patch: nil}, + expectedError: nil, + }, + { + name: "Invalid version format - missing 'v' prefix", + version: "1.2.3", + apiName: "TestAPI", + expectedResult: nil, + expectedError: errors.New("Invalid version: 1.2.3 for API: TestAPI. API version should be in the format x.y.z, x.y, vx.y.z or vx.y where x,y,z are non-negative integers and v is version prefix"), + }, + { + name: "Invalid version format - negative major version", + version: "v-1.2.3", + apiName: "TestAPI", + expectedResult: nil, + expectedError: errors.New("invalid version format"), + }, + { + name: "Invalid version format - negative minor version", + version: "v1.-2.3", + apiName: "TestAPI", + expectedResult: nil, + expectedError: errors.New("invalid version format"), + }, + { + name: "Invalid version format - patch version not an integer", + version: "v1.2.three", + apiName: "TestAPI", + expectedResult: nil, + expectedError: errors.New("invalid version format"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ValidateAndGetVersionComponents(tt.version, tt.apiName) + + // Check for errors + if (err != nil && tt.expectedError == nil) || (err == nil && tt.expectedError != nil) || (err != nil && tt.expectedError != nil && err.Error() != tt.expectedError.Error()) { + t.Errorf("Unexpected error. Expected: %v, Got: %v", tt.expectedError, err) + } + + // Check for nil results + if result == nil && tt.expectedResult != nil { + t.Errorf("Unexpected nil result") + } else if result != nil && tt.expectedResult == nil { + t.Errorf("Unexpected non-nil result") + } + + // Check for result equality + if result != nil && tt.expectedResult != nil { + if result.Version != tt.expectedResult.Version || result.Major != tt.expectedResult.Major || result.Minor != tt.expectedResult.Minor || (result.Patch != nil && (*result.Patch != *tt.expectedResult.Patch)) { + t.Errorf("Unexpected result. Expected: %v, Got: %v", tt.expectedResult, result) + } + } + }) + } + +} + +// PtrInt returns a pointer to an integer value +func PtrInt(i int) *int { + return &i +} diff --git a/gateway/enforcer/org.wso2.apk.enforcer/src/main/java/org/wso2/apk/enforcer/security/jwt/JWTAuthenticator.java b/gateway/enforcer/org.wso2.apk.enforcer/src/main/java/org/wso2/apk/enforcer/security/jwt/JWTAuthenticator.java index 6e3c52c35..fc99a686c 100644 --- a/gateway/enforcer/org.wso2.apk.enforcer/src/main/java/org/wso2/apk/enforcer/security/jwt/JWTAuthenticator.java +++ b/gateway/enforcer/org.wso2.apk.enforcer/src/main/java/org/wso2/apk/enforcer/security/jwt/JWTAuthenticator.java @@ -49,6 +49,7 @@ import org.wso2.apk.enforcer.security.jwt.validator.RevokedJWTDataHolder; import org.wso2.apk.enforcer.subscription.SubscriptionDataHolder; import org.wso2.apk.enforcer.server.RevokedTokenRedisClient; +import org.wso2.apk.enforcer.subscription.SubscriptionDataStore; import org.wso2.apk.enforcer.tracing.TracingConstants; import org.wso2.apk.enforcer.tracing.TracingSpan; import org.wso2.apk.enforcer.tracing.TracingTracer; @@ -472,8 +473,14 @@ private JWTValidationInfo getJwtValidationInfo(String jwtToken, String organizat try { // Get issuer String issuer = jwtClaimsSet.getIssuer(); - JWTValidator jwtValidator = SubscriptionDataHolder.getInstance().getSubscriptionDataStore(organization) - .getJWTValidatorByIssuer(issuer, environment); + SubscriptionDataStore subscriptionDataStore = SubscriptionDataHolder.getInstance() + .getSubscriptionDataStore(organization); + if (subscriptionDataStore == null) { + throw new APISecurityException(APIConstants.StatusCodes.UNAUTHENTICATED.getCode(), + APISecurityConstants.API_AUTH_INVALID_CREDENTIALS, + APISecurityConstants.API_AUTH_INVALID_CREDENTIALS_MESSAGE); + } + JWTValidator jwtValidator = subscriptionDataStore.getJWTValidatorByIssuer(issuer, environment); // If no validator found for the issuer, we are not caching the token. if (jwtValidator == null) { throw new APISecurityException(APIConstants.StatusCodes.UNAUTHENTICATED.getCode(), diff --git a/helm-charts/README.md b/helm-charts/README.md index 48782f80e..f37f76c89 100644 --- a/helm-charts/README.md +++ b/helm-charts/README.md @@ -168,6 +168,7 @@ A Helm chart for APK components | wso2.apk.dp.gatewayRuntime.deployment.router.configs.upstream.tls.disableSslVerification | bool | `false` | Disable SSL verification | | wso2.apk.dp.gatewayRuntime.deployment.router.configs.upstream.dns.dnsRefreshRate | int | `5000` | DNS refresh rate in miliseconds | | wso2.apk.dp.gatewayRuntime.deployment.router.configs.upstream.dns.respectDNSTtl | bool | `false` | set cluster’s DNS refresh rate to resource record’s TTL which comes from DNS resolution | +| wso2.apk.dp.gatewayRuntime.deployment.router.configs.enableIntelligentRouting | bool | `false` | Enable/Disable Semantic Versioning based Intelligent Routing | | wso2.apk.dp.gatewayRuntime.deployment.router.logging.wireLogs | object | `{"enable":true}` | Optionally configure logging for router. | | wso2.apk.dp.gatewayRuntime.deployment.router.logging.wireLogs.enable | bool | `true` | Enable wire logs for router. | | wso2.apk.dp.gatewayRuntime.deployment.router.logging.accessLogs.enable | bool | `true` | Enable access logs for router. | diff --git a/helm-charts/templates/data-plane/gateway-components/log-conf.yaml b/helm-charts/templates/data-plane/gateway-components/log-conf.yaml index eacbe2d7f..7da8ba20d 100644 --- a/helm-charts/templates/data-plane/gateway-components/log-conf.yaml +++ b/helm-charts/templates/data-plane/gateway-components/log-conf.yaml @@ -26,6 +26,9 @@ data: {{ if .Values.wso2.apk.dp.gatewayRuntime.deployment.router.configs.enforcerResponseTimeoutInSeconds }} enforcerResponseTimeoutInSeconds = {{ .Values.wso2.apk.dp.gatewayRuntime.deployment.router.configs.enforcerResponseTimeoutInSeconds }} {{end}} + {{ if .Values.wso2.apk.dp.gatewayRuntime.deployment.router.configs.enableIntelligentRouting }} + enableIntelligentRouting = {{ .Values.wso2.apk.dp.gatewayRuntime.deployment.router.configs.enableIntelligentRouting }} + {{ end }} {{ if .Values.wso2.apk.dp.gatewayRuntime.deployment.router.configs.upstream }} {{ if .Values.wso2.apk.dp.gatewayRuntime.deployment.router.configs.upstream.tls }} diff --git a/helm-charts/values.yaml.template b/helm-charts/values.yaml.template index 0a80f6ee3..0c5d8331f 100644 --- a/helm-charts/values.yaml.template +++ b/helm-charts/values.yaml.template @@ -400,6 +400,8 @@ wso2: useRemoteAddress: false # -- System hostname for system API resources (eg: /testkey and /health) systemHost: "localhost" + # -- Enable Semantic Versioning based Intelligent Routing for Gateway + enableIntelligentRouting: false tls: # -- TLS secret name for router public certificate. secretName: "router-cert" diff --git a/test/cucumber-tests/CRs/artifacts.yaml b/test/cucumber-tests/CRs/artifacts.yaml index a9ae0b918..2da9444f3 100644 --- a/test/cucumber-tests/CRs/artifacts.yaml +++ b/test/cucumber-tests/CRs/artifacts.yaml @@ -491,6 +491,61 @@ data: "body": "{\n \"keys\":[\n {\n \"kty\":\"RSA\",\n \"n\":\"m0YNpM5MVYToWZMZ9wL4KQOygvG0f6y0dw4wZ02T4C3SxiC1zEBCZLh2clj7bncyA3EV2bFrTIBNeq-1pFEfbNDMZB88Jcg0S9QyYujr6GM0AqLA7WjZQ6lLxLpeQdEQroEZI-c8rnGmzU8Qb25aiPbRf6Vh7vFYGQz5FnZ8E0LcEMYQ-4KPMkAqnMon1UKWDkqszTY5a-DGMAi5w7imKzXaU4qiEKVKIcezv9nLUVC5Od0T4FkUQi462ZA9SoHx1HNhcVAj8Nf9TG_C65GbsMMFJVcRXwZR99cVzVxVqEtxGlK7Qr0woYKQ3S5kHZPRFcMFXI6WHhEQXqyOMBdUfQ\",\n \"e\":\"AQAB\",\n \"alg\":\"RS256\",\n \"kid\":\"123-456\",\n \"use\":\"sig\"\n }\n ]\n}" } } + sem-versioning.json: | + {"mappings": [ + { + "request": { + "method": "GET", + "url": "/sem-api/v1.0/employee" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\n \"version\":\"v1.0\" \n}" + } + }, + { + "request": { + "method": "GET", + "url": "/sem-api/v1.1/employee" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\n \"version\":\"v1.1\" \n}" + } + }, + { + "request": { + "method": "GET", + "url": "/sem-api/v1.5/employee" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\n \"version\":\"v1.5\" \n}" + } + }, + { + "request": { + "method": "GET", + "url": "/sem-api/v2.1/employee" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\n \"version\":\"v2.1\" \n}" + } + } + ]} --- kind: TokenIssuer apiVersion: dp.wso2.com/v1alpha1 @@ -818,3 +873,24 @@ data: y5Oi4A4+id+xO0XnHIkkqCfPtFzxl3hwytcy8EqISynzzHWNJ8bFZIYX4tgX+PLq u0/ITEw= -----END CERTIFICATE----- +--- +apiVersion: cp.wso2.com/v1alpha2 +kind: Subscription +metadata: + name: semantic-versioning-subscription + namespace: apk-integration-test +spec: + organization: "default" + subscriptionStatus: "ACTIVE" + api: + name: "Semantic Versioning API" + version: "v\\d+(\\.\\d+)?" +--- +apiVersion: cp.wso2.com/v1alpha2 +kind: ApplicationMapping +metadata: + name: semantic-versioning-app-mapping + namespace: apk-integration-test +spec: + applicationRef: 583e4146-7ef5-11ee-b962-0242ac120003 + subscriptionRef: semantic-versioning-subscription diff --git a/test/cucumber-tests/src/test/resources/artifacts/apk-confs/semantic-versioning/sem_api_v1-0.yaml b/test/cucumber-tests/src/test/resources/artifacts/apk-confs/semantic-versioning/sem_api_v1-0.yaml new file mode 100644 index 000000000..2933aae06 --- /dev/null +++ b/test/cucumber-tests/src/test/resources/artifacts/apk-confs/semantic-versioning/sem_api_v1-0.yaml @@ -0,0 +1,28 @@ +--- +name: "Semantic Versioning API" +basePath: "/sem-api" +version: "v1.0" +id: "sem-api-v1-0" +type: "REST" +defaultVersion: false +subscriptionValidation: true +endpointConfigurations: + production: + endpoint: "http://dynamic-backend-service:8080/sem-api/v1.0" +operations: + - target: "/employee" + verb: "GET" + secured: true + scopes: [] + - target: "/employee" + verb: "POST" + secured: true + scopes: [] + - target: "/employee/{employeeId}" + verb: "PUT" + secured: true + scopes: [] + - target: "/employee/{employeeId}" + verb: "DELETE" + secured: true + scopes: [] diff --git a/test/cucumber-tests/src/test/resources/artifacts/apk-confs/semantic-versioning/sem_api_v1-1.yaml b/test/cucumber-tests/src/test/resources/artifacts/apk-confs/semantic-versioning/sem_api_v1-1.yaml new file mode 100644 index 000000000..79a65908b --- /dev/null +++ b/test/cucumber-tests/src/test/resources/artifacts/apk-confs/semantic-versioning/sem_api_v1-1.yaml @@ -0,0 +1,28 @@ +--- +name: "Semantic Versioning API" +basePath: "/sem-api" +version: "v1.1" +id: "sem-api-v1-1" +type: "REST" +defaultVersion: false +subscriptionValidation: true +endpointConfigurations: + production: + endpoint: "http://dynamic-backend-service:8080/sem-api/v1.1" +operations: + - target: "/employee" + verb: "GET" + secured: true + scopes: [] + - target: "/employee" + verb: "POST" + secured: true + scopes: [] + - target: "/employee/{employeeId}" + verb: "PUT" + secured: true + scopes: [] + - target: "/employee/{employeeId}" + verb: "DELETE" + secured: true + scopes: [] diff --git a/test/cucumber-tests/src/test/resources/artifacts/apk-confs/semantic-versioning/sem_api_v1-5.yaml b/test/cucumber-tests/src/test/resources/artifacts/apk-confs/semantic-versioning/sem_api_v1-5.yaml new file mode 100644 index 000000000..ba9650c17 --- /dev/null +++ b/test/cucumber-tests/src/test/resources/artifacts/apk-confs/semantic-versioning/sem_api_v1-5.yaml @@ -0,0 +1,28 @@ +--- +name: "Semantic Versioning API" +basePath: "/sem-api" +version: "v1.5" +id: "sem-api-v1-5" +type: "REST" +defaultVersion: false +subscriptionValidation: true +endpointConfigurations: + production: + endpoint: "http://dynamic-backend-service:8080/sem-api/v1.5" +operations: + - target: "/employee" + verb: "GET" + secured: true + scopes: [] + - target: "/employee" + verb: "POST" + secured: true + scopes: [] + - target: "/employee/{employeeId}" + verb: "PUT" + secured: true + scopes: [] + - target: "/employee/{employeeId}" + verb: "DELETE" + secured: true + scopes: [] diff --git a/test/cucumber-tests/src/test/resources/artifacts/apk-confs/semantic-versioning/sem_api_v2-1.yaml b/test/cucumber-tests/src/test/resources/artifacts/apk-confs/semantic-versioning/sem_api_v2-1.yaml new file mode 100644 index 000000000..f02706214 --- /dev/null +++ b/test/cucumber-tests/src/test/resources/artifacts/apk-confs/semantic-versioning/sem_api_v2-1.yaml @@ -0,0 +1,27 @@ +--- +name: "Semantic Versioning API" +basePath: "/sem-api" +version: "v2.1" +id: "sem-api-v2-1" +type: "REST" +defaultVersion: false +endpointConfigurations: + production: + endpoint: "http://dynamic-backend-service:8080/sem-api/v2.1" +operations: + - target: "/employee" + verb: "GET" + secured: true + scopes: [] + - target: "/employee" + verb: "POST" + secured: true + scopes: [] + - target: "/employee/{employeeId}" + verb: "PUT" + secured: true + scopes: [] + - target: "/employee/{employeeId}" + verb: "DELETE" + secured: true + scopes: [] diff --git a/test/cucumber-tests/src/test/resources/tests/api/SemanticVersioning.feature b/test/cucumber-tests/src/test/resources/tests/api/SemanticVersioning.feature new file mode 100644 index 000000000..6aa4ca1d2 --- /dev/null +++ b/test/cucumber-tests/src/test/resources/tests/api/SemanticVersioning.feature @@ -0,0 +1,118 @@ +Feature: Semantic Versioning Based Intelligent Routing + + Scenario: API version with Major and Minor + Given The system is ready + And I have a valid subscription + When I use the APK Conf file "artifacts/apk-confs/semantic-versioning/sem_api_v1-0.yaml" + And the definition file "artifacts/definitions/employees_api.json" + And make the API deployment request + Then the response status code should be 200 + Then I generate JWT token from idp1 with kid "123-456" and consumer_key "45f1c5c8-a92e-11ed-afa1-0242ac120005" + Then I set headers + |Authorization|bearer ${idp-1-45f1c5c8-a92e-11ed-afa1-0242ac120005-token}| + + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v1.0/employee/" with body "" + Then the response status code should be 200 + And the response body should contain "\"version\":\"v1.0\"" + + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v1/employee/" with body "" + Then the response status code should be 200 + And the response body should contain "\"version\":\"v1.0\"" + + When I use the APK Conf file "artifacts/apk-confs/semantic-versioning/sem_api_v1-1.yaml" + And the definition file "artifacts/definitions/employees_api.json" + And make the API deployment request + Then the response status code should be 200 + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v1.1/employee/" with body "" + Then the response status code should be 200 + And the response body should contain "\"version\":\"v1.1\"" + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v1/employee/" with body "" + Then the response status code should be 200 + And the response body should contain "\"version\":\"v1.1\"" + + When I use the APK Conf file "artifacts/apk-confs/semantic-versioning/sem_api_v1-5.yaml" + And the definition file "artifacts/definitions/employees_api.json" + And make the API deployment request + Then the response status code should be 200 + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v1.5/employee/" with body "" + Then the response status code should be 200 + And the response body should contain "\"version\":\"v1.5\"" + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v1/employee/" with body "" + Then the response status code should be 200 + And the response body should contain "\"version\":\"v1.5\"" + + When I undeploy the API whose ID is "sem-api-v1-5" + Then the response status code should be 202 + And I wait for 2 seconds + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v1/employee/" with body "" + And the response body should contain "\"version\":\"v1.1\"" + + When I undeploy the API whose ID is "sem-api-v1-1" + Then the response status code should be 202 + And I wait for 2 seconds + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v1/employee/" with body "" + And the response body should contain "\"version\":\"v1.0\"" + + When I undeploy the API whose ID is "sem-api-v1-0" + Then the response status code should be 202 + + Scenario: Multiple Major and minor versions for an API + Given The system is ready + And I have a valid subscription + When I use the APK Conf file "artifacts/apk-confs/semantic-versioning/sem_api_v1-0.yaml" + And the definition file "artifacts/definitions/employees_api.json" + And make the API deployment request + Then the response status code should be 200 + When I use the APK Conf file "artifacts/apk-confs/semantic-versioning/sem_api_v1-1.yaml" + And the definition file "artifacts/definitions/employees_api.json" + And make the API deployment request + Then the response status code should be 200 + When I use the APK Conf file "artifacts/apk-confs/semantic-versioning/sem_api_v1-5.yaml" + And the definition file "artifacts/definitions/employees_api.json" + And make the API deployment request + Then the response status code should be 200 + Then I generate JWT token from idp1 with kid "123-456" and consumer_key "45f1c5c8-a92e-11ed-afa1-0242ac120005" + Then I set headers + |Authorization|bearer ${idp-1-45f1c5c8-a92e-11ed-afa1-0242ac120005-token}| + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v1/employee/" with body "" + Then the response status code should be 200 + And the response body should contain "\"version\":\"v1.5\"" + + When I undeploy the API whose ID is "sem-api-v1-1" + Then the response status code should be 202 + And I wait for 2 seconds + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v1/employee/" with body "" + Then the response status code should be 200 + And the response body should contain "\"version\":\"v1.5\"" + + When I use the APK Conf file "artifacts/apk-confs/semantic-versioning/sem_api_v2-1.yaml" + And the definition file "artifacts/definitions/employees_api.json" + And make the API deployment request + Then the response status code should be 200 + + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v2/employee/" with body "" + Then the response status code should be 200 + And the response body should contain "\"version\":\"v2.1\"" + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v2.1/employee/" with body "" + Then the response status code should be 200 + And the response body should contain "\"version\":\"v2.1\"" + + When I undeploy the API whose ID is "sem-api-v1-0" + Then the response status code should be 202 + And I wait for 2 seconds + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v1/employee/" with body "" + Then the response status code should be 200 + And the response body should contain "\"version\":\"v1.5\"" + + When I undeploy the API whose ID is "sem-api-v1-5" + Then the response status code should be 202 + And I wait for 2 seconds + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v1/employee/" with body "" + Then the response status code should be 404 + + And I send "GET" request to "https://default.gw.wso2.com:9095/sem-api/v2/employee/" with body "" + Then the response status code should be 200 + And the response body should contain "\"version\":\"v2.1\"" + + When I undeploy the API whose ID is "sem-api-v2-1" + Then the response status code should be 202