diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 14d6672469..18c0532b06 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -63,7 +63,7 @@ jobs: APIML_SECURITY_AUTH_JWT_CUSTOMAUTHHEADER: customJwtHeader APIML_SECURITY_AUTH_PASSTICKET_CUSTOMUSERHEADER: customUserHeader APIML_SECURITY_AUTH_PASSTICKET_CUSTOMAUTHHEADER: customPassticketHeader - APIML_SERVICE_CENTRALREGISTRYURLS: https://discovery-service-2:10011/eureka + ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_DISCOVERYSERVICEURLS: https://discovery-service-2:10011/eureka mock-services: image: ghcr.io/balhar-jakub/mock-services:${{ github.run_id }}-${{ github.run_number }} metrics-service: @@ -254,10 +254,6 @@ jobs: APIML_SERVICE_APIMLID: central-apiml APIML_CLOUDGATEWAY_REGISTRY_ENABLED: true APIML_SECURITY_X509_REGISTRY_ALLOWEDUSERS: USER,UNKNOWNUSER - discoverable-client: - image: ghcr.io/balhar-jakub/discoverable-client:${{ github.run_id }}-${{ github.run_number }} - mock-services: - image: ghcr.io/balhar-jakub/mock-services:${{ github.run_id }}-${{ github.run_number }} # Second group of services represents domain apiml instance which registers it's gateway in central's discovery service discovery-service-2: @@ -274,7 +270,9 @@ jobs: APIML_SERVICE_HOSTNAME: gateway-service-2 APIML_SERVICE_PORT: 10037 APIML_SERVICE_DISCOVERYSERVICEURLS: https://discovery-service-2:10031/eureka/ - APIML_SERVICE_CENTRALREGISTRYURLS: https://discovery-service:10011/eureka + ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_DISCOVERYSERVICEURLS: https://discovery-service:10011/eureka + ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_GATEWAYURL: / + ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_SERVICEURL: / steps: - uses: actions/checkout@v3 diff --git a/cloud-gateway-service/src/main/resources/application.yml b/cloud-gateway-service/src/main/resources/application.yml index 9a1d353e2d..63a8db7790 100644 --- a/cloud-gateway-service/src/main/resources/application.yml +++ b/cloud-gateway-service/src/main/resources/application.yml @@ -24,7 +24,7 @@ apiml: nonStrictVerifySslCertificatesOfServices: true cloudGateway: registry: - enabled: false + enabled: true metadata-key-allow-list: zos.sysname,zos.system,zos.sysplex,zos.cpcName,zos.zosName,zos.lpar server: @@ -62,10 +62,19 @@ logging: management: endpoint: gateway: - enabled: true + enabled: false endpoints: web: base-path: /application exposure: include: health,gateway +--- +spring: + config: + activate: + on-profile: debug + +logging: + level: + org.zowe.apiml: DEBUG diff --git a/cloud-gateway-service/src/main/resources/logback.xml b/cloud-gateway-service/src/main/resources/logback.xml index 999077ccc1..e7f7740af5 100644 --- a/cloud-gateway-service/src/main/resources/logback.xml +++ b/cloud-gateway-service/src/main/resources/logback.xml @@ -48,4 +48,4 @@ - \ No newline at end of file + diff --git a/config/local/gateway-service.yml b/config/local/gateway-service.yml index e2c4348c08..43dab9a4e4 100644 --- a/config/local/gateway-service.yml +++ b/config/local/gateway-service.yml @@ -5,7 +5,7 @@ apiml: hostname: localhost ipAddress: 127.0.0.1 port: 10010 - centralRegistryUrls: https://localhost:10021/eureka/,https://localhost:10031/eureka/ + additionalRegistration: # List of additional Apiml Discovery Services metadata to register with discoveryServiceUrls: https://localhost:10011/eureka/ security: diff --git a/gateway-package/src/main/resources/bin/start.sh b/gateway-package/src/main/resources/bin/start.sh index 9e6cf90389..d6951c8a72 100755 --- a/gateway-package/src/main/resources/bin/start.sh +++ b/gateway-package/src/main/resources/bin/start.sh @@ -54,7 +54,6 @@ # - ZWE_configs_apiml_security_oidc_identityMapperUrl # - ZWE_configs_apiml_security_oidc_identityMapperUser # - ZWE_configs_apiml_service_allowEncodedSlashes - Allows encoded slashes on on URLs through gateway -# - ZWE_configs_apiml_service_centralRegistryUrls - List of additional Discovery Services URLs to register with # - ZWE_configs_apiml_service_corsEnabled # - ZWE_configs_certificate_keystore_alias - The alias of the key within the keystore # - ZWE_configs_certificate_keystore_file - The keystore to use for SSL certificates @@ -210,9 +209,7 @@ _BPX_JOBNAME=${ZWE_zowe_job_prefix}${GATEWAY_CODE} java \ -Dapiml.service.hostname=${ZWE_haInstance_hostname:-localhost} \ -Dapiml.service.port=${ZWE_configs_port:-7554} \ -Dapiml.service.discoveryServiceUrls=${ZWE_DISCOVERY_SERVICES_LIST:-"https://${ZWE_haInstance_hostname:-localhost}:${ZWE_components_discovery_port:-7553}/eureka/"} \ - -Dapiml.service.centralRegistryUrls=${ZWE_configs_apiml_service_centralRegistryUrls:-} \ -Dapiml.service.allowEncodedSlashes=${ZWE_configs_apiml_service_allowEncodedSlashes:-true} \ - -Dapiml.service.centralRegistryUrls=${ZWE_configs_apiml_service_centralRegistryUrls:-} \ -Dapiml.service.corsEnabled=${ZWE_configs_apiml_service_corsEnabled:-false} \ -Dapiml.service.externalUrl="${httpProtocol}://${ZWE_zowe_externalDomains_0}:${ZWE_zowe_externalPort}" \ -Dapiml.service.apimlId=${ZWE_configs_apimlId:-} \ diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/AdditionalRegistration.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/AdditionalRegistration.java new file mode 100644 index 0000000000..7e87b46f9c --- /dev/null +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/AdditionalRegistration.java @@ -0,0 +1,36 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.config; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class AdditionalRegistration { + + private String discoveryServiceUrls; + private List routes; + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class Route { + private String gatewayUrl; + private String serviceUrl; + } +} diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/AdditionalRegistrationCondition.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/AdditionalRegistrationCondition.java new file mode 100644 index 0000000000..99acf46e23 --- /dev/null +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/AdditionalRegistrationCondition.java @@ -0,0 +1,37 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.web.context.support.StandardServletEnvironment; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +public class AdditionalRegistrationCondition implements Condition { + + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + String dcUrls = context.getEnvironment().getProperty("apiml.service.additionalRegistration[0].discoveryServiceUrls"); + List additionalKeys = ((StandardServletEnvironment) context.getEnvironment()).getSystemEnvironment() + .entrySet().stream().map(e -> e.getKey().toUpperCase()).filter(key -> key.startsWith(AdditionalRegistrationConfig.COMMON_PREFIX)) + .collect(Collectors.toList()); + boolean isAdditionalRegistrationsDetected = dcUrls != null || !additionalKeys.isEmpty(); + log.debug("isAdditionalRegistrationsDetected: {}", isAdditionalRegistrationsDetected); + return isAdditionalRegistrationsDetected; + } +} diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/AdditionalRegistrationConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/AdditionalRegistrationConfig.java new file mode 100644 index 0000000000..69446fe81c --- /dev/null +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/AdditionalRegistrationConfig.java @@ -0,0 +1,136 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.util.CollectionUtils; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class AdditionalRegistrationConfig { + + public static final String DISCOVERY_SERVICE_URLS_KEY = "DISCOVERYSERVICEURLS"; + public static final String GATEWAY_URL_KEY = "GATEWAYURL"; + public static final String COMMON_PREFIX = "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_"; + public static final String SERVICE_URL_KEY = "SERVICEURL"; + private static final int EXPECTED_ROUTE_PART_INDEX = 2; + + @Bean + public List additionalRegistration(StandardEnvironment environment) { + List additionalRegistrations = extractAdditionalRegistrations(System.getenv()); + log.debug("Parsed {} additional regs, \t first: {}", additionalRegistrations.size(), additionalRegistrations.stream().findFirst().orElse(null)); + return additionalRegistrations; + } + + static List extractAdditionalRegistrations(Map allProperties) { + + if (CollectionUtils.isEmpty(allProperties)) { + return Collections.emptyList(); + } + + Map additionalProperties = allProperties.entrySet().stream().filter(entry -> entry.getKey().startsWith(COMMON_PREFIX)) + .filter(entry -> StringUtils.isNotBlank(entry.getValue())) + .map(entry -> new AbstractMap.SimpleEntry<>(entry.getKey().replace(COMMON_PREFIX, ""), entry.getValue())) + .collect(Collectors.toMap(AbstractMap.SimpleEntry::getKey, AbstractMap.SimpleEntry::getValue, (previous, current) -> current, TreeMap::new)); + + for (Map.Entry prp : additionalProperties.entrySet()) { + log.debug("Additional property: {}={}", prp.getKey(), prp.getValue()); + } + + Integer listSize = additionalProperties.keySet().stream().map(key -> parsePropertyName(key).getKey()) + .max(Integer::compareTo).map(maxIndex -> maxIndex + 1).orElse(0); + + List additionalRegistrations = IntStream.range(0, listSize) + .mapToObj(index -> AdditionalRegistration.builder().routes(new ArrayList<>()).build()) + .collect(Collectors.toList()); + + mapProperties(additionalRegistrations, additionalProperties); + for (AdditionalRegistration registration : additionalRegistrations) { + List definedRoutes = registration.getRoutes().stream().filter(route -> !AdditionalRegistrationConfig.isRouteDefined(route)).collect(Collectors.toList()); + registration.setRoutes(definedRoutes); + } + + return additionalRegistrations.stream() + .filter(registration -> StringUtils.isNotBlank(registration.getDiscoveryServiceUrls())) + .collect(Collectors.toList()); + } + + private static void mapProperties(List additionalRegistrations, Map properties) { + for (Map.Entry entry : properties.entrySet()) { + String propertyKey = entry.getKey(); + Pair property = parsePropertyName(propertyKey); + if (property.getKey() == -1) { + continue; + } + final String propertyValue = entry.getValue(); + switch (StringUtils.upperCase(property.getValue())) { + case DISCOVERY_SERVICE_URLS_KEY: + additionalRegistrations.get(property.getKey()).setDiscoveryServiceUrls(propertyValue); + break; + case GATEWAY_URL_KEY: + setRouteProperty(additionalRegistrations.get(property.getKey()), parseRouteIndex(propertyKey), (AdditionalRegistration.Route route) -> route.setGatewayUrl(propertyValue)); + break; + case SERVICE_URL_KEY: + setRouteProperty(additionalRegistrations.get(property.getKey()), parseRouteIndex(propertyKey), (AdditionalRegistration.Route route) -> route.setServiceUrl(propertyValue)); + break; + default: + break; + } + } + } + + private static void setRouteProperty(AdditionalRegistration registration, int routeIndex, Consumer setter) { + if (routeIndex > -1) { + if (registration.getRoutes().size() <= routeIndex) { + registration.getRoutes().add(new AdditionalRegistration.Route()); + setRouteProperty(registration, routeIndex, setter); + } + setter.accept(registration.getRoutes().get(routeIndex)); + } + } + + private static int parseRouteIndex(String propertyName) { + String[] parts = StringUtils.split(propertyName, "_."); + if (parts.length > EXPECTED_ROUTE_PART_INDEX && StringUtils.isNumeric(parts[EXPECTED_ROUTE_PART_INDEX])) { + return Integer.parseInt(parts[EXPECTED_ROUTE_PART_INDEX]); + } + return -1; + } + + private static Pair parsePropertyName(String fullPropertyName) { + String[] parts = StringUtils.split(fullPropertyName, "_"); + if (StringUtils.isNumeric(parts[0])) { + return Pair.of(Integer.parseInt(parts[0]), parts[parts.length - 1]); + } + return Pair.of(-1, null); + } + + private static boolean isRouteDefined(AdditionalRegistration.Route route) { + return route == null || StringUtils.isBlank(route.getGatewayUrl()) && StringUtils.isBlank(route.getServiceUrl()); + } +} diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/DiscoveryClientConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/DiscoveryClientConfig.java index c845e68824..c8cdaba922 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/DiscoveryClientConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/DiscoveryClientConfig.java @@ -12,27 +12,36 @@ import com.netflix.appinfo.ApplicationInfoManager; import com.netflix.appinfo.HealthCheckHandler; +import com.netflix.appinfo.InstanceInfo; import com.netflix.discovery.AbstractDiscoveryClientOptionalArgs; import com.netflix.discovery.EurekaClientConfig; import com.netflix.discovery.shared.transport.jersey.EurekaJerseyClientImpl; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.cloud.netflix.eureka.EurekaClientConfigBean; import org.springframework.cloud.netflix.eureka.MutableDiscoveryClientOptionalArgs; import org.springframework.cloud.util.ProxyUtils; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; +import org.springframework.util.CollectionUtils; import org.zowe.apiml.gateway.discovery.ApimlDiscoveryClient; +import org.zowe.apiml.gateway.discovery.ApimlDiscoveryClientFactory; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; + +import static org.springframework.cloud.netflix.eureka.EurekaClientConfigBean.DEFAULT_ZONE; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.ROUTES; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.ROUTES_GATEWAY_URL; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.ROUTES_SERVICE_URL; /** * This configuration override bean EurekaClient with custom ApimlDiscoveryClient. This bean offer additional method @@ -42,16 +51,15 @@ * Configuration also add listeners to call other beans waiting for fetch new registry. It speeds up distribution of * changes in whole gateway. */ +@Slf4j @Configuration @RequiredArgsConstructor public class DiscoveryClientConfig { - private final ApplicationContext context; private final AbstractDiscoveryClientOptionalArgs optionalArgs; + private final ApimlDiscoveryClientFactory apimlDiscoveryClientFactory; + private final ApplicationContext context; private final EurekaJerseyClientImpl.EurekaJerseyClientBuilder eurekaJerseyClientBuilder; - @Value("${apiml.service.centralRegistryUrls:-}") - private String[] centralRegistryUrls; - @Bean(destroyMethod = "shutdown") @RefreshScope public ApimlDiscoveryClient primaryApimlEurekaClient(ApplicationInfoManager manager, @@ -67,33 +75,60 @@ public ApimlDiscoveryClient primaryApimlEurekaClient(ApplicationInfoManager mana } @Bean(destroyMethod = "shutdown") - @ConditionalOnProperty(name = "apiml.service.centralRegistryUrls") + @Conditional({AdditionalRegistrationCondition.class}) @RefreshScope public DiscoveryClientWrapper additionalDiscoveryClientWrapper(ApplicationInfoManager manager, EurekaClientConfig config, - @Autowired(required = false) HealthCheckHandler healthCheckHandler + @Autowired(required = false) HealthCheckHandler healthCheckHandler, + List additionalRegistrations ) { - ApplicationInfoManager appManager = ProxyUtils.getTargetObject(manager); - List discoveryClientsList = new ArrayList<>(); - for (String url : centralRegistryUrls) { + List discoveryClientsList = new ArrayList<>(additionalRegistrations.size()); + for (AdditionalRegistration apimlRegistration : additionalRegistrations) { + ApimlDiscoveryClient additionalApimlRegistration = registerInTheApimlInstance(config, healthCheckHandler, apimlRegistration, manager); + discoveryClientsList.add(additionalApimlRegistration); + } - EurekaClientConfigBean configBean = new EurekaClientConfigBean(); - BeanUtils.copyProperties(config, configBean); + return new DiscoveryClientWrapper(discoveryClientsList); + } - Map urls = new HashMap<>(); - urls.put("defaultZone", url); + private ApimlDiscoveryClient registerInTheApimlInstance(EurekaClientConfig config, HealthCheckHandler healthCheckHandler, AdditionalRegistration apimlRegistration, ApplicationInfoManager appManager) { - configBean.setServiceUrl(urls); + EurekaClientConfigBean configBean = new EurekaClientConfigBean(); + BeanUtils.copyProperties(config, configBean); - MutableDiscoveryClientOptionalArgs args = new MutableDiscoveryClientOptionalArgs(); - args.setEurekaJerseyClient(eurekaJerseyClientBuilder.build()); + Map urls = new HashMap<>(); + log.debug("additional registration: {}", apimlRegistration.getDiscoveryServiceUrls()); + urls.put(DEFAULT_ZONE, apimlRegistration.getDiscoveryServiceUrls()); - final ApimlDiscoveryClient discoveryClientClient = new ApimlDiscoveryClient(appManager, configBean, args, this.context); - discoveryClientClient.registerHealthCheck(healthCheckHandler); - discoveryClientsList.add(discoveryClientClient); - } + configBean.setServiceUrl(urls); - return new DiscoveryClientWrapper(discoveryClientsList); + MutableDiscoveryClientOptionalArgs args = new MutableDiscoveryClientOptionalArgs(); + args.setEurekaJerseyClient(eurekaJerseyClientBuilder.build()); + + ApplicationInfoManager bareManager = ProxyUtils.getTargetObject(appManager); + InstanceInfo ii = getInstanceInfo(apimlRegistration, bareManager); + + ApplicationInfoManager perClientAppManager = new ApplicationInfoManager(bareManager.getEurekaInstanceConfig(), ii, null); + final ApimlDiscoveryClient discoveryClientClient = apimlDiscoveryClientFactory.buildApimlDiscoveryClient(perClientAppManager, configBean, args, context); + discoveryClientClient.registerHealthCheck(healthCheckHandler); + + return discoveryClientClient; + } + + private static InstanceInfo getInstanceInfo(AdditionalRegistration apimlRegistration, ApplicationInfoManager bareManager) { + if (!CollectionUtils.isEmpty(apimlRegistration.getRoutes())) { + Map metadataWithRoutes = bareManager.getInfo().getMetadata().entrySet().stream().filter(entry -> !entry.getKey().startsWith(ROUTES)).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + int index = 0; + for (AdditionalRegistration.Route route : apimlRegistration.getRoutes()) { + metadataWithRoutes.put(ROUTES + "." + index + "." + ROUTES_GATEWAY_URL, route.getGatewayUrl()); + metadataWithRoutes.put(ROUTES + "." + index + "." + ROUTES_SERVICE_URL, route.getServiceUrl()); + index++; + } + InstanceInfo.Builder builder = new InstanceInfo.Builder(bareManager.getInfo()); + builder.setMetadata(metadataWithRoutes); + return builder.build(); + } + return bareManager.getInfo(); } } diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/discovery/ApimlDiscoveryClientFactory.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/discovery/ApimlDiscoveryClientFactory.java new file mode 100644 index 0000000000..c854eb3aed --- /dev/null +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/discovery/ApimlDiscoveryClientFactory.java @@ -0,0 +1,28 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.discovery; + +import com.netflix.appinfo.ApplicationInfoManager; +import org.springframework.cloud.netflix.eureka.EurekaClientConfigBean; +import org.springframework.cloud.netflix.eureka.MutableDiscoveryClientOptionalArgs; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +/** + * + */ +@Component +public class ApimlDiscoveryClientFactory { + + public ApimlDiscoveryClient buildApimlDiscoveryClient(ApplicationInfoManager perClientAppManager, EurekaClientConfigBean configBean, MutableDiscoveryClientOptionalArgs args, ApplicationContext context) { + return new ApimlDiscoveryClient(perClientAppManager, configBean, args, context); + } +} diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index eb1b64dea3..87dd701be1 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -43,7 +43,9 @@ apiml: scheme: https # "https" or "http" preferIpAddress: false ignoredHeadersWhenCorsEnabled: Access-Control-Request-Method,Access-Control-Request-Headers,Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers,Access-Control-Allow-Credentials,Origin - centralRegistryUrls: # List of additional Discovery Services URLs to register with + + + additionalRegistration: # List of additional Apiml Discovery Services metadata to register with httpclient: conn-pool: diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/AdditionalRegistrationConditionTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/AdditionalRegistrationConditionTest.java new file mode 100644 index 0000000000..2b7361682f --- /dev/null +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/AdditionalRegistrationConditionTest.java @@ -0,0 +1,59 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.web.context.support.StandardServletEnvironment; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AdditionalRegistrationConditionTest { + + @Test + void givenEnvironmentVariables_thenReturnTrue() { + AdditionalRegistrationCondition crc = new AdditionalRegistrationCondition(); + ConditionContext context = mock(ConditionContext.class); + StandardServletEnvironment env = mock(StandardServletEnvironment.class); + Map systemEnv = new HashMap<>(); + systemEnv.put("ZWE_configs_apiml_service_additionalRegistration_0_DISCOVERYSERVICEURLS", "https://localhost:123,https://localhostr:345"); + systemEnv.put("ZWE_configs_apiml_service_additionalRegistration_1_DISCOVERYSERVICEURLS", "https://localhost:555,https://localhostr:666"); + when(env.getSystemEnvironment()).thenReturn(systemEnv); + when(context.getEnvironment()).thenReturn(env); + assertTrue(crc.matches(context, null)); + } + + @Test + void givenPropertyVariables_thenReturnTrue() { + AdditionalRegistrationCondition crc = new AdditionalRegistrationCondition(); + ConditionContext context = mock(ConditionContext.class); + StandardServletEnvironment env = mock(StandardServletEnvironment.class); + when(env.getProperty("apiml.service.additionalRegistration[0].discoveryServiceUrls")).thenReturn("https://localhost:123,https://localhostr:345"); + when(context.getEnvironment()).thenReturn(env); + assertTrue(crc.matches(context, null)); + } + + @Test + void givenMissingConfiguration_thenReturnFalse() { + AdditionalRegistrationCondition crc = new AdditionalRegistrationCondition(); + ConditionContext context = mock(ConditionContext.class); + StandardServletEnvironment env = mock(StandardServletEnvironment.class); + when(context.getEnvironment()).thenReturn(env); + assertFalse(crc.matches(context, null)); + } + +} diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/AdditionalRegistrationConfigTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/AdditionalRegistrationConfigTest.java new file mode 100644 index 0000000000..fe1df3ca08 --- /dev/null +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/AdditionalRegistrationConfigTest.java @@ -0,0 +1,141 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.config; + +import org.apache.groovy.util.Maps; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; + + +class AdditionalRegistrationConfigTest { + @Nested + class GivenInvalidEnvironmentPropertiesAreProvided { + @Test + void shouldParseEmptyListFromNullMap() { + List registrations = AdditionalRegistrationConfig.extractAdditionalRegistrations(null); + assertThat(registrations).isEmpty(); + } + + @Test + void shouldParseEmptyListFromIrrelevantMap() { + List registrations = AdditionalRegistrationConfig.extractAdditionalRegistrations(Maps.of("someKey", "someValue")); + assertThat(registrations).isEmpty(); + } + + @Test + void shouldParseEmptyListFromEmptyValues() { + Map allProperties = Maps.of( + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_DISCOVERYSERVICEURLS", "", + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_0_SERVICEURL", "", + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_0_GATEWAYURL", ""); + List registrations = AdditionalRegistrationConfig.extractAdditionalRegistrations(allProperties); + assertThat(registrations).isEmpty(); + } + + @Test + void shouldParseBadRoutesIndexToEmptyRoutes() { + Map allProperties = Maps.of( + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_DISCOVERYSERVICEURLS", "https://eureka", + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_A_SERVICEURL", "/", + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_A_GATEWAYURL", "/"); + List registrations = AdditionalRegistrationConfig.extractAdditionalRegistrations(allProperties); + + assertThat(registrations).containsExactly(AdditionalRegistration.builder().discoveryServiceUrls("https://eureka").routes(emptyList()).build()); + } + + @Test + void shouldParseBadIndexToEmptyRegistrations() { + Map allProperties = Maps.of( + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION__DISCOVERYSERVICEURLS", "https://eureka"); + List registrations = AdditionalRegistrationConfig.extractAdditionalRegistrations(allProperties); + + assertThat(registrations).isEmpty(); + } + } + + @Nested + class GivenValidEnvironmentPropertiesAreProvided { + + private final Map envProperties = new TreeMap<>(); + + @BeforeEach + void setUp() { + envProperties.putAll( + Maps.of( + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_DISCOVERYSERVICEURLS", "https://eureka", + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_0_SERVICEURL", "/", + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_0_GATEWAYURL", "/") + ); + } + + @Test + void shouldParseFirstAdditionalRegistration() { + + List registrations = AdditionalRegistrationConfig.extractAdditionalRegistrations(envProperties); + + AdditionalRegistration expectedRegistration = new AdditionalRegistration("https://eureka", singletonList(new AdditionalRegistration.Route("/", "/"))); + + assertThat(registrations).hasSize(1); + assertThat(registrations.get(0)).isEqualTo(expectedRegistration); + } + + @Test + void shouldParseAdditionalRegistrationWithoutRoutes() { + + envProperties.putAll( + Maps.of( + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_1_DISCOVERYSERVICEURLS", "https://eureka-2", + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_1_ROUTES_0_SERVICEURL", "", + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_1_ROUTES_0_GATEWAYURL", null) + ); + + List registrations = AdditionalRegistrationConfig.extractAdditionalRegistrations(envProperties); + + AdditionalRegistration expectedSecondRegistration = new AdditionalRegistration("https://eureka-2", Collections.emptyList()); + + assertThat(registrations).hasSize(2); + assertThat(registrations.get(1)).isEqualTo(expectedSecondRegistration); + } + + @Test + void shouldParseAdditionalRegistrationWithPartiallyDefinedRoutes() { + + envProperties.putAll( + Maps.of( + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_1_DISCOVERYSERVICEURLS", "https://eureka-2", + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_1_ROUTES_0_SERVICEURL", "", + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_1_ROUTES_0_GATEWAYURL", null, + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_1_ROUTES_1_SERVICEURL", "/serviceUrl", + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_1_ROUTES_1_GATEWAYURL", null, + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_1_ROUTES_2_SERVICEURL", null, + "ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_1_ROUTES_2_GATEWAYURL", "/gatewayUrl") + ); + + List registrations = AdditionalRegistrationConfig.extractAdditionalRegistrations(envProperties); + + AdditionalRegistration expectedSecondRegistration = new AdditionalRegistration("https://eureka-2", Arrays.asList(new AdditionalRegistration.Route(null, "/serviceUrl"), new AdditionalRegistration.Route("/gatewayUrl", null))); + + assertThat(registrations).hasSize(2); + assertThat(registrations.get(1)).isEqualTo(expectedSecondRegistration); + } + } +} diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/DiscoveryClientBeanTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/DiscoveryClientBeanTest.java index cae8fc56a0..ea21772d7e 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/DiscoveryClientBeanTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/DiscoveryClientBeanTest.java @@ -10,17 +10,25 @@ package org.zowe.apiml.gateway.config; -import com.netflix.appinfo.*; +import com.netflix.appinfo.ApplicationInfoManager; +import com.netflix.appinfo.DataCenterInfo; +import com.netflix.appinfo.InstanceInfo; +import com.netflix.appinfo.LeaseInfo; +import com.netflix.appinfo.MyDataCenterInfo; import com.netflix.discovery.shared.transport.jersey.EurekaJerseyClientImpl; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.cloud.netflix.eureka.EurekaClientConfigBean; import org.springframework.context.ApplicationContext; -import org.springframework.test.util.ReflectionTestUtils; +import org.zowe.apiml.gateway.discovery.ApimlDiscoveryClientFactory; -import static org.junit.jupiter.api.Assertions.assertEquals; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.zowe.apiml.product.constants.CoreService.GATEWAY; class DiscoveryClientBeanTest { DiscoveryClientConfig dcConfig; @@ -29,23 +37,31 @@ class DiscoveryClientBeanTest { void setup() { ApplicationContext context = mock(ApplicationContext.class); EurekaJerseyClientImpl.EurekaJerseyClientBuilder builder = mock(EurekaJerseyClientImpl.EurekaJerseyClientBuilder.class); - dcConfig = new DiscoveryClientConfig(context, null, builder); + dcConfig = new DiscoveryClientConfig(null, new ApimlDiscoveryClientFactory(), context, builder); } @Test - void givenListOfCentralRegistryURLs_thenCreateNewDiscoveryClientForEach() { - String[] centralRegistryUrls = {"https://host:10021/eureka", "https://host:10011/eureka"}; - ReflectionTestUtils.setField(dcConfig, "centralRegistryUrls", centralRegistryUrls); + void givenListOfAdditionalRegistrations_thenCreateNewDiscoveryClientForEach() { + List additionalRegistrations = Arrays.asList( + AdditionalRegistration.builder().discoveryServiceUrls("https://host:10021/eureka").build(), + AdditionalRegistration.builder().discoveryServiceUrls("https://host:10011/eureka").build()); + + ApplicationInfoManager manager = mock(ApplicationInfoManager.class); - InstanceInfo info = mock(InstanceInfo.class); + InstanceInfo info = new InstanceInfo.Builder(mock(InstanceInfo.class)).setAppName(GATEWAY.name()).build(); + when(manager.getInfo()).thenReturn(info); when(info.getIPAddr()).thenReturn("127.0.0.1"); + when(info.getDataCenterInfo()).thenReturn(new MyDataCenterInfo(DataCenterInfo.Name.MyOwn)); LeaseInfo leaseInfo = mock(LeaseInfo.class); + when(info.getLeaseInfo()).thenReturn(leaseInfo); + EurekaClientConfigBean bean = new EurekaClientConfigBean(); - DiscoveryClientWrapper wrapper = dcConfig.additionalDiscoveryClientWrapper(manager, bean, null); + DiscoveryClientWrapper wrapper = dcConfig.additionalDiscoveryClientWrapper(manager, bean, null, additionalRegistrations); wrapper.shutdown(); - assertEquals(2, wrapper.getDiscoveryClients().size()); + + assertThat(wrapper.getDiscoveryClients()).hasSize(2); } } diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/DiscoveryClientConfigTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/DiscoveryClientConfigTest.java new file mode 100644 index 0000000000..5d606fb2d0 --- /dev/null +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/DiscoveryClientConfigTest.java @@ -0,0 +1,116 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.config; + +import com.netflix.appinfo.ApplicationInfoManager; +import com.netflix.appinfo.HealthCheckHandler; +import com.netflix.appinfo.InstanceInfo; +import com.netflix.discovery.EurekaClientConfig; +import com.netflix.discovery.shared.transport.jersey.EurekaJerseyClientImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.zowe.apiml.gateway.discovery.ApimlDiscoveryClient; +import org.zowe.apiml.gateway.discovery.ApimlDiscoveryClientFactory; + +import java.util.ArrayList; +import java.util.TreeMap; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.zowe.apiml.product.constants.CoreService.GATEWAY; + +@ExtendWith(MockitoExtension.class) +class DiscoveryClientConfigTest { + @Nested + class WhenProcessingAdditionalRegistrations { + @Mock + private EurekaJerseyClientImpl.EurekaJerseyClientBuilder eurekaJerseyClientBuilder; + @Mock + private ApplicationInfoManager appManager; + @Mock + private ApimlDiscoveryClient discoveryClientClient; + @Mock + private ApimlDiscoveryClientFactory apimlDiscoveryClientFactory; + @Captor + private ArgumentCaptor appInfoManagerCaptor; + @Mock + private EurekaClientConfig eurekaClientConfig; + @Mock + private HealthCheckHandler healthCheckHandler; + + private AdditionalRegistration registration; + private InstanceInfo instanceInfo; + @Mock + private InstanceInfo baseInstanceInfo; + @InjectMocks + private DiscoveryClientConfig discoveryClientConfig; + + @BeforeEach + public void setUp() { + registration = AdditionalRegistration.builder() + .discoveryServiceUrls("https://host:10011/eureka").routes(new ArrayList<>()).build(); + + instanceInfo = new InstanceInfo.Builder(baseInstanceInfo).setAppName(GATEWAY.name()).setMetadata(new TreeMap<>()).build(); + when(appManager.getInfo()).thenReturn(instanceInfo); + when(apimlDiscoveryClientFactory.buildApimlDiscoveryClient(any(), any(), any(), any())).thenReturn(discoveryClientClient); + + } + + @Test + void shouldRegisterHealthCheckHandler() { + DiscoveryClientWrapper discoveryClientWrapper = discoveryClientConfig.additionalDiscoveryClientWrapper(appManager, eurekaClientConfig, healthCheckHandler, singletonList(registration)); + + assertThat(discoveryClientWrapper.getDiscoveryClients()).hasSize(1); + verify(discoveryClientClient).registerHealthCheck(healthCheckHandler); + } + + @Test + void shouldAddAdditionalRoutesToMetadata() { + + registration.getRoutes().add(new AdditionalRegistration.Route("/gatewayUrl", "/serviceUrl")); + + discoveryClientConfig.additionalDiscoveryClientWrapper(appManager, eurekaClientConfig, healthCheckHandler, singletonList(registration)); + + verify(apimlDiscoveryClientFactory).buildApimlDiscoveryClient(appInfoManagerCaptor.capture(), any(), any(), any()); + + ApplicationInfoManager createdInfoManager = appInfoManagerCaptor.getValue(); + InstanceInfo info = createdInfoManager.getInfo(); + when(info.getMetadata()).thenCallRealMethod(); + assertThat(info.getMetadata()).containsEntry("apiml.routes.0.gatewayUrl", "/gatewayUrl"); + assertThat(info.getMetadata()).containsEntry("apiml.routes.0.serviceUrl", "/serviceUrl"); + } + + @Test + void shouldNotAddAdditionalRoutesToMetadata() { + + discoveryClientConfig.additionalDiscoveryClientWrapper(appManager, eurekaClientConfig, healthCheckHandler, singletonList(registration)); + + verify(apimlDiscoveryClientFactory).buildApimlDiscoveryClient(appInfoManagerCaptor.capture(), any(), any(), any()); + + ApplicationInfoManager createdInfoManager = appInfoManagerCaptor.getValue(); + InstanceInfo info = createdInfoManager.getInfo(); + when(info.getMetadata()).thenCallRealMethod(); + assertThat(info.getMetadata()).isEmpty(); + } + + } + +} diff --git a/gateway-service/src/test/resources/application.yml b/gateway-service/src/test/resources/application.yml index dda5fb2ff8..78914d73ad 100644 --- a/gateway-service/src/test/resources/application.yml +++ b/gateway-service/src/test/resources/application.yml @@ -16,7 +16,7 @@ apiml: allowEncodedSlashes: true discoveryServiceUrls: https://localhost:10011/eureka/ ignoredHeadersWhenCorsEnabled: Access-Control-Request-Method,Access-Control-Request-Headers,Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers,Access-Control-Allow-Credentials,Origin - centralRegistryUrls: # List of additional Discovery Services URLs to register with + additionalRegistration: # List of additional Apiml Discovery Services metadata to register with loadBalancer: distribute: false gateway: diff --git a/integration-tests/src/test/java/org/zowe/apiml/functional/cloudgateway/CentralRegistryTest.java b/integration-tests/src/test/java/org/zowe/apiml/functional/cloudgateway/CentralRegistryTest.java index e62c2a1398..5aad09e781 100644 --- a/integration-tests/src/test/java/org/zowe/apiml/functional/cloudgateway/CentralRegistryTest.java +++ b/integration-tests/src/test/java/org/zowe/apiml/functional/cloudgateway/CentralRegistryTest.java @@ -53,7 +53,7 @@ class CentralRegistryTest implements TestWithStartedInstances { @SneakyThrows static void setupAll() { //In order to avoid config customization - ConfigReader.environmentConfiguration().getGatewayServiceConfiguration().setInstances(2); + ConfigReader.environmentConfiguration().getGatewayServiceConfiguration().setInstances(1); TlsConfiguration tlsCfg = ConfigReader.environmentConfiguration().getTlsConfiguration(); SslContextConfigurer sslContextConfigurer = new SslContextConfigurer(tlsCfg.getKeyStorePassword(), tlsCfg.getClientKeystore(), tlsCfg.getKeyStore()); @@ -87,7 +87,7 @@ void shouldFindBothApimlIds() { List apimlIds = listCentralRegistry(null, null, null) .extract().jsonPath().getList("apimlId"); - assertThat(apimlIds, Matchers.hasItems(Matchers.equalTo("central-apiml"), Matchers.equalTo("domain-apiml"))); + assertThat(apimlIds, Matchers.containsInAnyOrder("central-apiml", "domain-apiml")); } @Test diff --git a/schemas/gateway-schema.json b/schemas/gateway-schema.json index 8a9f6a65ce..9b6eee3e44 100644 --- a/schemas/gateway-schema.json +++ b/schemas/gateway-schema.json @@ -175,11 +175,23 @@ "description": "Allow URLs on gateway to contain encoded slashes.", "default": true }, - "centralRegistryUrls": { + "additionalRegistration": { "type": "array", - "description": "List of additional Discovery Services URLs to register with.", + "description": "List of additional Discovery Services URLs to register with and the routing patterns.", "minItems": 1, - "uniqueItems": true + "items": { + "type": "object", + "properties": { + "discoveryServiceUrls": { + "type": "string", + "description": "List of Discovery Services URLs in one security domain. You can separate multiple urls by comma or semicolon." + }, + "routes": { + "$ref": "#/$defs/routes" + } + }, + "required": ["discoveryServiceUrls","routes"] + } }, "corsEnabled": { "type": "boolean", @@ -286,6 +298,24 @@ "description": "TCP network port", "minimum": 1024, "maximum": 65535 + }, + "routes": { + "type": "array", + "description": "Routing parameters", + "items": { + "type": "object", + "properties": { + "gatewayUrl": { + "type": "string", + "description": "The portion of the gateway URL which is replaced by the serviceUrl path." + }, + "serviceUrl": { + "type": "string", + "description": "The portion of the service instance URL path which replaces the gatewayUrl part." + } + }, + "required": ["gatewayUrl","serviceUrl"] + } } } }