diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 9a0da63..abea618 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -13,3 +13,23 @@ include:
variables:
PUSH_TO_HARBOR: "true"
MTR_TARGET_IMAGE: ${CI_PROJECT_NAME}
+
+build_deploy:
+ tags:
+ - otc_run_sysbox_m
+ variables:
+ DOCKER_HOST: "tcp://docker:2375"
+ DOCKER_TLS_CERTDIR: ""
+ DOCKER_DRIVER: overlay2
+ services:
+ - name: 'dockerhub.devops.telekom.de/docker:20.10.23-dind'
+ command: [ '--tls=false', '--registry-mirror=https://dockerhub.devops.telekom.de' ]
+ alias: docker
+
+code_quality:
+ tags:
+ - otc_run_sysbox_m
+ services:
+ - name: 'dockerhub.devops.telekom.de/docker:20.10.12-dind'
+ command: ['--tls=false', '--host=tcp://0.0.0.0:2375', '--registry-mirror=https://dockerhub.devops.telekom.de']
+ alias: docker
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index df16de7..64b3805 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,145 @@
# Changelog
+## [3.14.3](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.14.2...3.14.3) (2024-05-30)
+
+
+### ๐ฆ CI/CD
+
+* **dhei-00000:** token span name evaluation ([0fe3506](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/0fe3506a62944c8b51eab84e6432eec6ef0ea74a))
+
+
+### ๐ Fixes
+
+* token span name evaluation ([fbbc297](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/fbbc297ab7d78a8ab79226f92742f98fc2a1bfb0))
+
+
+### Other
+
+* **release:** 3.14.2 ([f531bd2](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/f531bd2c5819dc4b208729e0dbe2518e4736c266))
+
+## [3.14.2](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.14.1...3.14.2) (2024-05-28)
+
+
+### ๐ Fixes
+
+* DHEI-15534 redis tests for zone health ([54a7e92](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/54a7e92dc443cb773dd0d6b2bd45cf141147607a))
+* redis tests for zone health ([56bbd1c](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/56bbd1c47a243b1b2cb8bb52805e3ce6ab2ca79f))
+
+
+### Other
+
+* **release:** 3.14.1 ([4036226](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/4036226b8d2cc69aac0f8e86dfd9d90e2594c132))
+
+## [3.14.1](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.14.0...3.14.1) (2024-05-21)
+
+
+### ๐ Fixes
+
+* async subscribe to redis channel ([e7d86bd](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/e7d86bdda45cf2dfe08f68b3e91cb90c15d5b155))
+* DHEI-15534 async subscribe to redis channel ([652b07b](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/652b07b0ce8224d47a8354e35c7e272607b973f0))
+
+
+### Other
+
+* **release:** 3.14.0 ([f4841c2](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/f4841c2a42e74db3616962f889d0bbc4d4b1d3d1))
+
+## [3.14.0](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.13.0...3.14.0) (2024-05-21)
+
+
+### :scissors: Refactor
+
+* removed "Bearer " ([7c49271](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/7c492715ea8a4fbe66678eb1d6ff02fb1b407b90))
+
+
+### ๐ Features
+
+* Added x-token-exchange header handling ([160e12f](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/160e12fd071fd2cc8bad21ab5bc6163a09534d30))
+* changed from targetZone to currentZone (env variable) ([517af44](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/517af44f0ec6fb4f4990b386672d18f02658edc7))
+* DHEI-15383 Added x-token-exchange header handling ([f89a586](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/f89a586a30c4517c029aaedf8e2fed3cf52e62cb))
+
+
+### Other
+
+* **release:** 3.13.0 ([7eba358](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/7eba358cd4b7ed7b9a0a7e76863172810bded0f6))
+
+## [3.13.0](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.12.0...3.13.0) (2024-05-16)
+
+
+### ๐ Features
+
+* DHEI-15534 Introduce redis based zone health ([13012f1](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/13012f1b279243041227bed38538377801d6a0d7))
+* DHEI-15534 Introduce redis based zone health ([8a580a0](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/8a580a059c15f0ee4f11af032bcdba5c96f2797b))
+
+
+### Other
+
+* **release:** 3.12.0 ([8b675cc](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/8b675cc09935b6fcf5403b929985c30f2c4a02a3))
+
+## [3.12.0](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.11.0...3.12.0) (2024-05-03)
+
+
+### :scissors: Refactor
+
+* logging revised ([1d750c9](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/1d750c9a90b20a87c91b3c7fb8ea4ee004b6cdb5))
+* merge RoutingConfig to JumperConfig ([28591ef](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/28591efc6a9d460972c1892f97e1fc350768246e))
+* targetZone variable rename ([565c119](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/565c11946a905fd0a2db23c19223bd12dd42a221))
+
+
+### ๐ Style
+
+* spotless friendly ([60958ae](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/60958aeb37773b63772280899f5e5e121239b1a9))
+
+
+### ๐ฆ CI/CD
+
+* **dhei-15533:** provider failover (routing part) ([cb08c2e](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/cb08c2eda4e2d70c12280c31a85a1b9a419a7c14))
+
+
+### ๐งช Tests
+
+* added tests for zone failover (routing part) ([dd31ce5](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/dd31ce5cc9ecb84668e5ea6db019e30d2974b669))
+
+
+### ๐ Features
+
+* audit log for failover, refactoring of duplicate methods ([40afd74](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/40afd740095f71758c3adcb7a6f7eb7a0e36ea18))
+* error span draft ([358f2e8](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/358f2e8f0140cdc3cf58746025c27964c4b59f2e))
+* support Spectre related jumperConfig for failover ([84eef25](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/84eef25ebc58d3363602ec033811787e3616f054))
+
+
+### ๐ Fixes
+
+* set scope for error span ([6db6030](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/6db60303d227b2d332b808aa8d21d52b23025fe1))
+* span names adjustment ([827f398](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/827f3985c9379dbc4abf1a3b23dcb59a7447521e))
+
+
+### Other
+
+* **release:** 3.11.0 ([be70acd](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/be70acd49ef655faeb4508934368f80d1f10eddb))
+
+## [3.11.0](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.10.0...3.11.0) (2024-04-19)
+
+
+### ๐ฆ CI/CD
+
+* **dhei-12345:** graceful shutdown, enable pool metrics by default, configurable oauth pool ([28edc5e](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/28edc5e8008ccc2b3f32700332b54547c4514a1e))
+
+
+### ๐ Features
+
+* configurable oauth, pool metrics enable by default ([5539f0f](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/5539f0fb5c4d19f24d4dcd60d696261316b97e58))
+
+
+### ๐ Fixes
+
+* increase server idle-timeout ([e6149f6](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/e6149f6332e1424c504f4a7994289d6f97f70007))
+* use graceful shutdown ([5d70e45](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/5d70e451ff79b9d2c4cc3f161a9713bdd55f35cc))
+
+
+### Other
+
+* **release:** 3.10.0 ([f9af081](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/commit/f9af0813c0b21e242a480a6b68421d8e2dedd6c8))
+
## [3.10.0](https://gitlab.devops.telekom.de/dhei/teams/hyperion/dev/src/jumper-sse/compare/3.9.0...3.10.0) (2024-02-27)
diff --git a/README.md b/README.md
index b1d7e8c..b89bf2d 100644
--- a/README.md
+++ b/README.md
@@ -202,6 +202,10 @@ Headers expected on incoming side:
}
}
``
+
+#### X-Token-Exchange header
+Spacegate allows for more flexibility by supporting the Authorization header token exchange. A consumer can set the "X-Token-Exchange" header containing an external provider specific token while calling an exposed external API. Jumper will then store value of the X-Token-Exchange header in the Authorization header field of the external API request and forward it to the provider. Available only for Spacegate.
+``
### Scenarios from used route perspective
#### Proxy route
Default route to be used for processing majority off traffic. All scenarios described within "token perspective" are supported.
@@ -278,6 +282,13 @@ Request/Response events are created, if consumer/API combination matches. Creat
}
``
+### Zone failover
+If enabled, Jumper ensures that in case of a zone failure requests to that zone are re-routed to the configured failover-zone.
+Following picture depicts how Jumper processes requests in case of zone failover:
+
+
+``
+
### Header enhancement/manipulation
* X-Spacegate-Token - if any Spacegate is involved, incoming token is copied to X-Spacegate-Token header
* X-Forwarded-Host/Port/Proto - to avoid additional reporting Kong + Jumper as separate hop, these headers needs to be adapted
diff --git a/pictures/jumper_request_processing_with_failover.png b/pictures/jumper_request_processing_with_failover.png
new file mode 100644
index 0000000..83d7d96
Binary files /dev/null and b/pictures/jumper_request_processing_with_failover.png differ
diff --git a/pictures/jumper_request_processing_with_failover.png.license b/pictures/jumper_request_processing_with_failover.png.license
new file mode 100644
index 0000000..3b62d58
--- /dev/null
+++ b/pictures/jumper_request_processing_with_failover.png.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2023 Deutsche Telekom AG
+
+SPDX-License-Identifier: CC-BY-4.0
diff --git a/pom.xml b/pom.xml
index 99b048c..020e4d4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -13,7 +13,7 @@ SPDX-License-Identifier: Apache-2.0
de.telekom.ei.jumper
jumper-sse
- 3.10.0
+ 3.14.3
org.springframework.boot
@@ -24,11 +24,13 @@ SPDX-License-Identifier: Apache-2.0
17
2021.0.8
+ 6.3.2.RELEASE
7.14.0
2020.0.37
4.1.100.Final
5.9.3
1.10.0
+ 1.19.8
@@ -167,6 +169,26 @@ SPDX-License-Identifier: Apache-2.0
test
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+
+ org.springframework.data
+ spring-data-redis
+
+
+
+ io.lettuce
+ lettuce-core
+ ${redis-lettuce.version}
+
+
+ org.springframework.retry
+ spring-retry
+
+
org.junit.jupiter
junit-jupiter-engine
@@ -227,6 +249,24 @@ SPDX-License-Identifier: Apache-2.0
test
+
+ org.testcontainers
+ testcontainers
+ ${testcontainers.version}
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ ${testcontainers.version}
+ test
+
+
+ org.awaitility
+ awaitility
+ test
+
+
com.diffplug.spotless
@@ -284,6 +324,7 @@ SPDX-License-Identifier: Apache-2.0
maven-surefire-plugin
ignore
+ true
diff --git a/src/main/java/jumper/Application.java b/src/main/java/jumper/Application.java
index 1490826..b8aa9a8 100644
--- a/src/main/java/jumper/Application.java
+++ b/src/main/java/jumper/Application.java
@@ -51,6 +51,7 @@ public RouteLocator proxyRoute(
new HashSet<>(
Arrays.asList(
Constants.HEADER_JUMPER_CONFIG,
+ Constants.HEADER_ROUTING_CONFIG,
Constants.HEADER_TOKEN_ENDPOINT,
Constants.HEADER_REMOTE_API_URL,
Constants.HEADER_ISSUER,
diff --git a/src/main/java/jumper/Constants.java b/src/main/java/jumper/Constants.java
index 1849de0..e65e0c0 100644
--- a/src/main/java/jumper/Constants.java
+++ b/src/main/java/jumper/Constants.java
@@ -15,6 +15,8 @@ public class Constants {
public static final String HEADER_X_SPACEGATE_CLIENT_SECRET = "X-Spacegate-Client-Secret";
public static final String HEADER_X_SPACEGATE_SCOPE = "X-Spacegate-Scope";
public static final String HEADER_JUMPER_CONFIG = "jumper_config";
+ public static final String HEADER_ROUTING_CONFIG = "routing_config";
+
public static final String HEADER_ISSUER = "issuer";
public static final String HEADER_TOKEN_ENDPOINT = "token_endpoint";
public static final String HEADER_CLIENT_ID = "client_id";
@@ -44,6 +46,7 @@ public class Constants {
public static final String HEADER_X_PUBSUB_SUBSCRIBER_ID = "x-pubsub-subscriber-id";
public static final String HEADER_B3 = "b3";
public static final String HEADER_X_SPACEGATE_TOKEN = "X-Spacegate-Token";
+ public static final String HEADER_X_TOKEN_EXCHANGE = "X-Token-Exchange";
public static final String HEADER_API_BASE_PATH = "api_base_path";
public static final String HEADER_X_FORWARDED_HOST = "X-Forwarded-Host";
public static final String HEADER_X_FORWARDED_PORT = "X-Forwarded-Port";
@@ -51,6 +54,8 @@ public class Constants {
public static final String HEADER_X_FORWARDED_PORT_PORT = "443";
public static final String HEADER_X_FORWARDED_PROTO_HTTPS = "https";
+ public static final String HEADER_X_FAILOVER_SKIP_ZONE = "x-failover-skip-zone";
+
public static final String QUERY_PARAM_LISTENER = "listener";
public static final String LISTENER_ROOT_PATH_PREFIX = "/listener";
public static final String PROXY_ROOT_PATH_PREFIX = "/proxy";
@@ -85,7 +90,7 @@ public class Constants {
public static final String TOKEN_CLAIM_ACCESS_TOKEN_PUBLISHER_ID = "publisherId";
public static final String TOKEN_CLAIM_ACCESS_TOKEN_SUBSCRIBER_ID = "subscriberId";
- public static final List SPACE_ZONES = List.of("space", "spacex", "canis", "aries");
+ public static final List SPACE_ZONES = List.of("space", "canis", "aries");
public static final String BASIC_AUTH_PROVIDER_KEY = "default";
diff --git a/src/main/java/jumper/config/HttpClientConfiguration.java b/src/main/java/jumper/config/HttpClientConfiguration.java
index 24b1270..11a808a 100644
--- a/src/main/java/jumper/config/HttpClientConfiguration.java
+++ b/src/main/java/jumper/config/HttpClientConfiguration.java
@@ -34,6 +34,18 @@ public class HttpClientConfiguration {
@Value("${CUSTOM_CIPHERS:}")
List customCiphers;
+ @Value("${spring.cloud.oauth.connect-timeout:10000}")
+ private int oauthConnectTimeout;
+
+ @Value("${spring.cloud.oauth.pool.max-life-time:300}")
+ private int oauthPoolMaxLifeTime;
+
+ @Value("${spring.cloud.oauth.pool.max-idle-time:2}")
+ private int oauthPoolMaxIdleTime;
+
+ @Value("${spring.cloud.oauth.pool.metrics:true}")
+ private boolean oauthPoolMetrics;
+
private final HttpClientProperties properties;
@Bean
@@ -53,7 +65,7 @@ public WebClient createWebClientForOauthTokenUtil() throws SSLException {
HttpClient httpClient =
HttpClient.create(getProvider())
.secure(t -> t.sslContext(sslContext))
- .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
+ .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, oauthConnectTimeout);
httpClient = configureProxy(httpClient);
return WebClient.builder().clientConnector(new ReactorClientHttpConnector(httpClient)).build();
@@ -131,9 +143,10 @@ private ProxyProvider.Builder configureProxyProvider(
private ConnectionProvider getProvider() {
return ConnectionProvider.builder("oauth")
.maxConnections(100)
- .maxIdleTime(Duration.ofSeconds(5))
- .maxLifeTime(Duration.ofSeconds(60))
+ .maxIdleTime(Duration.ofSeconds(oauthPoolMaxIdleTime))
+ .maxLifeTime(Duration.ofSeconds(oauthPoolMaxLifeTime))
.pendingAcquireMaxCount(-1)
+ .metrics(oauthPoolMetrics)
.build();
}
}
diff --git a/src/main/java/jumper/config/RedisConfig.java b/src/main/java/jumper/config/RedisConfig.java
new file mode 100644
index 0000000..5611b60
--- /dev/null
+++ b/src/main/java/jumper/config/RedisConfig.java
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package jumper.config;
+
+import io.lettuce.core.metrics.MicrometerOptions;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.listener.RedisMessageListenerContainer;
+
+@Slf4j
+@Configuration
+@ConditionalOnProperty(value = "jumper.zone.health.enabled", havingValue = "true")
+public class RedisConfig {
+
+ // in newer versions this should not prevent app startup on missing redis connection
+ // https://stackoverflow.com/questions/72436006/spring-boot-2-7-redis-pub-sub-fails-startup-on-missing-redis-connection
+ @Bean
+ public RedisMessageListenerContainer redisContainer(
+ LettuceConnectionFactory lettuceConnectionFactory) {
+ RedisMessageListenerContainer container = new RedisMessageListenerContainer();
+ container.setConnectionFactory(lettuceConnectionFactory);
+ // the following causes the app to fail on startup if redis is not available
+ // container.addMessageListener(statusService, new ChannelTopic(statusService.getChannelKey()));
+
+ return container;
+ }
+
+ @Bean
+ MicrometerOptions micrometerOptions() {
+ return MicrometerOptions.builder().histogram(false).build();
+ }
+}
diff --git a/src/main/java/jumper/config/SleuthConfiguration.java b/src/main/java/jumper/config/SleuthConfiguration.java
index 489a531..34d709f 100644
--- a/src/main/java/jumper/config/SleuthConfiguration.java
+++ b/src/main/java/jumper/config/SleuthConfiguration.java
@@ -27,19 +27,20 @@ HttpResponseParser httpResponseParser() {
@Bean(name = HttpClientRequestParser.NAME)
HttpRequestParser httpRequestParser() {
return (request, context, span) -> {
- String url = request.url();
String xTardisTraceId = request.header(Constants.HEADER_X_TARDIS_TRACE_ID);
- String spanName = "Provider";
- if (request.header(Constants.HEADER_CONSUMER_TOKEN) != null) {
+ String spanName;
+ if (request.path().contains("token")) {
+ spanName = "Idp";
+ } else if (request.header(Constants.HEADER_CONSUMER_TOKEN) != null) {
spanName = "Gateway";
+ } else {
+ spanName = "Provider";
}
span.name("Outgoing Request: " + spanName);
- if (url != null) {
- span.tag("http.url", url);
- }
+ span.tag("http.url", request.url());
if (xTardisTraceId != null) {
span.tag(Constants.HEADER_X_TARDIS_TRACE_ID, xTardisTraceId);
diff --git a/src/main/java/jumper/exception/JsonErrorWebExceptionHandler.java b/src/main/java/jumper/exception/JsonErrorWebExceptionHandler.java
index 4ef75d9..95985d9 100644
--- a/src/main/java/jumper/exception/JsonErrorWebExceptionHandler.java
+++ b/src/main/java/jumper/exception/JsonErrorWebExceptionHandler.java
@@ -19,6 +19,7 @@
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.cloud.sleuth.CurrentTraceContext;
+import org.springframework.cloud.sleuth.Span;
import org.springframework.cloud.sleuth.Tracer;
import org.springframework.cloud.sleuth.instrument.web.WebFluxSleuthOperators;
import org.springframework.context.ApplicationContext;
@@ -94,6 +95,12 @@ protected Map getErrorAttributes(
? request.headers().firstHeader(Constants.HEADER_X_TARDIS_TRACE_ID)
: "");
+ WebFluxSleuthOperators.withSpanInScope(
+ tracer,
+ currentTraceContext,
+ request.exchange(),
+ () -> writeErrorSpan(error, errorAttributes));
+
// should also evaluate include options (stacktrace, message, bindingErrors)
return errorAttributes;
}
@@ -198,4 +205,17 @@ private String determineMessage(
}
}
}
+
+ private void writeErrorSpan(Throwable error, Map errorAttributes) {
+ Span errorSpan = this.tracer.nextSpan().name("error").start();
+ tracer.withSpan(errorSpan);
+
+ errorSpan.tag("message", (String) errorAttributes.get("message"));
+ errorSpan.tag("http.status_code", errorAttributes.get("status").toString());
+ errorSpan.tag("http.method", (String) errorAttributes.get("method"));
+ errorSpan.tag("x-tardis-traceid", (String) errorAttributes.get("tardisTraceId"));
+ errorSpan.error(error);
+
+ errorSpan.end();
+ }
}
diff --git a/src/main/java/jumper/filter/RequestFilter.java b/src/main/java/jumper/filter/RequestFilter.java
index 82c0787..96ba2f8 100644
--- a/src/main/java/jumper/filter/RequestFilter.java
+++ b/src/main/java/jumper/filter/RequestFilter.java
@@ -8,7 +8,7 @@
import java.net.URI;
import java.net.URISyntaxException;
-import java.util.HashMap;
+import java.util.List;
import java.util.Objects;
import java.util.Optional;
import jumper.Constants;
@@ -18,11 +18,10 @@
import jumper.model.config.OauthCredentials;
import jumper.model.request.IncomingRequest;
import jumper.model.request.JumperInfoRequest;
-import jumper.service.BasicAuthUtil;
-import jumper.service.HeaderUtil;
-import jumper.service.OauthTokenUtil;
+import jumper.service.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
+import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
@@ -43,16 +42,21 @@
@Component
@Slf4j
+@Setter
public class RequestFilter extends AbstractGatewayFilterFactory {
private final CurrentTraceContext currentTraceContext;
private final Tracer tracer;
private final OauthTokenUtil oauthTokenUtil;
private final BasicAuthUtil basicAuthUtil;
+ private final ZoneHealthCheckService zoneHealthCheckService;
@Value("${jumper.issuer.url}")
private String localIssuerUrl;
+ @Value("${jumper.zone.name}")
+ private String currentZone;
+
@Value("${spring.application.name}")
private String applicationName;
@@ -63,12 +67,14 @@ public RequestFilter(
CurrentTraceContext currentTraceContext,
Tracer tracer,
OauthTokenUtil oauthTokenUtil,
- BasicAuthUtil basicAuthUtil) {
+ BasicAuthUtil basicAuthUtil,
+ ZoneHealthCheckService zoneHealthCheckService) {
super(Config.class);
this.currentTraceContext = currentTraceContext;
this.tracer = tracer;
this.oauthTokenUtil = oauthTokenUtil;
this.basicAuthUtil = basicAuthUtil;
+ this.zoneHealthCheckService = zoneHealthCheckService;
}
@Override
@@ -81,17 +87,36 @@ public GatewayFilter apply(Config config) {
exchange,
() -> {
ServerHttpRequest request = exchange.getRequest();
+ addTracingInfo(request);
+
+ JumperConfig jumperConfig;
+ // failover logic if routing_config header present
+ if (request.getHeaders().containsKey(Constants.HEADER_ROUTING_CONFIG)) {
+ // evaluate routingConfig for failover scenario
+ List jumperConfigList =
+ JumperConfig.parseJumperConfigListFrom(request);
+ log.debug("failover case, routing_config: {}", jumperConfigList);
+ jumperConfig =
+ evaluateTargetZone(
+ jumperConfigList,
+ request.getHeaders().getFirst(Constants.HEADER_X_FAILOVER_SKIP_ZONE));
+ jumperConfig.fillProcessingInfo(request);
+ log.debug("failover case, enhanced jumper_config: {}", jumperConfig);
- // checking to prevent later nullPointer on inconsistent state from Kong
- if (!request.getHeaders().containsKey(Constants.HEADER_REMOTE_API_URL)) {
- throw new RuntimeException(
- "missing mandatory header " + Constants.HEADER_REMOTE_API_URL);
}
- // Prepare and extract JumperConfigValues
- JumperConfig jumperConfig = JumperConfig.parseConfigFrom(request);
- log.debug("JumperConfig encodedAsBase64: {}", JumperConfig.toBase64(jumperConfig));
- log.debug("JumperConfig decoded: {}", jumperConfig);
+ // no failover
+ else {
+ // checking to prevent later nullPointer on inconsistent state from Kong
+ if (!request.getHeaders().containsKey(Constants.HEADER_REMOTE_API_URL)) {
+ throw new RuntimeException(
+ "missing mandatory header " + Constants.HEADER_REMOTE_API_URL);
+ }
+
+ // Prepare and extract JumperConfigValues
+ jumperConfig = JumperConfig.parseAndFillJumperConfigFrom(request);
+ log.debug("JumperConfig decoded: {}", jumperConfig);
+ }
// calculate routing stuff and add it to exchange and JumperConfig
calculateRoutingStuff(request, exchange, config.getRoutePathPrefix(), jumperConfig);
@@ -104,6 +129,11 @@ public GatewayFilter apply(Config config) {
.put(Constants.HEADER_JUMPER_CONFIG, JumperConfig.toBase64(jumperConfig));
}
+ // write audit log if needed
+ if (jumperConfig.getAuditLog()) {
+ AuditLogService.writeFailoverAuditLog(jumperConfig);
+ }
+
// handle request
Optional jumperInfoRequest =
initializeJumperInfoRequest(jumperConfig);
@@ -116,7 +146,7 @@ public GatewayFilter apply(Config config) {
// GW-2-GW MESH TOKEN GENERATION
log.debug("----------------GATEWAY MESH-------------");
jumperInfoRequest.ifPresent(
- i -> i.setInfoScenario(false, false, true, false, false));
+ i -> i.setInfoScenario(false, false, true, false, false, false));
TokenInfo meshTokenInfo =
oauthTokenUtil.getInternalMeshAccessToken(jumperConfig);
@@ -137,100 +167,112 @@ public GatewayFilter apply(Config config) {
} else {
// ALL NON MESH SCENARIOS
- Optional basicAuthCredentials =
- jumperConfig.getBasicAuthCredentials();
- if (basicAuthCredentials.isPresent()) {
- // External Authorization with BasicAuth
- log.debug("----------------BASIC AUTH HEADER-------------");
- jumperInfoRequest.ifPresent(
- i -> i.setInfoScenario(false, false, false, false, true));
+ if (request.getHeaders().containsKey(Constants.HEADER_X_TOKEN_EXCHANGE)
+ && isSpaceZone(currentZone)) {
- String encodedBasicAuth =
- basicAuthUtil.encodeBasicAuth(
- basicAuthCredentials.get().getUsername(),
- basicAuthCredentials.get().getPassword());
+ log.debug("----------------X-TOKEN-EXCHANGE HEADER-------------");
+ jumperInfoRequest.ifPresent(
+ i -> i.setInfoScenario(false, false, false, false, false, true));
- HeaderUtil.addHeader(
- exchange,
- Constants.HEADER_AUTHORIZATION,
- Constants.BASIC + " " + encodedBasicAuth);
+ addXtokenExchange(exchange);
} else {
- if (Objects.nonNull(jumperConfig.getExternalTokenEndpoint())) {
- // External Authorization with OAuth
- log.debug("----------------EXTERNAL AUTHORIZATION-------------");
- log.debug(
- "Remote TokenEndpoint is set to: {}",
- jumperConfig.getExternalTokenEndpoint());
+ Optional basicAuthCredentials =
+ jumperConfig.getBasicAuthCredentials();
+ if (basicAuthCredentials.isPresent()) {
+ // External Authorization with BasicAuth
+ log.debug("----------------BASIC AUTH HEADER-------------");
jumperInfoRequest.ifPresent(
- i -> i.setInfoScenario(false, false, false, true, false));
+ i -> i.setInfoScenario(false, false, false, false, true, false));
- Optional oauthCredentials =
- jumperConfig.getOauthCredentials();
- if (oauthCredentials.isPresent()
- && StringUtils.isNotBlank(oauthCredentials.get().getGrantType())) {
+ String encodedBasicAuth =
+ basicAuthUtil.encodeBasicAuth(
+ basicAuthCredentials.get().getUsername(),
+ basicAuthCredentials.get().getPassword());
- TokenInfo tokenInfo =
- oauthTokenUtil.getAccessTokenWithOauthCredentialsObject(
- jumperConfig.getExternalTokenEndpoint(),
- oauthCredentials.get(),
- jumperConfig.getConsumer());
+ HeaderUtil.addHeader(
+ exchange,
+ Constants.HEADER_AUTHORIZATION,
+ Constants.BASIC + " " + encodedBasicAuth);
+
+ } else {
+
+ if (Objects.nonNull(jumperConfig.getExternalTokenEndpoint())) {
+ // External Authorization with OAuth
+ log.debug("----------------EXTERNAL AUTHORIZATION-------------");
+ log.debug(
+ "Remote TokenEndpoint is set to: {}",
+ jumperConfig.getExternalTokenEndpoint());
+ jumperInfoRequest.ifPresent(
+ i -> i.setInfoScenario(false, false, false, true, false, false));
+
+ Optional oauthCredentials =
+ jumperConfig.getOauthCredentials();
+ if (oauthCredentials.isPresent()
+ && StringUtils.isNotBlank(oauthCredentials.get().getGrantType())) {
+
+ TokenInfo tokenInfo =
+ oauthTokenUtil.getAccessTokenWithOauthCredentialsObject(
+ jumperConfig.getExternalTokenEndpoint(),
+ oauthCredentials.get(),
+ jumperConfig.getConsumer());
+
+ HeaderUtil.addHeader(
+ exchange,
+ Constants.HEADER_AUTHORIZATION,
+ Constants.BEARER + " " + tokenInfo.getAccessToken());
+
+ } else {
+ getAccessTokenFromExternalIdpLegacy(exchange, jumperConfig);
+ }
+
+ } else if (Boolean.FALSE.equals(jumperConfig.getAccessTokenForwarding())) {
+ // Enhanced Last Mile Security Token scenario
+ log.debug("----------------LAST MILE SECURITY (ONE TOKEN)-------------");
+ jumperInfoRequest.ifPresent(
+ i -> i.setInfoScenario(true, true, false, false, false, false));
+
+ String enhancedLastmileSecurityToken =
+ oauthTokenUtil.generateEnhancedLastMileGatewayToken(
+ jumperConfig,
+ String.valueOf(request.getMethod()),
+ localIssuerUrl + "/" + jumperConfig.getRealmName(),
+ HeaderUtil.getLastValueFromHeaderField(
+ request, Constants.HEADER_X_PUBSUB_PUBLISHER_ID),
+ HeaderUtil.getLastValueFromHeaderField(
+ request, Constants.HEADER_X_PUBSUB_SUBSCRIBER_ID),
+ false);
HeaderUtil.addHeader(
exchange,
Constants.HEADER_AUTHORIZATION,
- Constants.BEARER + " " + tokenInfo.getAccessToken());
+ Constants.BEARER + " " + enhancedLastmileSecurityToken);
+ log.debug("lastMileSecurityToken: " + enhancedLastmileSecurityToken);
} else {
- getAccessTokenFromExternalIdpLegacy(exchange, jumperConfig);
- }
-
- } else if (Boolean.FALSE.equals(jumperConfig.getAccessTokenForwarding())) {
- // Enhanced Last Mile Security Token scenario
- log.debug("----------------LAST MILE SECURITY (ONE TOKEN)-------------");
- jumperInfoRequest.ifPresent(
- i -> i.setInfoScenario(true, true, false, false, false));
-
- String enhancedLastmileSecurityToken =
- oauthTokenUtil.generateEnhancedLastMileGatewayToken(
- jumperConfig,
- String.valueOf(request.getMethod()),
- localIssuerUrl + "/" + jumperConfig.getRealmName(),
- HeaderUtil.getLastValueFromHeaderField(
- request, Constants.HEADER_X_PUBSUB_PUBLISHER_ID),
- HeaderUtil.getLastValueFromHeaderField(
- request, Constants.HEADER_X_PUBSUB_SUBSCRIBER_ID),
- false);
-
- HeaderUtil.addHeader(
- exchange,
- Constants.HEADER_AUTHORIZATION,
- Constants.BEARER + " " + enhancedLastmileSecurityToken);
- log.debug("lastMileSecurityToken: " + enhancedLastmileSecurityToken);
+ // (Legacy) Last Mile Security Token scenario
+ log.debug("----------------LAST MILE SECURITY (LEGACY)-------------");
+ jumperInfoRequest.ifPresent(
+ i -> i.setInfoScenario(true, false, false, false, false, false));
+
+ String legacyLastmileSecurityToken =
+ oauthTokenUtil.generateEnhancedLastMileGatewayToken(
+ jumperConfig,
+ String.valueOf(request.getMethod()),
+ localIssuerUrl + "/" + jumperConfig.getRealmName(),
+ HeaderUtil.getLastValueFromHeaderField(
+ request, Constants.HEADER_X_PUBSUB_PUBLISHER_ID),
+ HeaderUtil.getLastValueFromHeaderField(
+ request, Constants.HEADER_X_PUBSUB_SUBSCRIBER_ID),
+ true);
- } else {
- // (Legacy) Last Mile Security Token scenario
- log.debug("----------------LAST MILE SECURITY (LEGACY)-------------");
- jumperInfoRequest.ifPresent(
- i -> i.setInfoScenario(true, false, false, false, false));
-
- String legacyLastmileSecurityToken =
- oauthTokenUtil.generateEnhancedLastMileGatewayToken(
- jumperConfig,
- String.valueOf(request.getMethod()),
- localIssuerUrl + "/" + jumperConfig.getRealmName(),
- HeaderUtil.getLastValueFromHeaderField(
- request, Constants.HEADER_X_PUBSUB_PUBLISHER_ID),
- HeaderUtil.getLastValueFromHeaderField(
- request, Constants.HEADER_X_PUBSUB_SUBSCRIBER_ID),
- true);
-
- HeaderUtil.addHeader(
- exchange,
- Constants.HEADER_LASTMILE_SECURITY_TOKEN,
- Constants.BEARER + " " + legacyLastmileSecurityToken);
- log.debug("lastMileSecurityToken: " + legacyLastmileSecurityToken);
+ HeaderUtil.addHeader(
+ exchange,
+ Constants.HEADER_LASTMILE_SECURITY_TOKEN,
+ Constants.BEARER + " " + legacyLastmileSecurityToken);
+ log.debug("lastMileSecurityToken: " + legacyLastmileSecurityToken);
+ }
}
}
}
@@ -251,7 +293,7 @@ public GatewayFilter apply(Config config) {
log.info("logging request: {}", value("jumperInfo", infoRequest));
});
- addTracingInfo(request);
+ tracer.currentSpan().event("jrqf");
});
return chain.filter(exchange);
@@ -263,7 +305,6 @@ private Optional initializeJumperInfoRequest(JumperConfig jum
if (log.isInfoEnabled()) {
JumperInfoRequest jumperInfoRequest = new JumperInfoRequest();
- jumperInfoRequest.setEnvironment(jumperConfig.getEnvName());
return Optional.of(jumperInfoRequest);
}
@@ -273,15 +314,12 @@ private Optional initializeJumperInfoRequest(JumperConfig jum
private IncomingRequest createIncomingRequest(
JumperConfig jumperConfig, ServerHttpRequest request) {
IncomingRequest incReq = new IncomingRequest();
+ incReq.setConsumer(jumperConfig.getConsumer());
incReq.setBasePath(jumperConfig.getApiBasePath());
- incReq.setHost(jumperConfig.getRemoteApiUrl());
- incReq.setMethod(String.valueOf(request.getMethod()));
- incReq.setResource(jumperConfig.getRoutingPath());
+ incReq.setFinalApiUrl(jumperConfig.getFinalApiUrl());
+ incReq.setMethod((request.getMethodValue()));
+ incReq.setRequestPath(jumperConfig.getRequestPath());
- HashMap logEntries = new HashMap<>();
- logEntries.put("Thread name", Thread.currentThread().getName());
-
- incReq.setLogEntries(logEntries);
return incReq;
}
@@ -318,6 +356,7 @@ private void calculateRoutingStuff(
// add calculated stuff to jumperConfig
jumperConfig.setRequestPath(requestPath);
jumperConfig.setRoutingPath(routingPath);
+ jumperConfig.setFinalApiUrl(finalApiUrl);
} catch (URISyntaxException e) {
throw new RuntimeException("can not construct URL from " + request.getURI(), e);
@@ -422,12 +461,50 @@ private static String determineClientId(
return clientId;
}
+ private JumperConfig evaluateTargetZone(
+ List jumperConfigList, String forceSkipZone) {
+ for (JumperConfig jc : jumperConfigList) {
+ // secondary route, failover in place => audit logs
+ if (StringUtils.isEmpty(jc.getTargetZoneName())) {
+ jc.setAuditLog(true);
+ return jc;
+ }
+ // targetZoneName present, check it against force skip header and zones state
+ // map
+ if (!(jc.getTargetZoneName().equalsIgnoreCase(forceSkipZone)
+ || !zoneHealthCheckService.getZoneHealth(jc.getTargetZoneName()))) {
+ return jc;
+ }
+ }
+ throw new ResponseStatusException(
+ HttpStatus.SERVICE_UNAVAILABLE, "Non of defined failover zones available");
+ }
+
private void checkForSpaceZone(ServerWebExchange exchange, String zone, String token) {
- if (zone != null && Constants.SPACE_ZONES.contains(zone)) {
+ if (isSpaceZone(zone)) {
HeaderUtil.addHeader(exchange, Constants.HEADER_X_SPACEGATE_TOKEN, token);
}
}
+ private void addXtokenExchange(ServerWebExchange exchange) {
+
+ HeaderUtil.addHeader(
+ exchange,
+ Constants.HEADER_AUTHORIZATION,
+ HeaderUtil.getFirstValueFromHeaderField(
+ exchange.getRequest(), Constants.HEADER_X_TOKEN_EXCHANGE));
+
+ log.debug(
+ "x-token-exchange: "
+ + HeaderUtil.getFirstValueFromHeaderField(
+ exchange.getRequest(), Constants.HEADER_X_TOKEN_EXCHANGE));
+ }
+
+ private boolean isSpaceZone(String zone) {
+
+ return zone != null && Constants.SPACE_ZONES.contains(zone);
+ }
+
private void addTracingInfo(ServerHttpRequest request) {
String xTardisTraceId =
@@ -437,7 +514,6 @@ private void addTracingInfo(ServerHttpRequest request) {
Span incomingRequestSpan = tracer.currentSpan();
incomingRequestSpan.name("Incoming Request");
- // todo would prefer to set NA for this (chunked transfer?) scenario
incomingRequestSpan.tag("message.size", Objects.requireNonNullElse(contentLength, "0"));
if (xTardisTraceId != null) {
@@ -445,7 +521,6 @@ private void addTracingInfo(ServerHttpRequest request) {
}
incomingRequestSpan.remoteServiceName(applicationName);
- incomingRequestSpan.event("jrqf");
}
@AllArgsConstructor
diff --git a/src/main/java/jumper/filter/ResponseFilter.java b/src/main/java/jumper/filter/ResponseFilter.java
index b1c84de..6f9e2c2 100644
--- a/src/main/java/jumper/filter/ResponseFilter.java
+++ b/src/main/java/jumper/filter/ResponseFilter.java
@@ -6,6 +6,7 @@
import static net.logstash.logback.argument.StructuredArguments.value;
+import java.util.Objects;
import jumper.model.response.IncomingResponse;
import jumper.model.response.JumperInfoResponse;
import lombok.AllArgsConstructor;
@@ -15,6 +16,7 @@
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.OrderedGatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
+import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.cloud.sleuth.CurrentTraceContext;
import org.springframework.cloud.sleuth.Span;
import org.springframework.cloud.sleuth.Tracer;
@@ -54,31 +56,37 @@ public GatewayFilter apply(Config config) {
ServerHttpResponse response = exchange.getResponse();
ServerHttpRequest request = exchange.getRequest();
- if (isLogLevelEnabled()) {
+ if (log.isDebugEnabled()) {
JumperInfoResponse jumperInfoResponse =
new JumperInfoResponse();
IncomingResponse incomingResponse = new IncomingResponse();
- incomingResponse.setPath(request.getPath().toString());
+ incomingResponse.setHost(
+ Objects.requireNonNull(
+ exchange.getAttribute(
+ ServerWebExchangeUtils
+ .GATEWAY_REQUEST_URL_ATTR))
+ .toString());
incomingResponse.setHttpStatusCode(
- response.getStatusCode().value());
-
+ Objects.requireNonNull(response.getStatusCode()).value());
+ incomingResponse.setMethod(request.getMethodValue());
+ incomingResponse.setRequestHeaders(
+ request.getHeaders().toSingleValueMap());
jumperInfoResponse.setIncomingResponse(incomingResponse);
- log.info(
+ log.debug(
"logging response: {}",
value("jumperInfo", jumperInfoResponse));
}
- Long contentLength = response.getHeaders().getContentLength();
+ long contentLength = response.getHeaders().getContentLength();
Span span = tracer.currentSpan();
- if (contentLength == null
- || contentLength.toString().equals("-1")) {
+ if (Long.toString(contentLength).equals("-1")) {
span.tag("message.size_response", "0");
} else {
- span.tag("message.size_response", contentLength.toString());
+ span.tag("message.size_response", Long.toString(contentLength));
}
span.event("jrpf");
@@ -86,10 +94,6 @@ public GatewayFilter apply(Config config) {
RequestFilter.REQUEST_FILTER_ORDER);
}
- private boolean isLogLevelEnabled() {
- return log.isInfoEnabled();
- }
-
@Getter
@Setter
@AllArgsConstructor
diff --git a/src/main/java/jumper/filter/SpectreRequestFilter.java b/src/main/java/jumper/filter/SpectreRequestFilter.java
index 33f8afc..8e67066 100644
--- a/src/main/java/jumper/filter/SpectreRequestFilter.java
+++ b/src/main/java/jumper/filter/SpectreRequestFilter.java
@@ -41,7 +41,7 @@ public GatewayFilter apply(Config config) {
request.getHeaders().toSingleValueMap(),
requestBody);
- JumperConfig jc = JumperConfig.parseConfigFrom(exchange);
+ JumperConfig jc = JumperConfig.parseJumperConfigFrom(exchange);
if (!jc.isListenerMatched()) {
return chain.filter(exchange.mutate().request(request).build());
}
diff --git a/src/main/java/jumper/filter/SpectreResponseFilter.java b/src/main/java/jumper/filter/SpectreResponseFilter.java
index 535ad18..a9afb07 100644
--- a/src/main/java/jumper/filter/SpectreResponseFilter.java
+++ b/src/main/java/jumper/filter/SpectreResponseFilter.java
@@ -55,7 +55,7 @@ public GatewayFilter apply(AbstractGatewayFilterFactory.NameConfig config) {
responseBody);
// use jumperConfig passed with exchange
- JumperConfig jumperConfig = JumperConfig.parseConfigFrom(exchange);
+ JumperConfig jumperConfig = JumperConfig.parseJumperConfigFrom(exchange);
if (jumperConfig.isListenerMatched()) {
RouteListener listener =
jumperConfig.getRouteListener().get(jumperConfig.getConsumer());
diff --git a/src/main/java/jumper/job/RedisHealthCheck.java b/src/main/java/jumper/job/RedisHealthCheck.java
new file mode 100644
index 0000000..5c9dffc
--- /dev/null
+++ b/src/main/java/jumper/job/RedisHealthCheck.java
@@ -0,0 +1,75 @@
+// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package jumper.job;
+
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.MeterRegistry;
+import java.util.concurrent.atomic.AtomicInteger;
+import jumper.config.RedisConfig;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.data.redis.connection.RedisClusterConnection;
+import org.springframework.data.redis.connection.RedisConnection;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisConnectionUtils;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+
+@Slf4j
+@ConditionalOnBean(RedisConfig.class)
+@Component
+@EnableScheduling
+public class RedisHealthCheck {
+
+ private static final String CONNECT = "CONNECTED";
+
+ private final RedisConnectionFactory redisConnectionFactory;
+ private final AtomicInteger redisConnectionStatus;
+
+ public RedisHealthCheck(RedisConnectionFactory connectionFactory, MeterRegistry meterRegistry) {
+ Assert.notNull(connectionFactory, "ConnectionFactory must not be null");
+ this.redisConnectionFactory = connectionFactory;
+
+ this.redisConnectionStatus = new AtomicInteger(0);
+ Gauge.builder("zone.health.redis.connection", () -> redisConnectionStatus)
+ .description("redis connection status")
+ .tag("status", CONNECT)
+ .register(meterRegistry);
+ }
+
+ @Scheduled(fixedRateString = "${jumper.zone.health.redis.checkConnectionInterval}")
+ public void refreshZoneHealthStatus() {
+ try {
+ this.doHealthCheck();
+ gaugeEvaluation(true);
+ } catch (Exception e) {
+ log.error("Error while checking Redis health", e);
+ gaugeEvaluation(false);
+ }
+ }
+
+ private void doHealthCheck() {
+ RedisConnection connection = RedisConnectionUtils.getConnection(this.redisConnectionFactory);
+ try {
+ this.doHealthCheck(connection);
+ } finally {
+ RedisConnectionUtils.releaseConnection(connection, this.redisConnectionFactory);
+ }
+ }
+
+ private void doHealthCheck(RedisConnection connection) {
+ if (connection instanceof RedisClusterConnection) {
+ ((RedisClusterConnection) connection).clusterGetClusterInfo();
+ } else {
+ connection.info("server");
+ }
+ }
+
+ private void gaugeEvaluation(Boolean isConnected) {
+ redisConnectionStatus.set(isConnected ? 1 : 0);
+ }
+}
diff --git a/src/main/java/jumper/model/config/AuditLog.java b/src/main/java/jumper/model/config/AuditLog.java
new file mode 100644
index 0000000..a82f3f1
--- /dev/null
+++ b/src/main/java/jumper/model/config/AuditLog.java
@@ -0,0 +1,19 @@
+// SPDX-FileCopyrightText: 2023 Deutsche Telekom AG
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package jumper.model.config;
+
+import java.util.Date;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class AuditLog {
+ private Date timestamp;
+ private String consumer;
+ private String consumerOriginZone;
+ private String apiBasePath;
+ private String upstreamPath;
+}
diff --git a/src/main/java/jumper/model/config/HealthStatus.java b/src/main/java/jumper/model/config/HealthStatus.java
new file mode 100644
index 0000000..6f67bce
--- /dev/null
+++ b/src/main/java/jumper/model/config/HealthStatus.java
@@ -0,0 +1,10 @@
+// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package jumper.model.config;
+
+public enum HealthStatus {
+ HEALTHY,
+ UNHEALTHY
+}
diff --git a/src/main/java/jumper/model/config/JumperConfig.java b/src/main/java/jumper/model/config/JumperConfig.java
index e8bce9c..f2000e8 100644
--- a/src/main/java/jumper/model/config/JumperConfig.java
+++ b/src/main/java/jumper/model/config/JumperConfig.java
@@ -6,16 +6,15 @@
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwt;
-import java.util.Base64;
-import java.util.HashMap;
-import java.util.Objects;
-import java.util.Optional;
+import java.util.*;
import jumper.Constants;
import jumper.service.HeaderUtil;
import jumper.service.OauthTokenUtil;
@@ -33,6 +32,7 @@ public class JumperConfig {
private HashMap routeListener;
private GatewayClient gatewayClient;
+ String targetZoneName;
String scopes;
String apiBasePath;
String consumer;
@@ -40,13 +40,22 @@ public class JumperConfig {
String consumerOriginZone;
String consumerToken;
String externalTokenEndpoint;
+
+ @JsonProperty("issuer")
String internalTokenEndpoint;
+
String clientId;
String clientSecret;
Boolean accessTokenForwarding;
+
+ @JsonProperty("realm")
String realmName;
+
String remoteApiUrl;
+
+ @JsonProperty("environment")
String envName;
+
String xSpacegateClientId;
String xSpacegateClientSecret;
String xSpacegateScope;
@@ -54,12 +63,15 @@ public class JumperConfig {
// calculated routing stuff within requestFilter
String requestPath;
String routingPath;
+ String finalApiUrl;
+
+ Boolean auditLog = false;
@JsonIgnore
- public static String toBase64(JumperConfig jc) {
+ public static String toBase64(Object o) {
String jsonConfigBase64 = null;
try {
- String decodedJson = new ObjectMapper().writeValueAsString(jc);
+ String decodedJson = new ObjectMapper().writeValueAsString(o);
jsonConfigBase64 = Base64.getEncoder().encodeToString(decodedJson.getBytes());
} catch (JsonProcessingException e) {
e.printStackTrace();
@@ -69,57 +81,59 @@ public static String toBase64(JumperConfig jc) {
}
@JsonIgnore
- public static JumperConfig fromBase64(String jsonConfigBase64) {
+ private static T fromBase64(String jsonConfigBase64, TypeReference typeReference) {
String decodedJson = new String(Base64.getDecoder().decode(jsonConfigBase64.getBytes()));
try {
return new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
- .readValue(decodedJson, JumperConfig.class);
+ .readValue(decodedJson, typeReference);
} catch (JsonProcessingException e) {
- e.printStackTrace();
+ throw new RuntimeException("can not base64decode header: " + jsonConfigBase64);
+ }
+ }
+
+ private static JumperConfig fromBase64(String jsonConfigBase64) {
+ if (StringUtils.isNotBlank(jsonConfigBase64)) {
+ return JumperConfig.fromBase64(jsonConfigBase64, new TypeReference<>() {});
+ } else {
return new JumperConfig();
}
}
@JsonIgnore
- public void fillWithLegacyHeaders(ServerHttpRequest request) {
+ private void fillWithLegacyHeaders(ServerHttpRequest request) {
- setScopes(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_CLIENT_SCOPES));
- setApiBasePath(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_API_BASE_PATH));
- setExternalTokenEndpoint(
- HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_TOKEN_ENDPOINT));
+ // proxy
+ setRemoteApiUrl(
+ HeaderUtil.getLastValueFromHeaderField(
+ request, Constants.HEADER_REMOTE_API_URL)); // also real
setInternalTokenEndpoint(
HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_ISSUER));
- setClientId(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_CLIENT_ID));
+ setClientId(
+ HeaderUtil.getLastValueFromHeaderField(
+ request, Constants.HEADER_CLIENT_ID)); // also external
setClientSecret(
- HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_CLIENT_SECRET));
+ HeaderUtil.getLastValueFromHeaderField(
+ request, Constants.HEADER_CLIENT_SECRET)); // also external
+ // real
+ setApiBasePath(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_API_BASE_PATH));
if (request.getHeaders().containsKey(Constants.HEADER_ACCESS_TOKEN_FORWARDING)) {
setAccessTokenForwarding(
Boolean.valueOf(
HeaderUtil.getLastValueFromHeaderField(
request, Constants.HEADER_ACCESS_TOKEN_FORWARDING)));
}
-
- setConsumerToken(
- HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_AUTHORIZATION));
- Jwt consumerTokenClaims =
- OauthTokenUtil.getAllClaimsFromToken(
- OauthTokenUtil.getTokenWithoutSignature(consumerToken));
- setConsumer(consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_CLIENT_ID, String.class));
- setConsumerOriginStargate(
- consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_ORIGIN_STARGATE, String.class));
- setConsumerOriginZone(
- consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_ORIGIN_ZONE, String.class));
-
setRealmName(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_REALM));
if (StringUtils.isBlank(getRealmName())) {
setRealmName(Constants.DEFAULT_REALM);
}
-
- setRemoteApiUrl(
- HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_REMOTE_API_URL));
setEnvName(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_ENVIRONMENT));
+
+ // external oauth
+ setScopes(HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_CLIENT_SCOPES));
+ setExternalTokenEndpoint(
+ HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_TOKEN_ENDPOINT));
setXSpacegateClientId(
HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_X_SPACEGATE_CLIENT_ID));
setXSpacegateClientSecret(
@@ -127,22 +141,60 @@ public void fillWithLegacyHeaders(ServerHttpRequest request) {
request, Constants.HEADER_X_SPACEGATE_CLIENT_SECRET));
setXSpacegateScope(
HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_X_SPACEGATE_SCOPE));
+
+ // processing
+ setConsumerToken(
+ HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_AUTHORIZATION));
+ Jwt consumerTokenClaims =
+ OauthTokenUtil.getAllClaimsFromToken(
+ OauthTokenUtil.getTokenWithoutSignature(consumerToken));
+ setConsumer(consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_CLIENT_ID, String.class));
+ setConsumerOriginStargate(
+ consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_ORIGIN_STARGATE, String.class));
+ setConsumerOriginZone(
+ consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_ORIGIN_ZONE, String.class));
}
@JsonIgnore
- public static JumperConfig parseConfigFrom(ServerHttpRequest request) {
+ public void fillProcessingInfo(ServerHttpRequest request) {
+ setConsumerToken(
+ HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_AUTHORIZATION));
+ Jwt consumerTokenClaims =
+ OauthTokenUtil.getAllClaimsFromToken(
+ OauthTokenUtil.getTokenWithoutSignature(consumerToken));
+ setConsumer(consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_CLIENT_ID, String.class));
+ setConsumerOriginStargate(
+ consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_ORIGIN_STARGATE, String.class));
+ setConsumerOriginZone(
+ consumerTokenClaims.getBody().get(Constants.TOKEN_CLAIM_ORIGIN_ZONE, String.class));
+
+ // Spectre stuff
+ JumperConfig jc =
+ JumperConfig.fromBase64(
+ HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_JUMPER_CONFIG));
+ this.setRouteListener(jc.getRouteListener());
+ this.setGatewayClient(jc.getGatewayClient());
+ }
- JumperConfig jc;
- String jumperConfigBase64 =
- HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_JUMPER_CONFIG);
+ public static List parseJumperConfigListFrom(ServerHttpRequest request) {
- if (StringUtils.isNotBlank(jumperConfigBase64)) {
- jc = JumperConfig.fromBase64(jumperConfigBase64);
+ String routingConfigBase64 =
+ HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_ROUTING_CONFIG);
- } else {
- jc = new JumperConfig();
+ if (StringUtils.isNotBlank(routingConfigBase64)) {
+ return JumperConfig.fromBase64(routingConfigBase64, new TypeReference<>() {});
}
+ throw new RuntimeException("can not base64decode header: " + routingConfigBase64);
+ }
+
+ @JsonIgnore
+ public static JumperConfig parseAndFillJumperConfigFrom(ServerHttpRequest request) {
+
+ JumperConfig jc =
+ JumperConfig.fromBase64(
+ HeaderUtil.getLastValueFromHeaderField(request, Constants.HEADER_JUMPER_CONFIG));
+
jc.fillWithLegacyHeaders(
request); // TODO: remove as soon we have completely shifted to json_config
@@ -150,13 +202,9 @@ public static JumperConfig parseConfigFrom(ServerHttpRequest request) {
}
@JsonIgnore
- public static JumperConfig parseConfigFrom(ServerWebExchange exchange) {
- String jumperConfigBase64 = exchange.getAttribute(Constants.HEADER_JUMPER_CONFIG);
- if (jumperConfigBase64 != null && !jumperConfigBase64.isEmpty()) {
- return JumperConfig.fromBase64(jumperConfigBase64);
- } else {
- return new JumperConfig();
- }
+ public static JumperConfig parseJumperConfigFrom(ServerWebExchange exchange) {
+
+ return JumperConfig.fromBase64(exchange.getAttribute(Constants.HEADER_JUMPER_CONFIG));
}
public boolean isListenerMatched() {
diff --git a/src/main/java/jumper/model/config/ZoneHealthMessage.java b/src/main/java/jumper/model/config/ZoneHealthMessage.java
new file mode 100644
index 0000000..3b7cc61
--- /dev/null
+++ b/src/main/java/jumper/model/config/ZoneHealthMessage.java
@@ -0,0 +1,19 @@
+// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package jumper.model.config;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@Getter
+@Setter
+@AllArgsConstructor
+@ToString
+public class ZoneHealthMessage {
+ private String zone;
+ private HealthStatus status;
+}
diff --git a/src/main/java/jumper/model/request/JumperInfoRequest.java b/src/main/java/jumper/model/request/JumperInfoRequest.java
index 36cbb79..9dde022 100644
--- a/src/main/java/jumper/model/request/JumperInfoRequest.java
+++ b/src/main/java/jumper/model/request/JumperInfoRequest.java
@@ -5,6 +5,7 @@
package jumper.model.request;
import java.util.Map.Entry;
+import java.util.Objects;
import java.util.Set;
import lombok.Getter;
import lombok.Setter;
@@ -18,23 +19,23 @@ public class JumperInfoRequest {
private boolean lastMileSecurityEnhanced;
private boolean externalAuthorization;
private boolean basicAuth;
-
- private String environment;
+ private boolean xTokenExchangeAuthorization;
private IncomingRequest incomingRequest;
- private OutgoingRequest outgoingRequest;
public void setInfoScenario(
boolean lastMileSecurity,
boolean lastMileSecurityEnhanced,
boolean meshActivated,
boolean externalAuthorization,
- boolean basicAuth) {
+ boolean basicAuth,
+ boolean xTokenExchangeauthorization) {
this.meshActivated = meshActivated;
this.lastMileSecurity = lastMileSecurity;
this.lastMileSecurityEnhanced = lastMileSecurityEnhanced;
this.externalAuthorization = externalAuthorization;
this.basicAuth = basicAuth;
+ this.xTokenExchangeAuthorization = xTokenExchangeauthorization;
}
@Override
@@ -53,6 +54,8 @@ public String toString() {
sb.append(lastMileSecurityEnhanced);
sb.append(" ExternalAuthorization: ");
sb.append(externalAuthorization);
+ sb.append(" XtokenExchangeAuthorization: ");
+ sb.append(xTokenExchangeAuthorization);
sb.append(" BasicAuth: ");
sb.append(basicAuth);
sb.append(lineSeparator);
@@ -63,19 +66,24 @@ public String toString() {
sb.append("HTTP-Method: ").append(incomingRequest.getMethod());
sb.append(lineSeparator);
- sb.append("Host: ").append(incomingRequest.getHost());
+ sb.append("Consumer: ").append(incomingRequest.getConsumer());
sb.append(lineSeparator);
sb.append("BasePath: ").append(incomingRequest.getBasePath());
sb.append(lineSeparator);
- sb.append("Resource: ").append(incomingRequest.getResource());
+ sb.append("Resource: ").append(incomingRequest.getRequestPath());
+ sb.append(lineSeparator);
+
+ sb.append("Host: ").append(incomingRequest.getFinalApiUrl());
sb.append(lineSeparator);
- Set> entrySet = incomingRequest.getLogEntries().entrySet();
- for (Entry entry : entrySet) {
- sb.append(entry.getKey()).append(": ").append(entry.getValue());
- sb.append(lineSeparator);
+ if (Objects.nonNull(incomingRequest.getLogEntries())) {
+ Set> entrySet = incomingRequest.getLogEntries().entrySet();
+ for (Entry entry : entrySet) {
+ sb.append(entry.getKey()).append(": ").append(entry.getValue());
+ sb.append(lineSeparator);
+ }
}
return sb.toString();
diff --git a/src/main/java/jumper/model/request/OutgoingRequest.java b/src/main/java/jumper/model/request/OutgoingRequest.java
deleted file mode 100644
index d186092..0000000
--- a/src/main/java/jumper/model/request/OutgoingRequest.java
+++ /dev/null
@@ -1,16 +0,0 @@
-// SPDX-FileCopyrightText: 2023 Deutsche Telekom AG
-//
-// SPDX-License-Identifier: Apache-2.0
-
-package jumper.model.request;
-
-import java.util.Map;
-import lombok.Getter;
-import lombok.Setter;
-
-@Getter
-@Setter
-public class OutgoingRequest extends Request {
-
- private Map zuulHeader;
-}
diff --git a/src/main/java/jumper/model/request/Request.java b/src/main/java/jumper/model/request/Request.java
index 34496e4..782263f 100644
--- a/src/main/java/jumper/model/request/Request.java
+++ b/src/main/java/jumper/model/request/Request.java
@@ -4,7 +4,6 @@
package jumper.model.request;
-import java.util.Map;
import lombok.Getter;
import lombok.Setter;
@@ -12,10 +11,9 @@
@Setter
public abstract class Request {
- private String host;
+ private String finalApiUrl;
+ private String consumer;
private String basePath;
- private String resource;
+ private String requestPath;
private String method;
-
- private Map originHeader;
}
diff --git a/src/main/java/jumper/model/response/IncomingResponse.java b/src/main/java/jumper/model/response/IncomingResponse.java
index 7b16fe8..01fc850 100644
--- a/src/main/java/jumper/model/response/IncomingResponse.java
+++ b/src/main/java/jumper/model/response/IncomingResponse.java
@@ -4,7 +4,7 @@
package jumper.model.response;
-import java.util.List;
+import java.util.Map;
import lombok.Getter;
import lombok.Setter;
@@ -13,8 +13,7 @@
public class IncomingResponse {
private String host;
- private String path;
private Integer httpStatusCode;
-
- List originHeaderResponse;
+ private String method;
+ Map requestHeaders;
}
diff --git a/src/main/java/jumper/model/response/JumperInfoResponse.java b/src/main/java/jumper/model/response/JumperInfoResponse.java
index f55b859..b09cba5 100644
--- a/src/main/java/jumper/model/response/JumperInfoResponse.java
+++ b/src/main/java/jumper/model/response/JumperInfoResponse.java
@@ -4,6 +4,8 @@
package jumper.model.response;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Getter;
import lombok.Setter;
@@ -11,4 +13,14 @@
@Setter
public class JumperInfoResponse {
IncomingResponse incomingResponse;
+
+ @Override
+ public String toString() {
+ ObjectMapper objectMapper = new ObjectMapper();
+ try {
+ return objectMapper.writeValueAsString(incomingResponse);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException(e);
+ }
+ }
}
diff --git a/src/main/java/jumper/service/AuditLogService.java b/src/main/java/jumper/service/AuditLogService.java
new file mode 100644
index 0000000..ca8be1e
--- /dev/null
+++ b/src/main/java/jumper/service/AuditLogService.java
@@ -0,0 +1,29 @@
+// SPDX-FileCopyrightText: 2023 Deutsche Telekom AG
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package jumper.service;
+
+import java.util.Date;
+import jumper.model.config.AuditLog;
+import jumper.model.config.JumperConfig;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class AuditLogService {
+ public static void logInfo(String msg) {
+ log.info(msg);
+ }
+
+ public static void writeFailoverAuditLog(JumperConfig jumperConfig) {
+ logInfo(
+ AuditLog.builder()
+ .upstreamPath(jumperConfig.getFinalApiUrl())
+ .apiBasePath(jumperConfig.getApiBasePath())
+ .consumer(jumperConfig.getConsumer())
+ .consumerOriginZone(jumperConfig.getConsumerOriginZone())
+ .timestamp(new Date())
+ .build()
+ .toString());
+ }
+}
diff --git a/src/main/java/jumper/service/RedisZoneHealthStatusService.java b/src/main/java/jumper/service/RedisZoneHealthStatusService.java
new file mode 100644
index 0000000..4db29f0
--- /dev/null
+++ b/src/main/java/jumper/service/RedisZoneHealthStatusService.java
@@ -0,0 +1,113 @@
+// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package jumper.service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.concurrent.CompletableFuture;
+import jumper.config.RedisConfig;
+import jumper.model.config.HealthStatus;
+import jumper.model.config.ZoneHealthMessage;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.data.redis.connection.Message;
+import org.springframework.data.redis.connection.MessageListener;
+import org.springframework.data.redis.listener.ChannelTopic;
+import org.springframework.data.redis.listener.RedisMessageListenerContainer;
+import org.springframework.retry.support.RetryTemplateBuilder;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@ConditionalOnBean(RedisConfig.class)
+@Component
+public class RedisZoneHealthStatusService implements MessageListener {
+
+ private final ObjectMapper objectMapper;
+ private final ZoneHealthCheckService zoneHealthCheckService;
+ private final RedisMessageListenerContainer redisMessageListenerContainer;
+
+ private final String channelKey;
+
+ @Getter private boolean isInitiallySubscribed = false;
+
+ public RedisZoneHealthStatusService(
+ ObjectMapper objectMapper,
+ ZoneHealthCheckService zoneHealthCheckService,
+ RedisMessageListenerContainer redisMessageListenerContainer,
+ @Value("${jumper.zone.health.redis.channel}") String channelKey) {
+ this.objectMapper = objectMapper;
+ this.zoneHealthCheckService = zoneHealthCheckService;
+ this.redisMessageListenerContainer = redisMessageListenerContainer;
+ this.channelKey = channelKey;
+
+ this.lazyInitializeRedisMessageListenerContainer();
+ }
+
+ @Override
+ public void onMessage(Message message, byte[] pattern) {
+ try {
+ ZoneHealthMessage zoneHealthMessage =
+ objectMapper.readValue(message.toString(), ZoneHealthMessage.class);
+ log.debug("Received message {}", zoneHealthMessage);
+ if (zoneHealthMessage.getZone() == null) {
+ log.error("Zone is null in message {}, ignoring set status", zoneHealthMessage);
+ return;
+ }
+ zoneHealthCheckService.setZoneHealth(
+ zoneHealthMessage.getZone(), zoneHealthMessage.getStatus() == HealthStatus.HEALTHY);
+ } catch (JsonProcessingException e) {
+ log.error("Error deserializing message", e);
+ } catch (Exception e) {
+ log.error("Error processing message", e);
+ }
+ }
+
+ void lazyInitializeRedisMessageListenerContainer() {
+ CompletableFuture.supplyAsync(
+ () -> {
+ var template =
+ new RetryTemplateBuilder()
+ .maxAttempts(Integer.MAX_VALUE)
+ .fixedBackoff(5000)
+ .build();
+ return template.execute(
+ context -> {
+ try {
+ if (redisMessageListenerContainer.getConnectionFactory() == null) {
+ log.debug(
+ "Redis connection factory not available, skipping initialization");
+ return false;
+ }
+
+ var connection =
+ redisMessageListenerContainer.getConnectionFactory().getConnection();
+ if (connection.isSubscribed()) {
+ log.debug("Redis connection already subscribed, skipping initialization");
+ return false;
+ }
+ } catch (Exception e) {
+ log.error(
+ "Connection failure occurred. Restarting subscription task after 5000 ms");
+ throw e;
+ }
+ redisMessageListenerContainer.addMessageListener(
+ this, new ChannelTopic(channelKey));
+ log.info(
+ "Listeners registered successfully after {} retries.",
+ context.getRetryCount());
+ return true;
+ });
+ })
+ .exceptionally(
+ throwable -> {
+ log.error(
+ "Stopped initializing Redis message listener container with errors", throwable);
+ return false;
+ })
+ .thenApply(result -> isInitiallySubscribed = result);
+ }
+}
diff --git a/src/main/java/jumper/service/ZoneHealthCheckService.java b/src/main/java/jumper/service/ZoneHealthCheckService.java
new file mode 100644
index 0000000..07d8582
--- /dev/null
+++ b/src/main/java/jumper/service/ZoneHealthCheckService.java
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package jumper.service;
+
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.MeterRegistry;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+import jumper.model.config.HealthStatus;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+@Component
+@Slf4j
+public class ZoneHealthCheckService {
+
+ @Value("${jumper.zone.health.defaultZoneHealth}")
+ private Boolean defaultZoneHealth;
+
+ private final Map zoneHealthCache = new ConcurrentHashMap<>();
+ private final MeterRegistry meterRegistry;
+ private final Map atomicIntegerMap;
+
+ public ZoneHealthCheckService(MeterRegistry meterRegistry) {
+ this.meterRegistry = meterRegistry;
+ this.atomicIntegerMap = new HashMap<>();
+ }
+
+ public Boolean getZoneHealth(String zone) {
+ return zoneHealthCache.getOrDefault(zone, defaultZoneHealth);
+ }
+
+ public void setZoneHealth(String zone, Boolean health) {
+ gaugeEvaluation(zone, health);
+ zoneHealthCache.put(zone, health);
+ }
+
+ private void gaugeEvaluation(String zone, Boolean health) {
+ if (atomicIntegerMap.containsKey(zone)) {
+ AtomicInteger ai = atomicIntegerMap.get(zone);
+ ai.set(health ? 1 : 0);
+ } else {
+ AtomicInteger ai = new AtomicInteger(health ? 1 : 0);
+ Gauge.builder("zone.health.status", () -> ai)
+ .description("Zone health status")
+ .tag("zone", zone)
+ .tag("status", HealthStatus.HEALTHY.toString())
+ .register(meterRegistry);
+ atomicIntegerMap.put(zone, ai);
+ }
+ }
+
+ public void clearCache() {
+ zoneHealthCache.clear();
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index c617cdc..232fb43 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -5,10 +5,15 @@
server:
port: ${JUMPER_PORT:8080}
max-http-header-size: 16384
+ shutdown: GRACEFUL
netty:
max-initial-line-length: 8192
+ idle-timeout: 60000
management:
+ health:
+ redis:
+ enabled: false
endpoint:
gateway.enabled: true # having actuators endpoint for filters e.g. host/actuator/gateway/routefilters
loggers.enabled: true # having actuators endpoint for setting log level
@@ -20,6 +25,8 @@ management:
exposure.include: gateway, loggers, prometheus, health
spring:
+ lifecycle:
+ timeout-per-shutdown-phase: 1m
application:
name: ${JUMPER_NAME:Jumper}
cloud:
@@ -43,6 +50,7 @@ spring:
max-life-time: 300s
max-idle-time: 2s
#eviction-interval: 120s
+ metrics: true
connect-timeout: 10000
response-timeout: 61s # ingress 60s
#ssl:
@@ -71,12 +79,30 @@ spring:
instrumentation-type: MANUAL #https://docs.spring.io/spring-cloud-sleuth/docs/current-SNAPSHOT/reference/html/integrations.html
codec:
max-in-memory-size: 4194304
-
+ data:
+ redis:
+ repositories:
+ enabled: false
+ redis:
+ connect-timeout: ${ZONE_HEALTH_DATABASE_CONNECTTIMEOUT:500}
+ timeout: ${ZONE_HEALTH_DATABASE_TIMEOUT:500}
+ host: ${ZONE_HEALTH_DATABASE_HOST:localhost}
+ port: ${ZONE_HEALTH_DATABASE_PORT:6379}
+ database: ${ZONE_HEALTH_DATABASE_INDEX:2}
+ password: ${ZONE_HEALTH_DATABASE_PASSWORD:foobar}
jumper:
issuer:
url: ${JUMPER_ISSUER_URL:https://stargate-integration.test.dhei.telekom.de/auth/realms}
stargate:
url: ${STARGATE_URL:https://stargate-integration.test.dhei.telekom.de}
+ zone:
+ name: ${JUMPER_ZONE_NAME:default}
+ health:
+ enabled: ${ZONE_HEALTH_ENABLED:false}
+ defaultZoneHealth: ${ZONE_HEALTH_DEFAULT:true}
+ redis:
+ channel: ${ZONE_HEALTH_KEY_CHANNEL:stargate-zone-status}
+ checkConnectionInterval: ${ZONE_HEALTH_REQUEST_GET_RATE:5000}
jumpercache.ttlOffset: 10
jumpercache.cleanCacheInSeconds: 3600
@@ -86,4 +112,5 @@ jumpercache.cleanCacheInSeconds: 3600
##############################
horizon:
publishEventUrl: ${PUBLISH_EVENT_URL:http://producer.stage:8080/v1/events}
+logging.level.jumper.service.AuditLogService: INFO
#logging.pattern.level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]" #https://github.com/micrometer-metrics/tracing/wiki/Spring-Cloud-Sleuth-3.1-Migration-Guide
diff --git a/src/test/java/jumper/BaseSteps.java b/src/test/java/jumper/BaseSteps.java
index f61fc5c..6dd2118 100644
--- a/src/test/java/jumper/BaseSteps.java
+++ b/src/test/java/jumper/BaseSteps.java
@@ -4,11 +4,13 @@
package jumper;
+import static jumper.config.Config.*;
import static org.junit.jupiter.api.Assertions.fail;
import io.cucumber.java.en.And;
import io.cucumber.java.en.When;
import java.util.function.Consumer;
+import jumper.filter.RequestFilter;
import jumper.mocks.MockHorizonServer;
import jumper.mocks.MockIrisServer;
import jumper.mocks.MockUpstreamServer;
@@ -18,6 +20,7 @@
import lombok.Setter;
import org.json.JSONException;
import org.json.JSONObject;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -40,6 +43,8 @@ public class BaseSteps {
private WebTestClient.ResponseSpec requestExchange;
private String id;
+ @Autowired private RequestFilter rf;
+
@Value("${jumper.stargate.url:https://stargate-integration.test.dhei.telekom.de}")
private String stargateUrl;
@@ -48,6 +53,15 @@ public void apiProviderWillRespondWithAStatusCode(int statusCode) {
responseStatusCode = String.valueOf(statusCode);
}
+ @And("API provider set to respond on {word} path")
+ public void apiProviderWillRespondWithAStatusCodeOnPath(String path_case) {
+ switch (path_case) {
+ case "real" -> mockUpstreamServer.failoverRequest(REMOTE_BASE_PATH);
+ case "failover" -> mockUpstreamServer.failoverRequest(REMOTE_FAILOVER_BASE_PATH);
+ case "provider" -> mockUpstreamServer.failoverRequest(REMOTE_PROVIDER_BASE_PATH);
+ }
+ }
+
@And("Event provider set to respond with a {int} status code")
public void eventProviderWillRespondWithAStatusCode(int statusCode) {
responseStatusCode = String.valueOf(statusCode);
@@ -175,6 +189,11 @@ public void consumerCallsTheAPI() {
.exchange();
}
+ @When("consumer calls the proxy route without base path")
+ public void consumerCallsProxy() {
+ requestExchange = webTestClient.get().uri("/proxy").headers(httpHeadersOfRequest).exchange();
+ }
+
@When("consumer calls the proxy route and runs into timeout")
public void consumerCallsTheAPIAndProviderRunsIntoTimeout() {
mockUpstreamServer.callbackRequestWithTimeout();
@@ -345,6 +364,11 @@ public void verifyQueryParam(String name, String value) {
mockUpstreamServer.verifyQueryParam(name, value);
}
+ @And("current zone is {string}")
+ public void currentZoneIs(String zone) {
+ rf.setCurrentZone(zone);
+ }
+
public static JSONObject getTestJson() {
JSONObject jsonObject = new JSONObject();
diff --git a/src/test/java/jumper/FailoverSteps.java b/src/test/java/jumper/FailoverSteps.java
new file mode 100644
index 0000000..476040c
--- /dev/null
+++ b/src/test/java/jumper/FailoverSteps.java
@@ -0,0 +1,44 @@
+// SPDX-FileCopyrightText: 2024 Deutsche Telekom AG
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package jumper;
+
+import static jumper.config.Config.REMOTE_ZONE_NAME;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import io.cucumber.java.After;
+import io.cucumber.java.en.And;
+import io.cucumber.java.en.Then;
+import jumper.model.config.HealthStatus;
+import jumper.service.ZoneHealthCheckService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+public class FailoverSteps {
+
+ private final ZoneHealthCheckService zoneHealthCheckService;
+
+ @Then("failover service for {} is returning {}")
+ public void failoverServiceIsReturning(String zone, HealthStatus healthStatus) {
+ boolean wantIsHealthy = healthStatus == HealthStatus.HEALTHY;
+ boolean zoneStatus = zoneHealthCheckService.getZoneHealth(zone);
+
+ assertEquals(
+ wantIsHealthy,
+ zoneStatus,
+ "Want:" + healthStatus + "\n" + "Got: " + zoneHealthCheckService.getZoneHealth(zone));
+ }
+
+ @And("set zone state to unhealthy")
+ public void setZoneUnhealthy() {
+ zoneHealthCheckService.setZoneHealth(REMOTE_ZONE_NAME, false);
+ }
+
+ @After("@failover")
+ public void clearCache() {
+ zoneHealthCheckService.clearCache();
+ }
+}
diff --git a/src/test/java/jumper/HeaderSteps.java b/src/test/java/jumper/HeaderSteps.java
index dc03a65..97e2dcc 100644
--- a/src/test/java/jumper/HeaderSteps.java
+++ b/src/test/java/jumper/HeaderSteps.java
@@ -10,6 +10,7 @@
import io.cucumber.java.en.And;
import io.cucumber.java.en.Given;
+import jumper.util.RoutingConfigUtil;
import jumper.util.TokenUtil;
import lombok.RequiredArgsConstructor;
@@ -23,12 +24,36 @@ public void proxyRouteHeadersSet() {
baseSteps.setHttpHeadersOfRequest(TokenUtil.getProxyRouteHeaders(baseSteps));
}
+ @Given("ProxyRoute headers are set with x-token-exchange")
+ public void proxyRouteHeadersSetWithXtokenExchange() {
+ baseSteps.authHeader = TokenUtil.getConsumerAccessToken();
+ baseSteps.setHttpHeadersOfRequest(TokenUtil.getProxyRouteHeadersWithXtokenExchange(baseSteps));
+ }
+
@Given("RealRoute headers are set")
public void realRouteHeadersSet() {
baseSteps.setHttpHeadersOfRequest(
TokenUtil.getRealRouteHeaders(TokenUtil.getConsumerAccessToken()));
}
+ @Given("RealRoute headers are set with x-token-exchange")
+ public void realRouteHeadersSetWithXtokenExchange() {
+ baseSteps.setHttpHeadersOfRequest(
+ TokenUtil.getRealRouteHeadersWithXtokenExchange(TokenUtil.getConsumerAccessToken()));
+ }
+
+ @Given("Secondary routing_config header set")
+ public void secondaryRoutingConfigHeaderSet() {
+ baseSteps.authHeader = TokenUtil.getConsumerAccessToken();
+ baseSteps.setHttpHeadersOfRequest(RoutingConfigUtil.getSecondaryRouteHeaders(baseSteps));
+ }
+
+ @Given("Proxy routing_config header set")
+ public void proxyRoutingConfigHeaderSet() {
+ baseSteps.authHeader = TokenUtil.getConsumerAccessToken();
+ baseSteps.setHttpHeadersOfRequest(RoutingConfigUtil.getProxyRouteHeaders(baseSteps));
+ }
+
@Given("RealRoute headers without Authorization are set")
public void realRouteHeadersNoAuth() {
baseSteps.setHttpHeadersOfRequest(TokenUtil.getRealRouteHeaders());
@@ -97,4 +122,13 @@ public void addTechnicalHeaders() {
httpHeaders.add("x-forwarded-prefix", "dummy");
}));
}
+
+ @And("skip zone header set")
+ public void setSkipZoneHeader() {
+ baseSteps.setHttpHeadersOfRequest(
+ baseSteps.httpHeadersOfRequest.andThen(
+ httpHeaders -> {
+ httpHeaders.set(Constants.HEADER_X_FAILOVER_SKIP_ZONE, REMOTE_ZONE_NAME);
+ }));
+ }
}
diff --git a/src/test/java/jumper/VerificationSteps.java b/src/test/java/jumper/VerificationSteps.java
index 17518d2..0ab37b2 100644
--- a/src/test/java/jumper/VerificationSteps.java
+++ b/src/test/java/jumper/VerificationSteps.java
@@ -81,6 +81,11 @@ public void apiProvidersReceivesNoTechnicalHeaders() {
.doesNotExist("access_token_forwarding");
}
+ @Then("API Provider receives no failover headers")
+ public void apiProvidersReceivesNoFailoverHeaders() {
+ this.baseSteps.getRequestExchange().expectHeader().doesNotExist("routing_config");
+ }
+
@Then("API Provider receives authorization {word}")
public void apiProviderReceivesToken(String tokenType) {
if (tokenType.equalsIgnoreCase("OneToken")) {
@@ -88,6 +93,11 @@ public void apiProviderReceivesToken(String tokenType) {
.getRequestExchange()
.expectHeader()
.value(HttpHeaders.AUTHORIZATION, this::checkOneToken);
+ } else if (tokenType.equalsIgnoreCase("OneTokenSimple")) {
+ this.baseSteps
+ .getRequestExchange()
+ .expectHeader()
+ .value(HttpHeaders.AUTHORIZATION, this::checkOneTokenSimple);
} else if (tokenType.equalsIgnoreCase("OneTokenWithPubSub")) {
this.baseSteps
.getRequestExchange()
@@ -136,6 +146,11 @@ public void apiProviderReceivesToken(String tokenType) {
.getRequestExchange()
.expectHeader()
.value(HttpHeaders.AUTHORIZATION, this::checkBasicAuthProvider);
+ } else if (tokenType.equalsIgnoreCase("XTokenExchangeHeader")) {
+ this.baseSteps
+ .getRequestExchange()
+ .expectHeader()
+ .valueMatches(HttpHeaders.AUTHORIZATION, "Bearer XTokenExchangeHeader");
} else {
fail("unknown authorization received");
}
@@ -183,6 +198,23 @@ private void checkOneToken(String token) {
assertNotNull(claimsFromToken.getBody().getIssuedAt());
}
+ private void checkOneTokenSimple(String token) {
+ Jwt claimsFromToken =
+ OauthTokenUtil.getAllClaimsFromToken(OauthTokenUtil.getTokenWithoutSignature(token));
+
+ assertEquals("Bearer", claimsFromToken.getBody().get("typ", String.class));
+ assertEquals(CONSUMER, claimsFromToken.getBody().get("clientId", String.class));
+ assertEquals("stargate", claimsFromToken.getBody().get("azp", String.class));
+ assertEquals(ENVIRONMENT, claimsFromToken.getBody().get("env", String.class));
+ assertEquals("GET", claimsFromToken.getBody().get("operation", String.class));
+ assertEquals(ORIGIN_ZONE, claimsFromToken.getBody().get("originZone", String.class));
+ assertEquals(ORIGIN_STARGATE, claimsFromToken.getBody().get("originStargate", String.class));
+ assertEquals(
+ localIssuerUrl + "/" + Constants.DEFAULT_REALM, claimsFromToken.getBody().getIssuer());
+ assertNotNull(claimsFromToken.getBody().getExpiration());
+ assertNotNull(claimsFromToken.getBody().getIssuedAt());
+ }
+
private void checkPubSub(String token) {
Jwt claimsFromToken =
OauthTokenUtil.getAllClaimsFromToken(OauthTokenUtil.getTokenWithoutSignature(token));
diff --git a/src/test/java/jumper/config/Config.java b/src/test/java/jumper/config/Config.java
index fb1dbf9..3000517 100644
--- a/src/test/java/jumper/config/Config.java
+++ b/src/test/java/jumper/config/Config.java
@@ -27,4 +27,11 @@ public class Config {
public static final String PUBSUB_SUBSCRIBER = "testSubscriber";
public static final String LISTENER_ISSUE = "issue";
public static final String LISTENER_PROVIDER = "serviceOwner";
+ public static final int REMOTE_HOST_PORT = 1080;
+ public static final String REMOTE_BASE_PATH = "/real";
+ public static final String REMOTE_FAILOVER_BASE_PATH = "/failover";
+ public static final String REMOTE_PROVIDER_BASE_PATH = "/provider";
+ public static final String REMOTE_HOST = "http://localhost:" + REMOTE_HOST_PORT;
+ public static final String REMOTE_ZONE_NAME = "realZone";
+ public static final String REMOTE_FAILOVER_ZONE_NAME = "failoverZone";
}
diff --git a/src/test/java/jumper/mocks/MockUpstreamServer.java b/src/test/java/jumper/mocks/MockUpstreamServer.java
index 62d9562..bdef58c 100644
--- a/src/test/java/jumper/mocks/MockUpstreamServer.java
+++ b/src/test/java/jumper/mocks/MockUpstreamServer.java
@@ -4,6 +4,7 @@
package jumper.mocks;
+import static jumper.config.Config.REMOTE_HOST_PORT;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockserver.integration.ClientAndServer.startClientAndServer;
import static org.mockserver.model.HttpClassCallback.callback;
@@ -27,9 +28,9 @@ public class MockUpstreamServer {
private ClientAndServer mockServer;
private MockServerClient mockServerClient;
+ int upstreamLocalPort = REMOTE_HOST_PORT;
public void startServer() {
- int upstreamLocalPort = 1080;
mockServer = startClientAndServer(upstreamLocalPort);
String upstreamLocalHost = "localhost";
mockServerClient = new MockServerClient(upstreamLocalHost, upstreamLocalPort);
@@ -48,6 +49,12 @@ public void callbackRequest() {
.respond(callback().withCallbackClass("jumper.mocks.TestExpectationCallback"));
}
+ public void failoverRequest(String path) {
+ mockServerClient
+ .when(request().withPath(path), Times.exactly(1))
+ .respond(callback().withCallbackClass("jumper.mocks.TestExpectationCallback"));
+ }
+
public void callbackRequestWithTimeout() {
mockServerClient
.when(request().withPath("/callback"))
diff --git a/src/test/java/jumper/mocks/TestExpectationCallback.java b/src/test/java/jumper/mocks/TestExpectationCallback.java
index 4d7e336..fad4f16 100644
--- a/src/test/java/jumper/mocks/TestExpectationCallback.java
+++ b/src/test/java/jumper/mocks/TestExpectationCallback.java
@@ -4,6 +4,7 @@
package jumper.mocks;
+import static jumper.config.Config.*;
import static org.mockserver.model.HttpResponse.notFoundResponse;
import static org.mockserver.model.HttpResponse.response;
@@ -15,7 +16,9 @@
public class TestExpectationCallback implements ExpectationResponseCallback {
@Override
public HttpResponse handle(HttpRequest httpRequest) {
- if (httpRequest.getPath().getValue().endsWith("/callback")) {
+ if (isFailoverPath(httpRequest.getPath().getValue())) {
+ return response().withHeaders(httpRequest.getHeaders()).withStatusCode(200);
+ } else if (httpRequest.getPath().getValue().endsWith("/callback")) {
return response()
.withHeaders(httpRequest.getHeaders())
.withBody(httpRequest.getBodyAsString())
@@ -28,4 +31,9 @@ public HttpResponse handle(HttpRequest httpRequest) {
return notFoundResponse();
}
}
+
+ private boolean isFailoverPath(String path) {
+ return List.of(REMOTE_BASE_PATH, REMOTE_FAILOVER_BASE_PATH, REMOTE_PROVIDER_BASE_PATH)
+ .contains(path);
+ }
}
diff --git a/src/test/java/jumper/service/RedisZoneHealthStatusServiceTest.java b/src/test/java/jumper/service/RedisZoneHealthStatusServiceTest.java
new file mode 100644
index 0000000..254fe5b
--- /dev/null
+++ b/src/test/java/jumper/service/RedisZoneHealthStatusServiceTest.java
@@ -0,0 +1,102 @@
+// SPDX-FileCopyrightText: 2023 Deutsche Telekom AG
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package jumper.service;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.time.Duration;
+import jumper.model.config.HealthStatus;
+import jumper.model.config.ZoneHealthMessage;
+import jumper.util.AbstractIntegrationTest;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.SpyBean;
+import org.springframework.data.redis.connection.DefaultMessage;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.test.context.ActiveProfiles;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test")
+class RedisZoneHealthStatusServiceTest extends AbstractIntegrationTest {
+
+ @Value("${jumper.zone.health.redis.channel}")
+ private String channelKey;
+
+ @Autowired private RedisTemplate redisTemplate;
+
+ @Autowired private ObjectMapper objectMapper;
+
+ @Autowired private RedisZoneHealthStatusService redisZoneHealthStatusService;
+
+ @SpyBean private ZoneHealthCheckService zoneHealthCheckService;
+
+ @BeforeEach
+ void setUp() {
+ Mockito.reset(zoneHealthCheckService);
+ }
+
+ @Test
+ @DisplayName(
+ "Test if a zone is marked correctly after receiving message via redis with a unhealthy status message")
+ void getZoneUnhealthyWithRedisPubSubListener() throws JsonProcessingException {
+ // given
+ String zoneToTest = "zoneToTest";
+ ZoneHealthMessage message = new ZoneHealthMessage(zoneToTest, HealthStatus.UNHEALTHY);
+ var messageString = objectMapper.writeValueAsString(message);
+ await()
+ .atMost(Duration.ofSeconds(15))
+ .until(() -> redisZoneHealthStatusService.isInitiallySubscribed());
+
+ // when
+ redisTemplate.convertAndSend(channelKey, messageString);
+
+ // then
+ Mockito.verify(zoneHealthCheckService, Mockito.timeout(5000L).times(1))
+ .setZoneHealth(Mockito.eq(zoneToTest), Mockito.eq(false));
+ assertFalse(zoneHealthCheckService.getZoneHealth(zoneToTest));
+ }
+
+ @ParameterizedTest
+ @DisplayName(
+ "Test if a zone is marked correctly healthy after receiving a incompatible message via redis with a unhealthy status message")
+ @ValueSource(
+ strings = {
+ """
+ {
+ "zone": "%s",
+ "status": "UNHEALTHYHELLO"
+ }
+ """,
+ """
+ {
+ "status": "UNHEALTHY"
+ }
+ """
+ })
+ void getZoneHealthyWithRedisPubSubListenerForMalformedMessage(String messageTemplate) {
+ // given
+ String zoneToTest = "wrongFormatZone";
+ var messageString = String.format(messageTemplate, zoneToTest);
+
+ // when
+ redisZoneHealthStatusService.onMessage(
+ new DefaultMessage(channelKey.getBytes(), messageString.getBytes()), null);
+
+ // then
+ Mockito.verify(zoneHealthCheckService, Mockito.times(0))
+ .setZoneHealth(Mockito.anyString(), Mockito.anyBoolean());
+ Mockito.verify(zoneHealthCheckService, Mockito.times(0))
+ .setZoneHealth(Mockito.isNull(), Mockito.anyBoolean());
+ assertTrue(zoneHealthCheckService.getZoneHealth(zoneToTest));
+ }
+}
diff --git a/src/test/java/jumper/util/AbstractIntegrationTest.java b/src/test/java/jumper/util/AbstractIntegrationTest.java
new file mode 100644
index 0000000..d29df6d
--- /dev/null
+++ b/src/test/java/jumper/util/AbstractIntegrationTest.java
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: 2023 Deutsche Telekom AG
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package jumper.util;
+
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+@Testcontainers
+public class AbstractIntegrationTest {
+
+ private static final GenericContainer> REDIS_CONTAINER;
+ private static final int REDIS_PORT = 6379;
+
+ static {
+ REDIS_CONTAINER =
+ new GenericContainer<>("bitnami/redis:latest")
+ .withEnv("REDIS_PASSWORD", "foobar")
+ .withExposedPorts(REDIS_PORT)
+ .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*", 1));
+ REDIS_CONTAINER.start();
+ }
+
+ @DynamicPropertySource
+ static void dynamicProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.redis.host", REDIS_CONTAINER::getHost);
+ registry.add("spring.redis.port", () -> REDIS_CONTAINER.getMappedPort(REDIS_PORT));
+ registry.add("jumper.zone.health.enabled", () -> true);
+ }
+}
diff --git a/src/test/java/jumper/util/RoutingConfigUtil.java b/src/test/java/jumper/util/RoutingConfigUtil.java
new file mode 100644
index 0000000..e555c00
--- /dev/null
+++ b/src/test/java/jumper/util/RoutingConfigUtil.java
@@ -0,0 +1,77 @@
+// SPDX-FileCopyrightText: 2023 Deutsche Telekom AG
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package jumper.util;
+
+import static jumper.config.Config.*;
+import static jumper.model.config.JumperConfig.toBase64;
+
+import java.util.List;
+import java.util.function.Consumer;
+import jumper.BaseSteps;
+import jumper.Constants;
+import jumper.model.config.*;
+import org.springframework.http.HttpHeaders;
+
+public class RoutingConfigUtil {
+
+ public static Consumer getSecondaryRouteHeaders(BaseSteps baseSteps) {
+ return httpHeaders -> {
+ httpHeaders.setBearerAuth(baseSteps.getAuthHeader());
+ httpHeaders.set(Constants.HEADER_ROUTING_CONFIG, getRcSecondary(baseSteps.getId()));
+ };
+ }
+
+ public static Consumer getProxyRouteHeaders(BaseSteps baseSteps) {
+ return httpHeaders -> {
+ httpHeaders.setBearerAuth(baseSteps.getAuthHeader());
+ httpHeaders.set(Constants.HEADER_ROUTING_CONFIG, getRcProxy(baseSteps.getId()));
+ };
+ }
+
+ public static String getRcSecondary(String id) {
+ // proxy + real
+ return toBase64(List.of(getProxyRouteJc(REMOTE_ZONE_NAME, id), getRealRouteJc()));
+ }
+
+ public static String getRcProxy(String id) {
+ // proxy + proxy
+ return toBase64(
+ List.of(
+ getProxyRouteJc(REMOTE_ZONE_NAME, id), getProxyRouteJc(REMOTE_FAILOVER_ZONE_NAME, id)));
+ }
+
+ private static JumperConfig getProxyRouteJc(String targetZone, String id) {
+ JumperConfig jc = new JumperConfig();
+ jc.setInternalTokenEndpoint("http://localhost:1081/auth/realms/default");
+ jc.setClientId(addIdSuffix("stargate", id));
+ jc.setClientSecret("secret");
+
+ switch (targetZone) {
+ case REMOTE_ZONE_NAME -> {
+ jc.setTargetZoneName(REMOTE_ZONE_NAME);
+ jc.setRemoteApiUrl(REMOTE_HOST + REMOTE_BASE_PATH);
+ }
+ case REMOTE_FAILOVER_ZONE_NAME -> {
+ jc.setTargetZoneName(REMOTE_FAILOVER_ZONE_NAME);
+ jc.setRemoteApiUrl(REMOTE_HOST + REMOTE_FAILOVER_BASE_PATH);
+ }
+ }
+ return jc;
+ }
+
+ private static JumperConfig getRealRouteJc() {
+ JumperConfig jc = new JumperConfig();
+ jc.setRemoteApiUrl(REMOTE_HOST + REMOTE_PROVIDER_BASE_PATH);
+ jc.setApiBasePath(BASE_PATH);
+ jc.setRealmName(REALM);
+ jc.setEnvName(ENVIRONMENT);
+ jc.setAccessTokenForwarding(false);
+ return jc;
+ }
+
+ public static String addIdSuffix(String from, String id) {
+ return from + "_" + id;
+ }
+}
diff --git a/src/test/java/jumper/util/TokenUtil.java b/src/test/java/jumper/util/TokenUtil.java
index aa0170c..63b5b62 100644
--- a/src/test/java/jumper/util/TokenUtil.java
+++ b/src/test/java/jumper/util/TokenUtil.java
@@ -59,6 +59,18 @@ public static Consumer getProxyRouteHeaders(BaseSteps baseSteps) {
};
}
+ public static Consumer getProxyRouteHeadersWithXtokenExchange(BaseSteps baseSteps) {
+ return httpHeaders -> {
+ httpHeaders.setBearerAuth(baseSteps.getAuthHeader());
+ httpHeaders.set(Constants.HEADER_REMOTE_API_URL, "http://localhost:1080");
+ httpHeaders.set(Constants.HEADER_ISSUER, "http://localhost:1081/auth/realms/default");
+ httpHeaders.set(Constants.HEADER_CLIENT_ID, addIdSuffix("stargate", baseSteps.getId()));
+ httpHeaders.set(Constants.HEADER_CLIENT_SECRET, "secret");
+ httpHeaders.set(Constants.HEADER_JUMPER_CONFIG, "e30=");
+ httpHeaders.set(Constants.HEADER_X_TOKEN_EXCHANGE, "Bearer XTokenExchangeHeader");
+ };
+ }
+
public static Consumer getRealRouteHeaders() {
return getRealRouteHeaders(null);
}
@@ -75,6 +87,19 @@ public static Consumer getRealRouteHeaders(String authorization) {
};
}
+ public static Consumer getRealRouteHeadersWithXtokenExchange(String authorization) {
+ return httpHeaders -> {
+ if (authorization != null) httpHeaders.setBearerAuth(authorization);
+ httpHeaders.set(Constants.HEADER_REMOTE_API_URL, "http://localhost:1080");
+ httpHeaders.set(Constants.HEADER_API_BASE_PATH, BASE_PATH);
+ httpHeaders.set(Constants.HEADER_ENVIRONMENT, ENVIRONMENT);
+ httpHeaders.set(Constants.HEADER_REALM, REALM);
+ httpHeaders.set(Constants.HEADER_ACCESS_TOKEN_FORWARDING, "false");
+ httpHeaders.set(Constants.HEADER_JUMPER_CONFIG, "e30=");
+ httpHeaders.set(Constants.HEADER_X_TOKEN_EXCHANGE, "Bearer XTokenExchangeHeader");
+ };
+ }
+
public static Consumer getEmptyHeaders() {
return httpHeaders -> {};
}
diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml
index 10ba891..16b9ca5 100644
--- a/src/test/resources/application.yml
+++ b/src/test/resources/application.yml
@@ -68,15 +68,34 @@ spring:
instrumentation-type: MANUAL #https://docs.spring.io/spring-cloud-sleuth/docs/current-SNAPSHOT/reference/html/integrations.html
codec:
max-in-memory-size: 4194304
+ data:
+ redis:
+ repositories:
+ enabled: false
+ redis:
+ connect-timeout: 500
+ timeout: 500
+ host: localhost
+ port: 6379
+ database: 2
+ password: foobar
jumper:
issuer:
url: ${JUMPER_ISSUER_URL:https://stargate-test.de/auth/realms}
stargate:
- url: ${STARGATE_URL:https://stargate-integration.test.dhei.telekom.de}
+ url: ${STARGATE_URL:https://stargate-integration.test.dhei.telekom.de}
security:
dir: src/test/resources/keypair
file: private.json
+ zone:
+ name: ${JUMPER_ZONE_NAME:default}
+ health:
+ enabled: :true
+ defaultZoneHealth: true
+ redis:
+ channel: stargate-zone-status
+ checkConnectionInterval: 15000
jumpercache.ttlOffset: 10
jumpercache.cleanCacheInSeconds: 3600
diff --git a/src/test/resources/features/failover.feature b/src/test/resources/features/failover.feature
new file mode 100644
index 0000000..424cfea
--- /dev/null
+++ b/src/test/resources/features/failover.feature
@@ -0,0 +1,66 @@
+# SPDX-FileCopyrightText: 2023 Deutsche Telekom AG
+#
+# SPDX-License-Identifier: Apache-2.0
+
+@upstream @iris @failover
+Feature: request containing routing_config properly handled
+
+ ################ secondary route ################
+ Scenario: Consumer calls proxy route with secondary config, proxy called
+ Given Secondary routing_config header set
+ And IDP set to provide internal token
+ And API provider set to respond on real path
+ When consumer calls the proxy route without base path
+ Then API Provider receives default bearer authorization headers
+ Then API Provider receives authorization MeshToken
+
+ Scenario: Consumer calls proxy route with secondary config and skip header, provider called
+ Given Secondary routing_config header set
+ And skip zone header set
+ And API provider set to respond on provider path
+ When consumer calls the proxy route without base path
+ Then API Provider receives default bearer authorization headers
+ Then API Provider receives authorization OneTokenSimple
+
+ Scenario: Consumer calls proxy route with secondary config and zone is unhealthy, provider called
+ Given Secondary routing_config header set
+ And set zone state to unhealthy
+ And API provider set to respond on provider path
+ When consumer calls the proxy route without base path
+ Then API Provider receives default bearer authorization headers
+ Then API Provider receives authorization OneTokenSimple
+
+################ proxy route ################
+ Scenario: Consumer calls proxy route with proxy config, real proxy called
+ Given Proxy routing_config header set
+ And IDP set to provide internal token
+ And API provider set to respond on real path
+ When consumer calls the proxy route without base path
+ Then API Provider receives default bearer authorization headers
+ Then API Provider receives authorization MeshToken
+
+ Scenario: Consumer calls proxy route with proxy config and skip header, failover proxy called
+ Given Proxy routing_config header set
+ And skip zone header set
+ And IDP set to provide internal token
+ And API provider set to respond on failover path
+ When consumer calls the proxy route without base path
+ Then API Provider receives default bearer authorization headers
+ Then API Provider receives authorization MeshToken
+
+ Scenario: Consumer calls proxy route with proxy config and zone is unhealthy, failover proxy called
+ Given Proxy routing_config header set
+ And set zone state to unhealthy
+ And IDP set to provide internal token
+ And API provider set to respond on failover path
+ When consumer calls the proxy route without base path
+ Then API Provider receives default bearer authorization headers
+ Then API Provider receives authorization MeshToken
+
+################ headers ################
+ Scenario: Consumer calls proxy route with secondary config, check header stripped
+ Given Secondary routing_config header set
+ And IDP set to provide internal token
+ And API provider set to respond on real path
+ When consumer calls the proxy route without base path
+ Then API Provider receives no failover headers
diff --git a/src/test/resources/features/xTokenExchange.feature b/src/test/resources/features/xTokenExchange.feature
new file mode 100644
index 0000000..f25d31d
--- /dev/null
+++ b/src/test/resources/features/xTokenExchange.feature
@@ -0,0 +1,75 @@
+# SPDX-FileCopyrightText: 2023 Deutsche Telekom AG
+#
+# SPDX-License-Identifier: Apache-2.0
+
+@upstream @iris @xtokenexchange
+Feature: proper authorization token reaches provider endpoint if x-token-exchange header set
+
+ Scenario Outline: Consumer calls proxy route with XtokenExchange Header and currentZone space || canis || aries
+ Given RealRoute headers are set with x-token-exchange
+ And current zone is ""
+ And API provider set to respond with a 200 status code
+ When consumer calls the proxy route
+ Then API Provider receives authorization XTokenExchangeHeader
+ Examples:
+ | zone |
+ | canis |
+ | aries |
+ | space |
+
+ Scenario Outline: Consumer calls proxy route with XtokenExchange Header and currentZone aws || cetus
+ Given RealRoute headers are set with x-token-exchange
+ And current zone is ""
+ And API provider set to respond with a 200 status code
+ When consumer calls the proxy route
+ Then API Provider receives authorization OneToken
+ Examples:
+ | zone |
+ | aws |
+ | cetus |
+
+
+
+ Scenario Outline: Consumer calls proxy route with proxy route headers with xTokenExchange header, mesh token sent
+ Given ProxyRoute headers are set with x-token-exchange
+ And current zone is ""
+ And IDP set to provide internal token
+ And API provider set to respond with a 200 status code
+ When consumer calls the proxy route
+ Then API Provider receives default bearer authorization headers
+ Then API Provider receives authorization MeshToken
+ And API consumer receives a 200 status code
+ Examples:
+ | zone |
+ | canis |
+ | aries |
+ | space |
+ | aws |
+ | cetus |
+
+ Scenario Outline: Consumer calls proxy route with real route headers with xTokenExchange header and currentZone space, jc with consumer and provider specific basic auth provided, xTokenExchange sent
+ Given RealRoute headers are set with x-token-exchange
+ And current zone is ""
+ And API provider set to respond with a 200 status code
+ And jumperConfig basic auth "consumer and provider" set
+ When consumer calls the proxy route
+ Then API Provider receives authorization XTokenExchangeHeader
+ And API consumer receives a 200 status code
+ Examples:
+ | zone |
+ | canis |
+ | aries |
+ | space |
+
+ Scenario Outline: Consumer calls proxy route with real route headers with xTokenExchange header and currentZone space, jc with consumer and provider specific basic auth provided, xTokenExchange sent
+ Given RealRoute headers are set with x-token-exchange
+ And current zone is ""
+ And API provider set to respond with a 200 status code
+ And jumperConfig basic auth "consumer and provider" set
+ When consumer calls the proxy route
+ Then API Provider receives authorization BasicAuthConsumer
+ And API consumer receives a 200 status code
+ Examples:
+ | zone |
+ | aws |
+ | cetus |
\ No newline at end of file