From 5f54de50d5585dff8ecf96a8554137d10d7f2b3a Mon Sep 17 00:00:00 2001 From: alexandr cumarav Date: Mon, 4 Sep 2023 16:18:50 +0200 Subject: [PATCH 1/2] GH2883 Cloud gateway service functionality Signed-off-by: alexandr cumarav GH2883 Cloud gateway service functionality Signed-off-by: alexandr cumarav GH2883 Cloud gateway service functionality Signed-off-by: alexandr cumarav GH2883 Cloud gateway service functionality Signed-off-by: alexandr cumarav --- cloud-gateway-service/build.gradle | 5 +- .../config/ConnectionsConfig.java | 9 +- .../config/RegistryConfig.java | 31 +++ .../scheduled/GatewayScanJob.java | 115 +++++++++++ .../service/BasicInfoService.java | 172 ++++++++++++++++ .../service/GatewayIndexService.java | 172 ++++++++++++++++ .../service/ServiceInfo.java | 88 ++++++++ .../service/ServiceInfoUtils.java | 105 ++++++++++ .../service/WebClientHelper.java | 79 +++++++ .../src/main/resources/application.yml | 13 +- .../resources/cloud-gateway-log-messages.yml | 17 ++ .../src/main/resources/logback.xml | 51 +++++ .../scheduled/GatewayScanJobTest.java | 113 ++++++++++ .../service/BasicInfoServiceTest.java | 139 +++++++++++++ .../service/GatewayIndexServiceTest.java | 193 ++++++++++++++++++ .../service/WebClientHelperTest.java | 48 +++++ .../src/test/resources/application.yml | 3 + .../zowe/apiml/security/HttpsConfigError.java | 6 + .../gateway/services/ServerInfoConfig.java | 1 - 19 files changed, 1354 insertions(+), 6 deletions(-) create mode 100644 cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/RegistryConfig.java create mode 100644 cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/scheduled/GatewayScanJob.java create mode 100644 cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/BasicInfoService.java create mode 100644 cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/GatewayIndexService.java create mode 100644 cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/ServiceInfo.java create mode 100644 cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/ServiceInfoUtils.java create mode 100644 cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/WebClientHelper.java create mode 100644 cloud-gateway-service/src/main/resources/logback.xml create mode 100644 cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/scheduled/GatewayScanJobTest.java create mode 100644 cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/BasicInfoServiceTest.java create mode 100644 cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/GatewayIndexServiceTest.java create mode 100644 cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/WebClientHelperTest.java diff --git a/cloud-gateway-service/build.gradle b/cloud-gateway-service/build.gradle index 32c7946499..643d9eed20 100644 --- a/cloud-gateway-service/build.gradle +++ b/cloud-gateway-service/build.gradle @@ -60,6 +60,8 @@ configurations.all { dependencies { api project(':common-service-core') + api project(':apiml-utility') + implementation libs.eh.cache implementation libs.spring.cloud.starter.gateway @@ -80,6 +82,7 @@ dependencies { implementation libs.spring.expression implementation libs.bcpkix implementation libs.nimbusJoseJwt + implementation libs.janino compileOnly libs.lombok annotationProcessor libs.lombok @@ -89,7 +92,7 @@ dependencies { testAnnotationProcessor libs.lombok testImplementation libs.spring.boot.starter.test testImplementation libs.rest.assured - + testImplementation libs.reactorTest testImplementation libs.mockito.inline } diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/ConnectionsConfig.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/ConnectionsConfig.java index 3c88aff37c..07516793f3 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/ConnectionsConfig.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/ConnectionsConfig.java @@ -45,8 +45,10 @@ import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.pattern.PathPatternParser; import org.zowe.apiml.message.core.MessageService; +import org.zowe.apiml.message.log.ApimlLogger; import org.zowe.apiml.message.yaml.YamlMessageServiceInstance; import org.zowe.apiml.security.HttpsConfig; +import org.zowe.apiml.security.HttpsConfigError; import org.zowe.apiml.security.HttpsFactory; import org.zowe.apiml.security.SecurityUtils; import org.zowe.apiml.util.CorsUtils; @@ -112,6 +114,7 @@ public class ConnectionsConfig { @Value("${apiml.service.corsEnabled:false}") private boolean corsEnabled; private final ApplicationContext context; + private static final ApimlLogger apimlLog = ApimlLogger.of(ConnectionsConfig.class, YamlMessageServiceInstance.getInstance()); public ConnectionsConfig(ApplicationContext context) { this.context = context; @@ -171,9 +174,9 @@ SslContext sslContext() { trustManagerFactory.init(trustStore); return SslContextBuilder.forClient().keyManager(keyManagerFactory).trustManager(trustManagerFactory).build(); } catch (Exception e) { - log.error("Exception while creating SSL context", e); - System.exit(1); - return null; + apimlLog.log("org.zowe.apiml.common.sslContextInitializationError", e.getMessage()); + throw new HttpsConfigError("Error initializing SSL Context: " + e.getMessage(), e, + HttpsConfigError.ErrorCode.HTTP_CLIENT_INITIALIZATION_FAILED, factory().getConfig()); } } diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/RegistryConfig.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/RegistryConfig.java new file mode 100644 index 0000000000..227a7b8bab --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/RegistryConfig.java @@ -0,0 +1,31 @@ +/* + * 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.cloudgatewayservice.config; + +import com.netflix.discovery.EurekaClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.zowe.apiml.cloudgatewayservice.service.BasicInfoService; +import org.zowe.apiml.eurekaservice.client.util.EurekaMetadataParser; + +@Configuration +public class RegistryConfig { + + @Bean + public EurekaMetadataParser getEurekaMetadataParser() { + return new EurekaMetadataParser(); + } + + @Bean + public BasicInfoService basicInfoService(EurekaClient eurekaClient, EurekaMetadataParser eurekaMetadataParser) { + return new BasicInfoService(eurekaClient, eurekaMetadataParser); + } +} diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/scheduled/GatewayScanJob.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/scheduled/GatewayScanJob.java new file mode 100644 index 0000000000..f72be66998 --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/scheduled/GatewayScanJob.java @@ -0,0 +1,115 @@ +/* + * 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.cloudgatewayservice.scheduled; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.zowe.apiml.cloudgatewayservice.service.BasicInfoService; +import org.zowe.apiml.cloudgatewayservice.service.GatewayIndexService; +import org.zowe.apiml.cloudgatewayservice.service.InstanceInfoService; +import org.zowe.apiml.cloudgatewayservice.service.ServiceInfo; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.zowe.apiml.constants.EurekaMetadataDefinition.APIML_ID; + +/** + * Scheduled job to refresh registry of all registered gateways services. + * Behaviour of the job can be configured by the following settings: + *
+ *   apiml:
+ *     cloudGateway:
+ *       cachePeriodSec: - default value 120 seconds
+ *       maxSimultaneousRequests:  - default value 20
+ *       clientKeystore: - default value null
+ *       clientKeystorePassword: - default value null
+ *       clientKeystoreType: - default PKCS12
+ *       serviceRegistryEnabled: - default value false
+ * 
+ */ +@EnableScheduling +@Slf4j +@Component +@ConditionalOnExpression("${apiml.cloudGateway.serviceRegistryEnabled:false}") +@RequiredArgsConstructor +public class GatewayScanJob { + public static final String GATEWAY_SERVICE_ID = "GATEWAY"; + + private final GatewayIndexService gatewayIndexerService; + private final InstanceInfoService instanceInfoService; + private final BasicInfoService basicInfoService; + + @Value("${apiml.service.apimlId:#{null}}") + private String currentApimlId; + @Value("${apiml.cloudGateway.maxSimultaneousRequests:20}") + private int maxSimultaneousRequests; + + @Scheduled(initialDelay = 5000, fixedDelayString = "${apiml.cloudGateway.refresh-interval-ms:30000}") + public void startScanExternalGatewayJob() { + log.debug("Scan gateways job start"); + doScanExternalGateway() + .subscribe(); + addLocalServices(); + } + + private void addLocalServices() { + String apimlIdKey = Optional.ofNullable(currentApimlId).orElse(APIML_ID); + List localServices = basicInfoService.getServicesInfo(); + gatewayIndexerService.putApimlServices(apimlIdKey, localServices); + } + + /** + * reactive entry point for the external gateways index refresh + */ + protected Flux> doScanExternalGateway() { + + Mono> registeredGateways = instanceInfoService.getServiceInstance(GATEWAY_SERVICE_ID) + .map(gateways -> gateways.stream().filter(info -> !StringUtils.equals(info.getMetadata().getOrDefault(APIML_ID, "N/A"), currentApimlId)).collect(Collectors.toList())); + + Flux serviceInstanceFlux = registeredGateways.flatMapMany(Flux::fromIterable); + + return serviceInstanceFlux + .flatMap(gatewayIndexerService::indexGatewayServices, maxSimultaneousRequests); + } + + /** + * Helper to echo state os the services caches. Will be removed later when REST api + */ + @Scheduled(initialDelay = 10000, fixedDelayString = "5000") + void listCaches() { + + Map> fullState = gatewayIndexerService.listRegistry(null, null); + + log.debug("Cache having {} apimlId records, {}", fullState.keySet().size(), System.currentTimeMillis()); + for (String apimlId : fullState.keySet()) { + List servicesInfo = gatewayIndexerService.listRegistry(apimlId, null).get(apimlId); + log.debug("\t {}-{} : found {} external services", apimlId, apimlId, servicesInfo.size()); + } + + Map> allSysviews = gatewayIndexerService.listRegistry(null, "bcm.sysview"); + for (Map.Entry> apimlEntiry : allSysviews.entrySet()) { + log.debug("Listing all sysview services at: {}", apimlEntiry.getKey()); + apimlEntiry.getValue().forEach(serviceInfo -> log.debug("\t {} - {}", serviceInfo.getServiceId(), serviceInfo.getInstances())); + } + } +} diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/BasicInfoService.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/BasicInfoService.java new file mode 100644 index 0000000000..4246558e4f --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/BasicInfoService.java @@ -0,0 +1,172 @@ +/* + * 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.cloudgatewayservice.service; + + +import com.fasterxml.jackson.core.Version; +import com.netflix.appinfo.InstanceInfo; +import com.netflix.discovery.EurekaClient; +import com.netflix.discovery.shared.Application; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.ObjectUtils; +import org.zowe.apiml.auth.Authentication; +import org.zowe.apiml.config.ApiInfo; +import org.zowe.apiml.eurekaservice.client.util.EurekaMetadataParser; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.minBy; +import static org.zowe.apiml.cloudgatewayservice.service.ServiceInfoUtils.getBasePath; +import static org.zowe.apiml.cloudgatewayservice.service.ServiceInfoUtils.getInstances; +import static org.zowe.apiml.cloudgatewayservice.service.ServiceInfoUtils.getMajorVersion; +import static org.zowe.apiml.cloudgatewayservice.service.ServiceInfoUtils.getStatus; +import static org.zowe.apiml.cloudgatewayservice.service.ServiceInfoUtils.getVersion; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.SERVICE_DESCRIPTION; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.SERVICE_TITLE; + +/** + * Similar to {@link org.zowe.apiml.gateway.services.ServicesInfoService} service which does not depend on gateway-service components. + * Following properties left blank: + * {@link ServiceInfo.Service#homePageUrl} and {@link ServiceInfo.ApiInfoExtended#swaggerUrl} + */ +@Slf4j +@RequiredArgsConstructor +public class BasicInfoService { + + private final EurekaClient eurekaClient; + private final EurekaMetadataParser eurekaMetadataParser; + + public List getServicesInfo() { + List servicesInfo = new LinkedList<>(); + for (Application application : eurekaClient.getApplications().getRegisteredApplications()) { + servicesInfo.add(getServiceInfo(application)); + } + + return servicesInfo; + } + + private ServiceInfo getServiceInfo(Application application) { + String serviceId = application.getName().toLowerCase(); + + List appInstances = application.getInstances(); + if (ObjectUtils.isEmpty(appInstances)) { + return ServiceInfo.builder() + .serviceId(serviceId) + .status(InstanceInfo.InstanceStatus.DOWN) + .build(); + } + + return ServiceInfo.builder() + .serviceId(serviceId) + .status(getStatus(appInstances)) + .apiml(getApiml(appInstances)) + .instances(getInstances(appInstances)) + .build(); + } + + /** + * uses simplified: + * - getApiInfos + * - getApiInfos + */ + private ServiceInfo.Apiml getApiml(List appInstances) { + return ServiceInfo.Apiml.builder() + .apiInfo(getApiInfos(appInstances)) + .service(getService(appInstances)) + .authentication(getAuthentication(appInstances)) + .build(); + } + + + /** + * simplified version, following part is excluded: + * - homePageUrl + */ + private ServiceInfo.Service getService(List appInstances) { + InstanceInfo instanceInfo = getInstanceWithHighestVersion(appInstances); + + return ServiceInfo.Service.builder() + .title(instanceInfo.getMetadata().get(SERVICE_TITLE)) + .description(instanceInfo.getMetadata().get(SERVICE_DESCRIPTION)) + .build(); + } + + /** + * Simplified version, following properties are excluded: + * - baseUrl + * - swaggerUrl + */ + private List getApiInfos(List appInstances) { + List completeList = new ArrayList<>(); + + for (InstanceInfo instanceInfo : appInstances) { + List apiInfoList = eurekaMetadataParser.parseApiInfo(instanceInfo.getMetadata()); + completeList.addAll(apiInfoList.stream() + .map(apiInfo -> ServiceInfo.ApiInfoExtended.builder() + .apiId(apiInfo.getApiId()) + .basePath(getBasePath(apiInfo, instanceInfo)) + .gatewayUrl(apiInfo.getGatewayUrl()) + .documentationUrl(apiInfo.getDocumentationUrl()) + .version(apiInfo.getVersion()) + .codeSnippet(apiInfo.getCodeSnippet()) + .isDefaultApi(apiInfo.isDefaultApi()) + .build()) + .collect(Collectors.toList())); + } + + return completeList.stream() + .collect(groupingBy( + apiInfo -> new AbstractMap.SimpleEntry<>(apiInfo.getApiId(), getMajorVersion(apiInfo)), + minBy(Comparator.comparingInt(ServiceInfoUtils::getMajorVersion)) + )) + .values() + .stream() + .map(Optional::get) + .collect(Collectors.toList()); + } + + private List getAuthentication(List appInstances) { + return appInstances.stream() + .map(instanceInfo -> { + Authentication authentication = eurekaMetadataParser.parseAuthentication(instanceInfo.getMetadata()); + return authentication.isEmpty() ? null : authentication; + }) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + } + + private InstanceInfo getInstanceWithHighestVersion(List appInstances) { + InstanceInfo instanceInfo = appInstances.get(0); + Version highestVersion = Version.unknownVersion(); + + for (InstanceInfo currentInfo : appInstances) { + List apiInfoList = eurekaMetadataParser.parseApiInfo(currentInfo.getMetadata()); + for (ApiInfo apiInfo : apiInfoList) { + Version version = getVersion(apiInfo.getVersion()); + if (version.compareTo(highestVersion) > 0) { + highestVersion = version; + instanceInfo = currentInfo; + } + } + } + return instanceInfo; + } +} diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/GatewayIndexService.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/GatewayIndexService.java new file mode 100644 index 0000000000..40b5888b1a --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/GatewayIndexService.java @@ -0,0 +1,172 @@ +/* + * 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.cloudgatewayservice.service; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.collect.ImmutableMap; +import io.netty.handler.ssl.SslContext; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.web.reactive.function.client.WebClient; +import org.zowe.apiml.message.log.ApimlLogger; +import org.zowe.apiml.message.yaml.YamlMessageServiceInstance; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; +import reactor.netty.tcp.SslProvider; + +import javax.validation.constraints.NotNull; +import java.util.AbstractMap; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import static java.util.Objects.nonNull; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.apache.commons.lang.StringUtils.isNotBlank; +import static org.springframework.util.CollectionUtils.isEmpty; +import static org.zowe.apiml.cloudgatewayservice.service.WebClientHelper.load; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.APIML_ID; + +/** + * Maintain all registered gateways lookup data. Internal caches uses apimlId is a key. + * if apimlId key is not available then synthetic key is generated containing SUBSTITUTE_ prefix and instanceId + */ +@Slf4j +@Service +public class GatewayIndexService { + private final ApimlLogger apimlLog = ApimlLogger.of(GatewayIndexService.class, YamlMessageServiceInstance.getInstance()); + private final Cache apimlGatewayLookup; + private final Cache> apimlServicesCache; + private final WebClient defaultWebClient; + private SslContext customClientSslContext = null; + + public GatewayIndexService(WebClient defaultWebClient, + @Value("${apiml.cloudGateway.cachePeriodSec:120}") int cachePeriodSec, + @Value("${apiml.cloudGateway.clientKeystore:#{null}}") String clientKeystorePath, + @Value("${apiml.cloudGateway.clientKeystorePassword:#{null}}") char[] clientKeystorePassword, + @Value("${apiml.cloudGateway.clientKeystoreType:PKCS12}") String keystoreType + ) { + this.defaultWebClient = defaultWebClient; + + apimlGatewayLookup = CacheBuilder.newBuilder().expireAfterWrite(cachePeriodSec, SECONDS).build(); + apimlServicesCache = CacheBuilder.newBuilder().expireAfterWrite(cachePeriodSec, SECONDS).build(); + + if (isNotBlank(clientKeystorePath) && nonNull(clientKeystorePassword)) { + customClientSslContext = load(clientKeystorePath, clientKeystorePassword, keystoreType); + } + } + + private WebClient buildWebClient(ServiceInstance registration) { + final String baseUrl = String.format("%s://%s:%d", registration.getScheme(), registration.getHost(), registration.getPort()); + if (this.customClientSslContext != null) { + SslProvider sslProvider = SslProvider.builder().sslContext(customClientSslContext).build(); + HttpClient httpClient = HttpClient.create() + .secure(sslProvider); + + return WebClient.builder() + .baseUrl(baseUrl) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + return defaultWebClient.mutate() + .baseUrl(baseUrl) + .build(); + } + + public Mono> indexGatewayServices(ServiceInstance registration) { + String apimlIdKey = extractApimlId(registration).orElse(buildAlternativeApimlIdKey(registration)); + log.debug("Fetching registered gateway instance services: {}", apimlIdKey); + apimlGatewayLookup.put(apimlIdKey, registration); + return fetchServices(apimlIdKey, registration) + .doOnError(ex -> apimlLog.log("org.zowe.apiml.gateway.servicesRequestFailed", apimlIdKey, ex.getMessage())) + .onErrorComplete() + .doFinally(signal -> log.debug("\t {} completed with {}", apimlIdKey, signal)); + } + + /** + * Store entry in the Services Registry. Should be used to store services info from the current apiml instance + * + * @param apimlId unique apimlId + * @param services List of the services + */ + public void putApimlServices(@NotNull String apimlId, List services) { + apimlServicesCache.put(apimlId, services); + } + + private Mono> fetchServices(String apimlId, ServiceInstance registration) { + WebClient webClient = buildWebClient(registration); + final ParameterizedTypeReference> serviceInfoType = new ParameterizedTypeReference>() { + }; + + return webClient.get().uri("/gateway/services") + .retrieve() + .bodyToMono(serviceInfoType) + .doOnNext(foreignServices -> apimlServicesCache.put(apimlId, foreignServices)); + } + + private String buildAlternativeApimlIdKey(ServiceInstance registration) { + return "SUBSTITUTE" + "_" + registration.getInstanceId(); + } + + private Optional extractApimlId(ServiceInstance registration) { + if (registration.getMetadata() != null) { + return Optional.ofNullable(registration.getMetadata().get(APIML_ID)); + } + return Optional.empty(); + } + + /** + * list currently cached apiml registry with option to filter by the apimlId and apiId + * + * @param apimlId - filter for only services from the particular apiml instance, NULL - filter not applied + * @param apiId - filter for only services of particular type e.g. bcm.sysview + * @return full of filter immutable map of the registry + */ + public Map> listRegistry(String apimlId, String apiId) { + + Map> allServices = ImmutableMap.>builder() + .putAll(apimlServicesCache.asMap()).build(); + return allServices.entrySet().stream() + .filter(entry -> apimlId == null || StringUtils.equals(apimlId, entry.getKey())) + .map(entry -> new AbstractMap.SimpleEntry<>(entry.getKey(), filterServicesByApiId(entry.getValue(), apiId))) + .collect(Collectors.toMap(AbstractMap.SimpleEntry::getKey, AbstractMap.SimpleEntry::getValue)); + } + + List filterServicesByApiId(List apimlIdServices, String apiId) { + if (!CollectionUtils.isEmpty(apimlIdServices)) { + return apimlIdServices.stream() + .filter(Objects::nonNull) + .filter(serviceInfo -> apiId == null || hasSameApiId(serviceInfo, apiId)) + .collect(Collectors.toList()); + } + return Collections.emptyList(); + } + + private boolean hasSameApiId(ServiceInfo serviceInfo, String apiId) { + if (serviceInfo.getApiml() != null && !isEmpty(serviceInfo.getApiml().getApiInfo())) { + return StringUtils.equals(apiId, serviceInfo.getApiml().getApiInfo().get(0).getApiId()); + } + return false; + } +} diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/ServiceInfo.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/ServiceInfo.java new file mode 100644 index 0000000000..4dd70c1a43 --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/ServiceInfo.java @@ -0,0 +1,88 @@ +/* + * 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.cloudgatewayservice.service; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.netflix.appinfo.InstanceInfo; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.zowe.apiml.auth.Authentication; +import org.zowe.apiml.config.ApiInfo; + +import java.util.List; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ServiceInfo { + private String serviceId; + private InstanceInfo.InstanceStatus status; + private Apiml apiml; + private Map instances; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Apiml { + private List apiInfo; + private Service service; + private List authentication; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Instances { + private InstanceInfo.InstanceStatus status; + private String hostname; + private String ipAddr; + private String protocol; + private int port; + private String homePageUrl; + private String healthCheckUrl; + private String statusPageUrl; + private Map customMetadata; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Service { + private String title; + private String description; + private String homePageUrl; + } + + @Data + @SuperBuilder + @EqualsAndHashCode(callSuper = true) + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ApiInfoExtended extends ApiInfo { + private String baseUrl; + private String basePath; + } + +} diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/ServiceInfoUtils.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/ServiceInfoUtils.java new file mode 100644 index 0000000000..1f4d2a0285 --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/ServiceInfoUtils.java @@ -0,0 +1,105 @@ +/* + * 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.cloudgatewayservice.service; + +import com.fasterxml.jackson.core.Version; +import com.netflix.appinfo.InstanceInfo; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.zowe.apiml.config.ApiInfo; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Utility class containing mapping functions for ServiceInfo formatting + */ +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ServiceInfoUtils { + + public static Map getInstances(List appInstances) { + return appInstances.stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap( + InstanceInfo::getInstanceId, + instanceInfo -> ServiceInfo.Instances.builder() + .status(instanceInfo.getStatus()) + .hostname(instanceInfo.getHostName()) + .ipAddr(instanceInfo.getIPAddr()) + .protocol(getProtocol(instanceInfo)) + .port(getPort(instanceInfo)) + .homePageUrl(instanceInfo.getHomePageUrl()) + .healthCheckUrl(getHealthCheckUrl(instanceInfo)) + .statusPageUrl(instanceInfo.getStatusPageUrl()) + .customMetadata(getCustomMetadata(instanceInfo.getMetadata())) + .build() + )); + } + + public static String getBasePath(ApiInfo apiInfo, InstanceInfo instanceInfo) { + return String.format("/%s/%s", instanceInfo.getAppName().toLowerCase(), apiInfo.getGatewayUrl()); + } + + private static String getHealthCheckUrl(InstanceInfo instanceInfo) { + return instanceInfo.isPortEnabled(InstanceInfo.PortType.SECURE) ? + instanceInfo.getSecureHealthCheckUrl() : instanceInfo.getHealthCheckUrl(); + } + + private static int getPort(InstanceInfo instanceInfo) { + return instanceInfo.isPortEnabled(InstanceInfo.PortType.SECURE) ? + instanceInfo.getSecurePort() : instanceInfo.getPort(); + } + + private static String getProtocol(InstanceInfo instanceInfo) { + return instanceInfo.isPortEnabled(InstanceInfo.PortType.SECURE) ? "https" : "http"; + } + + public static int getMajorVersion(ServiceInfo.ApiInfoExtended apiInfo) { + return getVersion(apiInfo.getVersion()).getMajorVersion(); + } + + public static Map getCustomMetadata(Map metadata) { + return metadata.entrySet().stream() + .filter(entry -> !entry.getKey().startsWith("apiml.")) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + public static Version getVersion(String version) { + if (version == null) return Version.unknownVersion(); + + String[] versions = version.split("\\."); + + int major = 0; + int minor = 0; + int patch = 0; + try { + if (versions.length >= 1) major = Integer.parseInt(versions[0]); + if (versions.length >= 2) minor = Integer.parseInt(versions[1]); + if (versions.length >= 3) patch = Integer.parseInt(versions[2]); + } catch (NumberFormatException ex) { + log.debug("Incorrect version {}", version); + } + + return new Version(major, minor, patch, null, null, null); + } + + public static InstanceInfo.InstanceStatus getStatus(List instances) { + if (instances.stream().anyMatch(instance -> instance.getStatus().equals(InstanceInfo.InstanceStatus.UP))) { + return InstanceInfo.InstanceStatus.UP; + } else { + return InstanceInfo.InstanceStatus.DOWN; + } + } +} diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/WebClientHelper.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/WebClientHelper.java new file mode 100644 index 0000000000..c3e0cd86cb --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/WebClientHelper.java @@ -0,0 +1,79 @@ +/* + * 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.cloudgatewayservice.service; + +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.zowe.apiml.message.log.ApimlLogger; +import org.zowe.apiml.message.yaml.YamlMessageServiceInstance; +import org.zowe.apiml.security.HttpsConfigError; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLException; +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; + +/** + * Utility class for the custom Netty {@link SslContext} creation. + * Does not support keyring because client keystore override mainly used in development mode and not supposed be run on the Mainframe. + */ +@Slf4j +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WebClientHelper { + private static final ApimlLogger apimlLog = ApimlLogger.of(WebClientHelper.class, YamlMessageServiceInstance.getInstance()); + + /** + * Load {@link SslContext} from the specified keystore + * + * @param keystorePath path to the keystore file + * @param password keystore password + * @param keystoreType keystore type + * @throws IllegalArgumentException if keystore file does not exist. + * @throws HttpsConfigError if any error occur during the context creation. + */ + public static SslContext load(String keystorePath, char[] password, String keystoreType) { + File keyStoreFile = new File(keystorePath); + if (keyStoreFile.exists()) { + try (InputStream is = Files.newInputStream(Paths.get(keystorePath))) { + KeyStore keyStore = KeyStore.getInstance(keystoreType); + keyStore.load(is, password); + return initSslContext(keyStore, password); + } catch (Exception e) { + apimlLog.log("org.zowe.apiml.common.sslContextInitializationError", e.getMessage()); + throw new HttpsConfigError("Error initializing SSL Context: " + e.getMessage(), e, + HttpsConfigError.ErrorCode.HTTP_CLIENT_INITIALIZATION_FAILED); + } + } else { + throw new IllegalArgumentException("Not existing file: " + keystorePath); + } + } + + private static SslContext initSslContext(KeyStore keyStore, char[] password) throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException, SSLException { + KeyManagerFactory kmf = KeyManagerFactory.getInstance("PKIX"); + kmf.init(keyStore, password); + + return SslContextBuilder.forClient() + .trustManager(InsecureTrustManagerFactory.INSTANCE) + .keyManager(kmf).build(); + } + + +} diff --git a/cloud-gateway-service/src/main/resources/application.yml b/cloud-gateway-service/src/main/resources/application.yml index f6b7314cb7..19b607ee88 100644 --- a/cloud-gateway-service/src/main/resources/application.yml +++ b/cloud-gateway-service/src/main/resources/application.yml @@ -3,19 +3,28 @@ eureka: serviceUrl: defaultZone: https://localhost:10011/eureka/ +spring: + application: + name: ${apiml.service.id} + apiml: gateway: timeout: 60 service: + apimlId: apiml1 id: cloud-gateway port: 10023 hostname: localhost + scheme: https # "https" or "http" corsEnabled: true 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 forwardClientCertEnabled: false security: ssl: nonStrictVerifySslCertificatesOfServices: true + cloudGateway: + serviceRegistryEnabled: false + server: port: ${apiml.service.port} ssl: @@ -29,7 +38,6 @@ server: trustStorePassword: password trustStoreType: PKCS12 -spring: cloud: gateway: discovery: @@ -41,10 +49,13 @@ spring: main: allow-circular-references: true +logbackServiceName: ZWEACG1 + logging: level: org.springframework.cloud.gateway: DEBUG reactor.netty.http.client: DEBUG + reactor.netty.http.client.HttpClientConnect: OFF management: endpoint: diff --git a/cloud-gateway-service/src/main/resources/cloud-gateway-log-messages.yml b/cloud-gateway-service/src/main/resources/cloud-gateway-log-messages.yml index 2835245a8e..6eddb131fa 100644 --- a/cloud-gateway-service/src/main/resources/cloud-gateway-log-messages.yml +++ b/cloud-gateway-service/src/main/resources/cloud-gateway-log-messages.yml @@ -351,6 +351,16 @@ messages: reason: "The JWT token or client certificate is not valid" action: "Configure your client to provide valid authentication." + - key: org.zowe.apiml.common.sslContextInitializationError + number: ZWEAM400 + type: ERROR + text: "Error initializing SSL Context: '%s'" + reason: "An error occurred while initializing the SSL Context." + action: "Refer to the specific message to identify the exact problem.\n + Possible causes include:\n + - Incorrect security algorithm\n + - The keystore is invalid or corrupted\n + - The certificate is invalid or corrupted" # Revoke personal access token - key: org.zowe.apiml.security.query.invalidRevokeRequestBody @@ -359,3 +369,10 @@ messages: text: "Body in the revoke request is not valid." reason: "The request body is not valid" action: "Use a valid body in the request. Format of a message: {userId: string, (optional)timestamp: long} or {serviceId: string, (optional)timestamp: long}." + + - key: org.zowe.apiml.gateway.servicesRequestFailed + number: ZWESG100 + type: WARNING + text: "Cannot receive information about services on API Gateway with apimlId '%s' because: %s" + reason: "Cannot connect to the Gateway service." + action: "Make sure that the external Gateway service is running and the truststore of the both Gateways contain the corresponding certificate." diff --git a/cloud-gateway-service/src/main/resources/logback.xml b/cloud-gateway-service/src/main/resources/logback.xml new file mode 100644 index 0000000000..999077ccc1 --- /dev/null +++ b/cloud-gateway-service/src/main/resources/logback.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + ${apimlLogPattern} + + + + + + ${STORAGE_LOCATION}/${logbackServiceName}.log + + + ${STORAGE_LOCATION}/${logbackServiceName}.%i.log + ${MIN_INDEX} + ${MAX_INDEX} + + + + ${MAX_FILE_SIZE} + + + + + ${apimlLogPattern} + + + + + + + + + \ No newline at end of file diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/scheduled/GatewayScanJobTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/scheduled/GatewayScanJobTest.java new file mode 100644 index 0000000000..6c21870ea1 --- /dev/null +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/scheduled/GatewayScanJobTest.java @@ -0,0 +1,113 @@ +/* + * 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.cloudgatewayservice.scheduled; + + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.test.util.ReflectionTestUtils; +import org.zowe.apiml.cloudgatewayservice.service.BasicInfoService; +import org.zowe.apiml.cloudgatewayservice.service.GatewayIndexService; +import org.zowe.apiml.cloudgatewayservice.service.InstanceInfoService; +import org.zowe.apiml.cloudgatewayservice.service.ServiceInfo; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.List; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.zowe.apiml.cloudgatewayservice.scheduled.GatewayScanJob.GATEWAY_SERVICE_ID; + + +@ExtendWith(MockitoExtension.class) +class GatewayScanJobTest { + + @Mock + private ServiceInstance instanceOne; + @Mock + private ServiceInstance instanceTwo; + @Mock + private List apimlServicesOne; + @Mock + private List apimlServicesTwo; + @Mock + private GatewayIndexService gatewayIndexerService; + @Mock + private InstanceInfoService instanceInfoService; + @Mock + private BasicInfoService basicInfoService; + @InjectMocks + private GatewayScanJob gatewayScanJob; + + @BeforeEach + public void setUp() { + ReflectionTestUtils.setField(gatewayScanJob, "maxSimultaneousRequests", 3); + + lenient().when(instanceInfoService.getServiceInstance(GATEWAY_SERVICE_ID)).thenReturn(Mono.just(asList(instanceOne, instanceTwo))); + + lenient().when(gatewayIndexerService.indexGatewayServices(instanceOne)).thenReturn(Mono.just(apimlServicesOne)); + lenient().when(gatewayIndexerService.indexGatewayServices(instanceTwo)).thenReturn(Mono.just(apimlServicesTwo)); + } + + @Nested + class WhenScanningExternalGateway { + @Test + void shouldTriggerIndexingForRegisteredGateways() { + StepVerifier.create(gatewayScanJob.doScanExternalGateway()) + .expectNext(apimlServicesOne) + .expectNext(apimlServicesTwo) + .verifyComplete(); + + verify(gatewayIndexerService).indexGatewayServices(instanceOne); + verify(gatewayIndexerService).indexGatewayServices(instanceTwo); + verifyNoMoreInteractions(gatewayIndexerService); + } + } + + @Test + void scheduledCallShouldTriggerReactiveAction() { + GatewayScanJob spy = spy(gatewayScanJob); + when(spy.doScanExternalGateway()).thenReturn(Flux.empty()); + + spy.startScanExternalGatewayJob(); + + verify(spy).doScanExternalGateway(); + } + + @Test + void shouldListRegistry() { + ServiceInfo serviceInfo = mock(ServiceInfo.class); + when(gatewayIndexerService.listRegistry(null, null)).thenReturn(singletonMap("testApimlId", singletonList(serviceInfo))); + when(gatewayIndexerService.listRegistry("testApimlId", null)).thenReturn(singletonMap("testApimlId", singletonList(serviceInfo))); + when(gatewayIndexerService.listRegistry(null, "bcm.sysview")).thenReturn(singletonMap("testApimlId", singletonList(serviceInfo))); + + gatewayScanJob.listCaches(); + + verify(gatewayIndexerService).listRegistry(null, null); + verify(gatewayIndexerService).listRegistry("testApimlId", null); + verify(gatewayIndexerService).listRegistry(null, "bcm.sysview"); + } +} diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/BasicInfoServiceTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/BasicInfoServiceTest.java new file mode 100644 index 0000000000..0649f4a296 --- /dev/null +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/BasicInfoServiceTest.java @@ -0,0 +1,139 @@ +/* + * 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.cloudgatewayservice.service; + + +import com.netflix.appinfo.InstanceInfo; +import com.netflix.discovery.EurekaClient; +import com.netflix.discovery.shared.Application; +import com.netflix.discovery.shared.Applications; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.zowe.apiml.auth.AuthenticationScheme; +import org.zowe.apiml.config.ApiInfo; +import org.zowe.apiml.eurekaservice.client.util.EurekaMetadataParser; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.hasProperty; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.AUTHENTICATION_APPLID; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.AUTHENTICATION_SCHEME; +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; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.SERVICE_DESCRIPTION; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.SERVICE_TITLE; + +@ExtendWith(MockitoExtension.class) +class BasicInfoServiceTest { + + // Client test configuration + private final static String CLIENT_SERVICE_ID = "testclient"; + private final static String CLIENT_INSTANCE_ID = CLIENT_SERVICE_ID + ":"; + private final static String CLIENT_HOSTNAME = "client"; + private final static String CLIENT_IP = "192.168.0.1"; + private static final int CLIENT_PORT = 10; + private static final String CLIENT_HOMEPAGE = "https://client:10"; + private static final String CLIENT_RELATIVE_HEALTH_URL = "/actuator/health"; + private static final String CLIENT_STATUS_URL = "https://client:10/actuator/info"; + private static final String CLIENT_API_ID = "zowe.client.api"; + private static final String CLIENT_API_VERSION = "1.0.0"; + private static final String CLIENT_API_GW_URL = "api/v1"; + private static final boolean CLIENT_API_DEFAULT = true; + private static final String CLIENT_API_SWAGGER_URL = CLIENT_HOMEPAGE + "/apiDoc"; + private static final String CLIENT_API_DOC_URL = "https://www.zowe.org"; + private static final String CLIENT_ROUTE_UI = "ui/v1"; + private static final String CLIENT_SERVICE_TITLE = "Client service"; + private static final String CLIENT_SERVICE_DESCRIPTION = "Client test service"; + private static final AuthenticationScheme CLIENT_AUTHENTICATION_SCHEME = AuthenticationScheme.ZOSMF; + private static final String CLIENT_AUTHENTICATION_APPLID = "authid"; + private static final String CLIENT_CUSTOM_METADATA_KEY = "custom.test.key"; + private static final String CLIENT_CUSTOM_METADATA_VALUE = "value"; + + // ServiceInfo properties + private static final String SERVICE_SERVICE_ID = "serviceId"; + + @Mock + private EurekaClient eurekaClient; + + private final EurekaMetadataParser eurekaMetadataParser = new EurekaMetadataParser(); + + private BasicInfoService basicInfoService; + + @BeforeEach + void setUp() { + basicInfoService = new BasicInfoService(eurekaClient, eurekaMetadataParser); + } + + @Test + void whenListingAllServices_thenReturnList() { + String clientServiceId2 = "testclient2"; + + List applications = Arrays.asList( + new Application(CLIENT_SERVICE_ID, Collections.singletonList(createFullTestInstance())), + new Application(clientServiceId2) + ); + when(eurekaClient.getApplications()) + .thenReturn(new Applications(null, 1L, applications)); + + List servicesInfo = basicInfoService.getServicesInfo(); + + + assertEquals(2, servicesInfo.size()); + assertThat(servicesInfo, contains( + hasProperty(SERVICE_SERVICE_ID, is(CLIENT_SERVICE_ID)), + hasProperty(SERVICE_SERVICE_ID, is(clientServiceId2)) + )); + } + + private InstanceInfo createFullTestInstance() { + ApiInfo apiInfo = ApiInfo.builder() + .apiId(CLIENT_API_ID) + .version(CLIENT_API_VERSION) + .gatewayUrl(CLIENT_API_GW_URL) + .isDefaultApi(CLIENT_API_DEFAULT) + .swaggerUrl(CLIENT_API_SWAGGER_URL) + .documentationUrl(CLIENT_API_DOC_URL) + .build(); + Map metadata = EurekaMetadataParser.generateMetadata(CLIENT_SERVICE_ID, apiInfo); + metadata.put(SERVICE_TITLE, CLIENT_SERVICE_TITLE); + metadata.put(SERVICE_DESCRIPTION, CLIENT_SERVICE_DESCRIPTION); + metadata.put(ROUTES + ".ui-v1." + ROUTES_SERVICE_URL, "/"); + metadata.put(ROUTES + ".ui-v1." + ROUTES_GATEWAY_URL, CLIENT_ROUTE_UI); + metadata.put(AUTHENTICATION_SCHEME, CLIENT_AUTHENTICATION_SCHEME.getScheme()); + metadata.put(AUTHENTICATION_APPLID, CLIENT_AUTHENTICATION_APPLID); + metadata.put(CLIENT_CUSTOM_METADATA_KEY, CLIENT_CUSTOM_METADATA_VALUE); + + return InstanceInfo.Builder.newBuilder() + .setAppName(CLIENT_SERVICE_ID) + .setInstanceId(CLIENT_INSTANCE_ID + Math.random()) + .setHostName(CLIENT_HOSTNAME) + .setIPAddr(CLIENT_IP) + .enablePort(InstanceInfo.PortType.SECURE, true) + .setSecurePort(CLIENT_PORT) + .setHomePageUrl(null, CLIENT_HOMEPAGE) + .setHealthCheckUrls(CLIENT_RELATIVE_HEALTH_URL, null, null) + .setStatusPageUrl(null, CLIENT_STATUS_URL) + .setMetadata(metadata) + .build(); + } +} \ No newline at end of file diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/GatewayIndexServiceTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/GatewayIndexServiceTest.java new file mode 100644 index 0000000000..ea016c6901 --- /dev/null +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/GatewayIndexServiceTest.java @@ -0,0 +1,193 @@ +/* + * 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.cloudgatewayservice.service; + + +import io.netty.handler.ssl.SslContext; +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.AbstractMap; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.zowe.apiml.cloudgatewayservice.service.WebClientHelperTest.KEYSTORE_PATH; +import static org.zowe.apiml.cloudgatewayservice.service.WebClientHelperTest.PASSWORD; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.APIML_ID; + +@ExtendWith(MockitoExtension.class) +class GatewayIndexServiceTest { + private GatewayIndexService gatewayIndexService; + private final ParameterizedTypeReference> serviceInfoType = new ParameterizedTypeReference>() { + }; + private ServiceInfo serviceInfoA, serviceInfoB; + private WebClient webClient; + private final String sysviewApiId = "bcm.sysview"; + @Mock + private ClientResponse clientResponse; + @Mock + private ExchangeFunction exchangeFunction; + @Mock + private ServiceInstance eurekaInstance; + + @BeforeEach + public void setUp() { + + lenient().when(eurekaInstance.getMetadata()).thenReturn(Maps.of(APIML_ID, "testApimlIdA")); + lenient().when(eurekaInstance.getInstanceId()).thenReturn("testInstanceIdA"); + + serviceInfoA = new ServiceInfo(); + serviceInfoB = new ServiceInfo(); + + serviceInfoB.setApiml(new ServiceInfo.Apiml()); + ServiceInfo.ApiInfoExtended sysviewApiInfo = new ServiceInfo.ApiInfoExtended(); + sysviewApiInfo.setApiId(sysviewApiId); + + serviceInfoB.getApiml().setApiInfo(Collections.singletonList(sysviewApiInfo)); + + webClient = spy(WebClient.builder().exchangeFunction(exchangeFunction).build()); + gatewayIndexService = new GatewayIndexService(webClient, 60, null, null, null); + } + + @Nested + class WhenIndexingGatewayService { + + @BeforeEach + void setUp() { + lenient().when(exchangeFunction.exchange(any(ClientRequest.class))) + .thenReturn(Mono.just(clientResponse)); + + lenient().when(clientResponse.bodyToMono(serviceInfoType)).thenReturn(Mono.just(Arrays.asList(serviceInfoA, serviceInfoB))); + } + + @Test + void shouldCacheListOfTheServices() { + + StepVerifier.FirstStep> servicesVerifier = StepVerifier.create(gatewayIndexService.indexGatewayServices(eurekaInstance)); + + servicesVerifier + .expectNext(asList(serviceInfoA, serviceInfoB)) + .verifyComplete(); + + verify(exchangeFunction).exchange(any()); + + Map> allServices = gatewayIndexService.listRegistry(null, null); + + assertThat(allServices).containsOnlyKeys("testApimlIdA"); + assertThat(allServices.get("testApimlIdA")).containsExactlyInAnyOrder(serviceInfoA, serviceInfoB); + verifyNoMoreInteractions(exchangeFunction); + } + + @Test + void shouldGenerateSyntheticApimlIdCacheKey() { + when(eurekaInstance.getMetadata()).thenReturn(null); + + StepVerifier.FirstStep> servicesVerifier = StepVerifier.create(gatewayIndexService.indexGatewayServices(eurekaInstance)); + + servicesVerifier + .expectNext(asList(serviceInfoA, serviceInfoB)) + .verifyComplete(); + + Map> allServices = gatewayIndexService.listRegistry(null, null); + + assertThat(allServices).containsOnlyKeys("SUBSTITUTE_testInstanceIdA"); + } + + @Test + void shouldFilterCachedServicesByApiId() { + + StepVerifier.create(gatewayIndexService.indexGatewayServices(eurekaInstance)) + .expectNext(asList(serviceInfoA, serviceInfoB)) + .verifyComplete(); + + Map> allServices = gatewayIndexService.listRegistry(null, sysviewApiId); + + assertThat(allServices).containsOnly(new AbstractMap.SimpleEntry<>("testApimlIdA", Collections.singletonList(serviceInfoB))); + } + + @Test + void shouldReturnEmptyMapForNotExistingApimlId() { + assertThat(gatewayIndexService.listRegistry("unknownId", null)).isEmpty(); + assertThat(gatewayIndexService.listRegistry(null, "unknownApiId")).isEmpty(); + assertThat(gatewayIndexService.listRegistry("unknownId", "unknownApiId")).isEmpty(); + } + } + + @Nested + class WhenUsingCustomClientKey { + + @Test + void shouldInitializeCustomSslContext() { + + gatewayIndexService = new GatewayIndexService(webClient, 60, KEYSTORE_PATH, PASSWORD, "PKCS12"); + + SslContext customClientSslContext = (SslContext) ReflectionTestUtils.getField(gatewayIndexService, "customClientSslContext"); + + assertThat(customClientSslContext).isNotNull(); + } + + @Test + void shouldNotUseDefaultWebClientWhenCustomContextIdProvided() { + gatewayIndexService = new GatewayIndexService(webClient, 60, KEYSTORE_PATH, PASSWORD, "PKCS12"); + + StepVerifier.create(gatewayIndexService.indexGatewayServices(eurekaInstance)) + .verifyComplete(); + + verifyNoInteractions(webClient); + } + + @Test + void shouldSkipCustomSslContextCreationIfPasswordNotDefined() { + + gatewayIndexService = new GatewayIndexService(webClient, 60, KEYSTORE_PATH, null, null); + + SslContext customClientSslContext = (SslContext) ReflectionTestUtils.getField(gatewayIndexService, "customClientSslContext"); + + assertThat(customClientSslContext).isNull(); + } + + @Test + void shouldUseDefaultWebClientWhenCustomSslContextIsNotProvided() { + gatewayIndexService = new GatewayIndexService(webClient, 60, null, null, null); + + StepVerifier.create(gatewayIndexService.indexGatewayServices(eurekaInstance)) + .verifyComplete(); + + verify(webClient).mutate(); + } + + } +} \ No newline at end of file diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/WebClientHelperTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/WebClientHelperTest.java new file mode 100644 index 0000000000..b233623b56 --- /dev/null +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/WebClientHelperTest.java @@ -0,0 +1,48 @@ +/* + * 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.cloudgatewayservice.service; + +import io.netty.handler.ssl.SslContext; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.zowe.apiml.security.HttpsConfigError; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowableOfType; +import static org.zowe.apiml.security.HttpsConfigError.ErrorCode.HTTP_CLIENT_INITIALIZATION_FAILED; + +class WebClientHelperTest { + static final char[] PASSWORD = "password".toCharArray(); // NOSONAR + private static final char[] WRONG_PASSWORD = "wrong_password".toCharArray(); // NOSONAR + static final String KEYSTORE_PATH = "../keystore/localhost/localhost.keystore.p12"; + + @Nested + class WhenLoading { + @Test + void givenWrongPath_thenThrowException() { + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> WebClientHelper.load("../wrong/path", PASSWORD, "PKCS12")); + } + + @Test + void givenCorrectPath_thenLoadSSLContext() { + final SslContext sslContext = WebClientHelper.load(KEYSTORE_PATH, PASSWORD, "PKCS12"); + assertThat(sslContext).isNotNull(); + } + + @Test + void givenWrongPassword_httpConfigErrorIsExpected() { + HttpsConfigError error = catchThrowableOfType(() -> WebClientHelper.load("../keystore/localhost/localhost.keystore.p12", WRONG_PASSWORD, "PKCS12"), HttpsConfigError.class); + assertThat(error.getCode()).isEqualTo(HTTP_CLIENT_INITIALIZATION_FAILED); + assertThat(error.getConfig()).isNull(); + } + } +} diff --git a/cloud-gateway-service/src/test/resources/application.yml b/cloud-gateway-service/src/test/resources/application.yml index f6811128b6..90fb74edc7 100644 --- a/cloud-gateway-service/src/test/resources/application.yml +++ b/cloud-gateway-service/src/test/resources/application.yml @@ -9,9 +9,12 @@ apiml: id: cloud-gateway port: 10023 hostname: localhost + scheme: https corsEnabled: true 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 forwardClientCertEnabled: false + cloudGateway: + serviceRegistryEnabled: false server: port: ${apiml.service.port} ssl: diff --git a/common-service-core/src/main/java/org/zowe/apiml/security/HttpsConfigError.java b/common-service-core/src/main/java/org/zowe/apiml/security/HttpsConfigError.java index 74a31c2345..ec5e316fde 100644 --- a/common-service-core/src/main/java/org/zowe/apiml/security/HttpsConfigError.java +++ b/common-service-core/src/main/java/org/zowe/apiml/security/HttpsConfigError.java @@ -45,6 +45,12 @@ public HttpsConfigError(Throwable cause, ErrorCode code, HttpsConfig config) { this.config = config; } + public HttpsConfigError(String message, Throwable cause, ErrorCode code) { + super(message, cause); + this.code = code; + this.config = null; + } + public ErrorCode getCode() { return this.code; } diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/services/ServerInfoConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/services/ServerInfoConfig.java index de0ee3f94f..edc454098c 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/services/ServerInfoConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/services/ServerInfoConfig.java @@ -26,7 +26,6 @@ public EurekaMetadataParser getEurekaMetadataParser() { return new EurekaMetadataParser(); } - @Bean public ServicesInfoService servicesInfoService(EurekaClient eurekaClient, EurekaMetadataParser eurekaMetadataParser, GatewayConfigProperties gatewayConfigProperties, TransformService transformService) { From e30e2c120773018183a42189d1dc1e8a20336332 Mon Sep 17 00:00:00 2001 From: alexandr cumarav Date: Tue, 5 Sep 2023 15:09:57 +0200 Subject: [PATCH 2/2] GH2883 Cloud gateway service functionality Signed-off-by: alexandr cumarav --- .../src/main/resources/bin/start.sh | 1 + cloud-gateway-service/build.gradle | 1 - .../src/main/resources/application.yml | 3 +- .../src/main/resources/logback.xml | 51 ------------------- .../src/test/resources/application.yml | 2 +- 5 files changed, 3 insertions(+), 55 deletions(-) delete mode 100644 cloud-gateway-service/src/main/resources/logback.xml diff --git a/cloud-gateway-package/src/main/resources/bin/start.sh b/cloud-gateway-package/src/main/resources/bin/start.sh index d1c7ec25bf..18513eb8aa 100755 --- a/cloud-gateway-package/src/main/resources/bin/start.sh +++ b/cloud-gateway-package/src/main/resources/bin/start.sh @@ -99,6 +99,7 @@ _BPX_JOBNAME=${ZWE_zowe_job_prefix}${CLOUD_GATEWAY_CODE} java \ -Dapiml.logs.location=${ZWE_zowe_logDirectory} \ -Dapiml.zoweManifest=${ZWE_zowe_runtimeDirectory}/manifest.json \ -Dserver.address=0.0.0.0 \ + -Dapiml.service.apimlId=${ZWE_configs_apimlId:-} \ -Deureka.client.serviceUrl.defaultZone=${ZWE_DISCOVERY_SERVICES_LIST:-"https://${ZWE_haInstance_hostname:-localhost}:${ZWE_components_discovery_port:-7553}/eureka/"} \ -Dserver.ssl.enabled=${ZWE_configs_server_ssl_enabled:-true} \ -Dserver.maxConnectionsPerRoute=${ZWE_configs_server_maxConnectionsPerRoute:-100} \ diff --git a/cloud-gateway-service/build.gradle b/cloud-gateway-service/build.gradle index 643d9eed20..327ccefaa6 100644 --- a/cloud-gateway-service/build.gradle +++ b/cloud-gateway-service/build.gradle @@ -82,7 +82,6 @@ dependencies { implementation libs.spring.expression implementation libs.bcpkix implementation libs.nimbusJoseJwt - implementation libs.janino compileOnly libs.lombok annotationProcessor libs.lombok diff --git a/cloud-gateway-service/src/main/resources/application.yml b/cloud-gateway-service/src/main/resources/application.yml index 19b607ee88..0be73f0240 100644 --- a/cloud-gateway-service/src/main/resources/application.yml +++ b/cloud-gateway-service/src/main/resources/application.yml @@ -15,7 +15,7 @@ apiml: id: cloud-gateway port: 10023 hostname: localhost - scheme: https # "https" or "http" + scheme: https corsEnabled: true 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 forwardClientCertEnabled: false @@ -49,7 +49,6 @@ server: main: allow-circular-references: true -logbackServiceName: ZWEACG1 logging: level: diff --git a/cloud-gateway-service/src/main/resources/logback.xml b/cloud-gateway-service/src/main/resources/logback.xml deleted file mode 100644 index 999077ccc1..0000000000 --- a/cloud-gateway-service/src/main/resources/logback.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - ${apimlLogPattern} - - - - - - ${STORAGE_LOCATION}/${logbackServiceName}.log - - - ${STORAGE_LOCATION}/${logbackServiceName}.%i.log - ${MIN_INDEX} - ${MAX_INDEX} - - - - ${MAX_FILE_SIZE} - - - - - ${apimlLogPattern} - - - - - - - - - \ No newline at end of file diff --git a/cloud-gateway-service/src/test/resources/application.yml b/cloud-gateway-service/src/test/resources/application.yml index 90fb74edc7..b2a08791bb 100644 --- a/cloud-gateway-service/src/test/resources/application.yml +++ b/cloud-gateway-service/src/test/resources/application.yml @@ -9,8 +9,8 @@ apiml: id: cloud-gateway port: 10023 hostname: localhost - scheme: https corsEnabled: true + scheme: https 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 forwardClientCertEnabled: false cloudGateway: