From 1f57e1938c9ad3cf05ddcaed83627a18c1ca1004 Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Wed, 26 Aug 2026 16:00:56 +0400 Subject: [PATCH 01/20] feat(consul): add kubernetes auth method as a second way to get an ACL token Introduce ConsulLoginCredentials as the seam between the login protocol and the way the bearer token is obtained, and build the core of the new way on top of it: - ConsulLoginCredentials with M2MLoginCredentials and KubernetesLoginCredentials - ConsulClient.login(ConsulLoginCredentials) added with a default implementation; login(String) is deprecated for removal - ConsulOkHttpClient lets a transport IOException out instead of wrapping it, ConsulRestClient wraps MicroserviceRestClientException into IOException - ConsulLogin as the single-operation interface, TokenProvider implements it - SelfTokenReader takes reading an already issued token out of TokenProvider - TokenUpdater works through ConsulLogin, schedules a relogin at 80% of the remaining lifetime and pauses between retries - ProbingConsulLogin probes the new way, falls back to the old one on any failure and sticks to the choice until the pod restarts - ConsulLoginMode plus mode, authMethod and audience inputs in CreateOptions, with per-mode validation - TokenStorageFactory.from builds the chain in one place The auth method default is a placeholder until infrastructure confirms the real name. --- .../consul-config-provider-common/pom.xml | 10 + .../consul/provider/common/ConsulLogin.java | 8 + .../common/ConsulLoginCredentials.java | 8 + .../provider/common/ConsulLoginMode.java | 7 + .../common/KubernetesLoginCredentials.java | 24 +++ .../provider/common/M2MLoginCredentials.java | 24 +++ .../provider/common/ProbingConsulLogin.java | 89 +++++++++ .../provider/common/SelfTokenReader.java | 40 ++++ .../consul/provider/common/TokenProvider.java | 29 +-- .../provider/common/TokenStorageFactory.java | 69 ++++++- .../consul/provider/common/TokenUpdater.java | 50 ++++- .../provider/common/client/ConsulClient.java | 10 + .../common/client/ConsulOkHttpClient.java | 18 +- .../common/client/ConsulRestClient.java | 23 +++ .../KubernetesLoginCredentialsTest.java | 67 +++++++ .../common/M2MLoginCredentialsTest.java | 41 ++++ .../common/ProbingConsulLoginTest.java | 188 ++++++++++++++++++ .../provider/common/SelfTokenReaderTest.java | 71 +++++++ .../provider/common/TokenProviderTest.java | 82 +++++--- .../common/TokenStorageFactoryTest.java | 186 ++++++++++++++++- .../provider/common/TokenUpdaterTest.java | 111 +++++++++-- .../client/ConsulClientDefaultLoginTest.java | 60 ++++++ .../common/client/ConsulOkHttpClientTest.java | 78 ++++++++ .../common/client/ConsulRestClientTest.java | 57 ++++++ .../ConsulM2MConfigDataLocationResolver.java | 19 +- 25 files changed, 1288 insertions(+), 81 deletions(-) create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLogin.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentials.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentials.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentialsTest.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentialsTest.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLoginTest.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/SelfTokenReaderTest.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulClientDefaultLoginTest.java diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/pom.xml b/core-rest-libraries/consul-config-provider/consul-config-provider-common/pom.xml index 71d3c2e568..9caa49e078 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/pom.xml +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/pom.xml @@ -22,6 +22,11 @@ com.netcracker.cloud microservice-restclient-api + + com.netcracker.cloud.security.core.utils + k8s-utils + ${cloud-core.core-utils.version} + com.squareup.okhttp3 okhttp @@ -36,6 +41,11 @@ mockito-core test + + ch.qos.logback + logback-classic + test + org.slf4j slf4j-api diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLogin.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLogin.java new file mode 100644 index 0000000000..347a130354 --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLogin.java @@ -0,0 +1,8 @@ +package com.netcracker.cloud.consul.provider.common; + +import java.io.IOException; + +public interface ConsulLogin { + + Token perform() throws IOException; +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java new file mode 100644 index 0000000000..7d7da71dca --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java @@ -0,0 +1,8 @@ +package com.netcracker.cloud.consul.provider.common; + +public interface ConsulLoginCredentials { + + String authMethod(); + + String bearerToken(); +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java new file mode 100644 index 0000000000..516075ffb0 --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java @@ -0,0 +1,7 @@ +package com.netcracker.cloud.consul.provider.common; + +public enum ConsulLoginMode { + AUTO, + KUBERNETES, + M2M +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentials.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentials.java new file mode 100644 index 0000000000..5306b7e4aa --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentials.java @@ -0,0 +1,24 @@ +package com.netcracker.cloud.consul.provider.common; + +import com.netcracker.cloud.security.core.utils.k8s.KubernetesAudienceToken; + +final class KubernetesLoginCredentials implements ConsulLoginCredentials { + + private final String authMethod; + private final String audience; + + KubernetesLoginCredentials(String authMethod, String audience) { + this.authMethod = authMethod; + this.audience = audience; + } + + @Override + public String authMethod() { + return authMethod; + } + + @Override + public String bearerToken() { + return KubernetesAudienceToken.getToken(audience); + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentials.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentials.java new file mode 100644 index 0000000000..c82b423979 --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentials.java @@ -0,0 +1,24 @@ +package com.netcracker.cloud.consul.provider.common; + +import java.util.function.Supplier; + +final class M2MLoginCredentials implements ConsulLoginCredentials { + + private final String namespace; + private final Supplier m2mSupplier; + + M2MLoginCredentials(String namespace, Supplier m2mSupplier) { + this.namespace = namespace; + this.m2mSupplier = m2mSupplier; + } + + @Override + public String authMethod() { + return namespace; + } + + @Override + public String bearerToken() { + return m2mSupplier.get(); + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java new file mode 100644 index 0000000000..f759c76fa5 --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java @@ -0,0 +1,89 @@ +package com.netcracker.cloud.consul.provider.common; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; + +final class ProbingConsulLogin implements ConsulLogin { + + static final int PROBE_TRIES = 3; + static final int ERROR_MESSAGE_LIMIT = 512; + + private static final Duration DEFAULT_PROBE_PAUSE = Duration.ofSeconds(1); + private static final String KUBERNETES_WAY = "kubernetes"; + private static final String M2M_WAY = "m2m"; + + private static final Logger log = LoggerFactory.getLogger(ProbingConsulLogin.class); + + private final ConsulLogin probed; + private final ConsulLogin fallback; + private final int tries; + private final Duration probePause; + + private volatile ConsulLogin chosen; + + ProbingConsulLogin(ConsulLogin probed, ConsulLogin fallback) { + this(probed, fallback, PROBE_TRIES, DEFAULT_PROBE_PAUSE); + } + + ProbingConsulLogin(ConsulLogin probed, ConsulLogin fallback, int tries, Duration probePause) { + this.probed = probed; + this.fallback = fallback; + this.tries = tries; + this.probePause = probePause; + } + + @Override + public synchronized Token perform() throws IOException { + if (chosen != null) { + return chosen.perform(); + } + try { + Token token = probe(); + chosen = probed; + log.info("Consul ACL token is obtained by the {} auth method", KUBERNETES_WAY); + return token; + } catch (Exception e) { + chosen = fallback; + log.info("Consul login by the {} auth method failed, falling back to the {} one until the pod restarts: {}", + KUBERNETES_WAY, M2M_WAY, describe(e)); + return fallback.perform(); + } + } + + private Token probe() throws IOException { + int count = 0; + while (true) { + try { + return probed.perform(); + } catch (IOException e) { + if (++count >= tries) { + throw e; + } + pauseBeforeNextAttempt(); + } + } + } + + private void pauseBeforeNextAttempt() { + if (probePause.isZero() || probePause.isNegative()) { + return; + } + try { + Thread.sleep(probePause.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("interrupted while probing the consul login", e); + } + } + + private static String describe(Exception e) { + String message = e.getMessage() == null ? "" : e.getMessage(); + if (message.length() > ERROR_MESSAGE_LIMIT) { + message = message.substring(0, ERROR_MESSAGE_LIMIT) + "..."; + } + return e.getClass().getSimpleName() + ": " + message; + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java new file mode 100644 index 0000000000..6e8caf4c5a --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java @@ -0,0 +1,40 @@ +package com.netcracker.cloud.consul.provider.common; + +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.PathNotFoundException; +import com.netcracker.cloud.consul.provider.common.client.ConsulClient; +import com.netcracker.cloud.consul.provider.common.client.ConsulClientResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.OffsetDateTime; + +public class SelfTokenReader { + + private static final Logger log = LoggerFactory.getLogger(SelfTokenReader.class); + + private final ConsulClient client; + + public SelfTokenReader(ConsulClient client) { + this.client = client; + } + + public Token read(String currentSecretId) throws IOException { + ConsulClientResponse response = client.getSelfToken(currentSecretId); + String bodyJson = response.getBodyJson(); + if (response.getCode() != 200) { + throw new IOException(String.format("can not get self token from consul; response code=%s; body='%s'", response.getCode(), bodyJson)); + } + + String secretId = JsonPath.read(bodyJson, "$.SecretID"); + OffsetDateTime expirationTime = null; + try { + expirationTime = OffsetDateTime.parse(JsonPath.read(bodyJson, "$.ExpirationTime")); + } catch (PathNotFoundException ex) { + // No Expiration Time. Nothing to do. + } + log.debug("Got self token from Consul"); + return new Token(secretId, expirationTime); + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenProvider.java index 438a59a728..a86b7aa9ab 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenProvider.java @@ -10,39 +10,28 @@ import java.io.IOException; import java.time.OffsetDateTime; -public class TokenProvider { +public class TokenProvider implements ConsulLogin { private static final Logger log = LoggerFactory.getLogger(TokenProvider.class); private final ConsulClient client; - private final String authMethod; + private final ConsulLoginCredentials credentials; - public TokenProvider(ConsulClient client, String authMethod) { + public TokenProvider(ConsulClient client, ConsulLoginCredentials credentials) { this.client = client; - this.authMethod = authMethod; + this.credentials = credentials; } - public Token getSelf(String currentSecretId) throws IOException { - ConsulClientResponse response = client.getSelfToken(currentSecretId); - String bodyJson = response.getBodyJson(); - if (response.getCode() != 200) { - throw new IOException(String.format("can not get self token from consul; response code=%s; body='%s'", response.getCode(), bodyJson)); - } - - String secretId = JsonPath.read(bodyJson, "$.SecretID"); - OffsetDateTime expirationTime = OffsetDateTime.parse(JsonPath.read(bodyJson, "$.ExpirationTime")); - log.debug("Got self token from Consul"); - return new Token(secretId, expirationTime); - } - - public Token getNewConsulToken() throws IOException { - ConsulClientResponse response = client.login(authMethod); + @Override + public Token perform() throws IOException { + ConsulClientResponse response = client.login(credentials); String responseBody = response.getBodyJson(); if (responseBody == null || responseBody.isEmpty()) { throw new IOException("can not get consul token by m2m token: response body is empty"); } if (response.getCode() != 200) { - throw new IOException("can not get consul token by m2m token: " + responseBody); + String reason = response.getCode() == 403 ? "consul auth method is not ready" : "login to consul failed"; + throw new IOException(String.format("%s: response code=%s; body='%s'", reason, response.getCode(), responseBody)); } String secretId = JsonPath.read(responseBody, "$.SecretID"); OffsetDateTime expirationTime = null; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java index 0301fd9abe..e448c44b37 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java @@ -1,8 +1,9 @@ package com.netcracker.cloud.consul.provider.common; import com.netcracker.cloud.consul.provider.common.client.ConsulClient; +import com.netcracker.cloud.security.core.utils.k8s.AudienceName; -import java.util.Objects; +import java.util.Locale; import java.util.function.Supplier; /** @@ -17,20 +18,45 @@ protected TokenStorageFactory() { public TokenStorage create(CreateOptions config) { ConsulClient consulClient = createTokenExchanger(config); - TokenUpdater tokenUpdater = new TokenUpdater(consulClient, config.namespace); + TokenUpdater tokenUpdater = new TokenUpdater(from(consulClient, config), new SelfTokenReader(consulClient)); TokenStorage tokenStorage = createTokenStorage(config); tokenUpdater.watch(tokenStorage::update, tokenStorage.get()); return tokenStorage; } + public static ConsulLogin from(ConsulClient client, CreateOptions options) { + switch (options.mode) { + case M2M: + return m2mLogin(client, options); + case KUBERNETES: + return kubernetesLogin(client, options); + default: + return new ProbingConsulLogin(kubernetesLogin(client, options), m2mLogin(client, options)); + } + } + + private static ConsulLogin m2mLogin(ConsulClient client, CreateOptions options) { + return new TokenProvider(client, new M2MLoginCredentials(options.namespace, options.m2mSupplier)); + } + + private static ConsulLogin kubernetesLogin(ConsulClient client, CreateOptions options) { + return new TokenProvider(client, new KubernetesLoginCredentials(options.authMethod, options.audience)); + } + abstract protected TokenStorage createTokenStorage(CreateOptions config); abstract protected ConsulClient createTokenExchanger(CreateOptions config); public static class CreateOptions { + + public static final String DEFAULT_AUTH_METHOD = "kubernetes-auth-method-placeholder"; + String consulUrl; String namespace; Supplier m2mSupplier; + ConsulLoginMode mode; + String authMethod; + String audience; public static class Builder { CreateOptions options = new CreateOptions(); @@ -53,14 +79,47 @@ public Builder m2mSupplier(Supplier m2mTokenSupplier) { return this; } + public Builder mode(ConsulLoginMode mode) { + options.mode = mode; + return this; + } + + public Builder authMethod(String authMethod) { + options.authMethod = authMethod; + return this; + } + + public Builder audience(String audience) { + options.audience = audience; + return this; + } + public CreateOptions build() { - Objects.requireNonNull(options.consulUrl); - Objects.requireNonNull(options.namespace); - Objects.requireNonNull(options.m2mSupplier); + if (options.mode == null) { + options.mode = ConsulLoginMode.AUTO; + } + if (options.authMethod == null) { + options.authMethod = DEFAULT_AUTH_METHOD; + } + if (options.audience == null) { + options.audience = AudienceName.NETCRACKER; + } + require(options.consulUrl != null, "consulUrl", options.mode); + if (options.mode != ConsulLoginMode.KUBERNETES) { + require(options.namespace != null, "namespace", options.mode); + require(options.m2mSupplier != null, "m2mSupplier", options.mode); + } CreateOptions result = options; options = new CreateOptions(); return result; } + + private static void require(boolean given, String input, ConsulLoginMode mode) { + if (!given) { + throw new IllegalArgumentException(String.format("%s is required in the %s consul login mode", + input, mode.name().toLowerCase(Locale.ROOT))); + } + } } } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java index 6c15662a91..dbdce749b5 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java @@ -1,11 +1,11 @@ package com.netcracker.cloud.consul.provider.common; -import com.netcracker.cloud.consul.provider.common.client.ConsulClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.time.Clock; +import java.time.Duration; import java.time.OffsetDateTime; import java.time.temporal.ChronoUnit; import java.util.concurrent.Executors; @@ -16,40 +16,50 @@ public class TokenUpdater { private static final Logger log = LoggerFactory.getLogger(TokenUpdater.class); - private static final int DEFAULT_TRIES = 10; - private final TokenProvider tokenProvider; + static final int DEFAULT_TRIES = 10; + private static final Duration DEFAULT_RETRY_PAUSE = Duration.ofSeconds(1); + private static final long DELAY_NUMERATOR = 8; + private static final long DELAY_DENOMINATOR = 10; + static final long MIN_DELAY_SECONDS = 10; + private final ConsulLogin consulLogin; + private final SelfTokenReader selfTokenReader; private ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); private Clock clock = Clock.systemDefaultZone(); private final Integer tries; + private final Duration retryPause; - public TokenUpdater(ConsulClient client, String authMethod) { - this.tokenProvider = new TokenProvider(client, authMethod); + public TokenUpdater(ConsulLogin consulLogin, SelfTokenReader selfTokenReader) { + this.consulLogin = consulLogin; + this.selfTokenReader = selfTokenReader; this.tries = DEFAULT_TRIES; + this.retryPause = DEFAULT_RETRY_PAUSE; } - TokenUpdater(TokenProvider tokenProvider, ScheduledExecutorService executor, Clock clock, int tries) { - this.tokenProvider = tokenProvider; + TokenUpdater(ConsulLogin consulLogin, SelfTokenReader selfTokenReader, ScheduledExecutorService executor, Clock clock, int tries, Duration retryPause) { + this.consulLogin = consulLogin; + this.selfTokenReader = selfTokenReader; this.executor = executor; this.clock = clock; this.tries = tries; + this.retryPause = retryPause; } synchronized public void watch(Consumer updater, String currentSecretId) { log.debug("Start token refreshing process for consul"); Token token; if (currentSecretId == null || currentSecretId.isEmpty()) { - token = withRetry(unused -> tokenProvider.getNewConsulToken(), tries); + token = withRetry(unused -> consulLogin.perform(), tries); updater.accept(token.getSecretId()); } else { - token = withRetry(unused -> tokenProvider.getSelf(currentSecretId), tries); + token = withRetry(unused -> selfTokenReader.read(currentSecretId), tries); } if (token.getExpirationTime() != null) { - long delay = ChronoUnit.SECONDS.between(OffsetDateTime.now(clock), token.getExpirationTime().minusMinutes(5)); + long delay = relogonDelay(token.getExpirationTime()); Runnable task = () -> { log.debug("Get new consul token with {} retry attempts", tries); try { - Token newToken = withRetry(unused -> tokenProvider.getNewConsulToken(), tries); + Token newToken = withRetry(unused -> consulLogin.perform(), tries); updater.accept(newToken.getSecretId()); } catch (Exception e) { log.error("Error occurred during getting new consul token. Will try in {} second.", delay, e); @@ -59,6 +69,11 @@ synchronized public void watch(Consumer updater, String currentSecretId) } } + private long relogonDelay(OffsetDateTime expirationTime) { + long remaining = ChronoUnit.SECONDS.between(OffsetDateTime.now(clock), expirationTime); + return Math.max(remaining * DELAY_NUMERATOR / DELAY_DENOMINATOR, MIN_DELAY_SECONDS); + } + private Token withRetry(CheckedFunction c, int tries) { int count = 0; while (true) { @@ -69,10 +84,23 @@ private Token withRetry(CheckedFunction c, int tries) { throw new RuntimeException("can not update consul token: ", e); } log.debug("Failed {} retry attempt, exception: {}", count, e); + pauseBeforeNextAttempt(); } } } + private void pauseBeforeNextAttempt() { + if (retryPause.isZero() || retryPause.isNegative()) { + return; + } + try { + Thread.sleep(retryPause.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("interrupted while waiting for the next consul token attempt", e); + } + } + @FunctionalInterface public interface CheckedFunction { R apply(T t) throws IOException; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulClient.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulClient.java index dfda3b02cd..1782b32fc2 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulClient.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulClient.java @@ -1,5 +1,9 @@ package com.netcracker.cloud.consul.provider.common.client; +import com.netcracker.cloud.consul.provider.common.ConsulLoginCredentials; + +import java.io.IOException; + public interface ConsulClient { String V1_ACL_LOGIN = "/v1/acl/login"; @@ -11,5 +15,11 @@ public interface ConsulClient { String CONTENT_TYPE = "Content-Type"; ConsulClientResponse getSelfToken(String currentSecretId); + + @Deprecated(forRemoval = true) ConsulClientResponse login(String authMethod); + + default ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOException { + return login(credentials.authMethod()); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java index 477d75f635..31de7f1084 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java @@ -1,6 +1,7 @@ package com.netcracker.cloud.consul.provider.common.client; import com.google.gson.Gson; +import com.netcracker.cloud.consul.provider.common.ConsulLoginCredentials; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -68,5 +69,20 @@ public ConsulClientResponse login(String authMethod) { } return new ConsulClientResponse(responseBody, response.code()); } -} + @Override + public ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOException { + Map payload = new HashMap<>(); + payload.put(AUTH_METHOD_FIELD, credentials.authMethod()); + payload.put(BEARER_TOKEN_FIELD, credentials.bearerToken()); + String json = new Gson().toJson(payload); + log.info("Perform login to {} with {} auth method", consulAddr, credentials.authMethod()); + Response response = client.newCall(new Request.Builder() + .post(RequestBody.create(MediaType.parse(APPLICATION_JSON), json)) + .url(consulAddr + V1_ACL_LOGIN) + .addHeader(CONTENT_TYPE, APPLICATION_JSON) + .build() + ).execute(); + return new ConsulClientResponse(response.body().string(), response.code()); + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java index d3396392e6..36f7033708 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java @@ -1,12 +1,15 @@ package com.netcracker.cloud.consul.provider.common.client; import com.google.gson.Gson; +import com.netcracker.cloud.consul.provider.common.ConsulLoginCredentials; import com.netcracker.cloud.restclient.HttpMethod; import com.netcracker.cloud.restclient.MicroserviceRestClient; import com.netcracker.cloud.restclient.entity.RestClientResponseEntity; +import com.netcracker.cloud.restclient.exception.MicroserviceRestClientException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -58,4 +61,24 @@ public ConsulClientResponse login(String authMethod) { RestClientResponseEntity response = client.doRequest(consulAddr + V1_ACL_LOGIN, HttpMethod.POST, headers, json, String.class); return new ConsulClientResponse(response.getResponseBody(), response.getHttpStatus()); } + + @Override + public ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOException { + Map payload = new HashMap<>(); + payload.put(AUTH_METHOD_FIELD, credentials.authMethod()); + payload.put(BEARER_TOKEN_FIELD, credentials.bearerToken()); + String json = new Gson().toJson(payload); + + Map> headers = new HashMap<>(); + headers.put(CONTENT_TYPE, Collections.singletonList(APPLICATION_JSON)); + + log.debug("Perform login to {} with {} auth method", consulAddr, credentials.authMethod()); + RestClientResponseEntity response; + try { + response = client.doRequest(consulAddr + V1_ACL_LOGIN, HttpMethod.POST, headers, json, String.class); + } catch (MicroserviceRestClientException e) { + throw new IOException("can not perform login to consul: " + e.getMessage(), e); + } + return new ConsulClientResponse(response.getResponseBody(), response.getHttpStatus()); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentialsTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentialsTest.java new file mode 100644 index 0000000000..8ac1da4a1d --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentialsTest.java @@ -0,0 +1,67 @@ +package com.netcracker.cloud.consul.provider.common; + +import com.netcracker.cloud.security.core.utils.k8s.AudienceName; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class KubernetesLoginCredentialsTest { + + private static final String TOKENS_DIR_PROP = "com.netcracker.cloud.security.kubernetes.tokens.dir"; + private static final String POLLING_INTERVAL_PROP = "com.netcracker.cloud.security.kubernetes.tokens.polling.interval"; + + @TempDir + static Path tokensDir; + + @BeforeAll + static void pointTokenSourceToTempDir() { + System.setProperty(TOKENS_DIR_PROP, tokensDir.toString()); + System.setProperty(POLLING_INTERVAL_PROP, "PT0S"); + } + + private static void writeToken(String audience, String value) throws IOException { + Path audienceDir = Files.createDirectories(tokensDir.resolve(audience)); + Files.writeString(audienceDir.resolve("token"), value); + } + + @Test + void authMethodIsTheGivenName() { + KubernetesLoginCredentials credentials = new KubernetesLoginCredentials("core-k8s", AudienceName.NETCRACKER); + assertEquals("core-k8s", credentials.authMethod()); + } + + @Test + void bearerTokenIsReadOnEveryCallAndNotCached() throws IOException { + writeToken(AudienceName.NETCRACKER, "first-projected-token"); + KubernetesLoginCredentials credentials = new KubernetesLoginCredentials("core-k8s", AudienceName.NETCRACKER); + + assertEquals("first-projected-token", credentials.bearerToken()); + + writeToken(AudienceName.NETCRACKER, "rotated-projected-token"); + assertEquals("rotated-projected-token", credentials.bearerToken()); + } + + @Test + void bearerTokenFailsWithRuntimeExceptionWhenTokenFileIsUnreadable() throws IOException { + Files.createDirectories(tokensDir.resolve(AudienceName.MAAS).resolve("token")); + KubernetesLoginCredentials credentials = new KubernetesLoginCredentials("core-k8s", AudienceName.MAAS); + + RuntimeException thrown = assertThrows(RuntimeException.class, credentials::bearerToken); + assertInstanceOf(IOException.class, thrown.getCause()); + } + + @Test + void bearerTokenFailsWithIllegalArgumentExceptionForUnknownAudience() { + KubernetesLoginCredentials credentials = new KubernetesLoginCredentials("core-k8s", "no-such-audience"); + + assertThrows(IllegalArgumentException.class, credentials::bearerToken); + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentialsTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentialsTest.java new file mode 100644 index 0000000000..d4e1cfc86e --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentialsTest.java @@ -0,0 +1,41 @@ +package com.netcracker.cloud.consul.provider.common; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class M2MLoginCredentialsTest { + + @Test + void authMethodIsNamespace() { + M2MLoginCredentials credentials = new M2MLoginCredentials("test-namespace", () -> "test-m2m-token"); + assertEquals("test-namespace", credentials.authMethod()); + } + + @Test + void bearerTokenAsksSupplierOnEveryCall() { + AtomicInteger calls = new AtomicInteger(); + Supplier supplier = () -> "test-m2m-token-" + calls.incrementAndGet(); + + M2MLoginCredentials credentials = new M2MLoginCredentials("test-namespace", supplier); + + assertEquals("test-m2m-token-1", credentials.bearerToken()); + assertEquals("test-m2m-token-2", credentials.bearerToken()); + assertEquals(2, calls.get()); + } + + @Test + void bearerTokenSupplierIsNotAskedOnCreation() { + AtomicInteger calls = new AtomicInteger(); + + new M2MLoginCredentials("test-namespace", () -> { + calls.incrementAndGet(); + return "test-m2m-token"; + }); + + assertEquals(0, calls.get()); + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLoginTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLoginTest.java new file mode 100644 index 0000000000..2feea7d97b --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLoginTest.java @@ -0,0 +1,188 @@ +package com.netcracker.cloud.consul.provider.common; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class ProbingConsulLoginTest { + + private static final String BEARER_TOKEN = "my-secret-bearer-token"; + private static final String SECRET_ID = "my-secret-acl-token"; + + private static final String AUTH_METHOD_NOT_FOUND = + "ACL not found: auth method \"k8s-does-not-exist\" not found"; + private static final String TOKEN_REVIEW_UNREACHABLE = + "Post \"https://kubernetes.default.svc/apis/authentication.k8s.io/v1/tokenreviews\": " + + "dial tcp 10.96.0.1:443: connect: connection refused"; + + private ConsulLogin kubernetes; + private ConsulLogin m2m; + private ListAppender appender; + private ch.qos.logback.classic.Logger logger; + + @BeforeEach + void init() { + kubernetes = mock(ConsulLogin.class); + m2m = mock(ConsulLogin.class); + + logger = ((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(ProbingConsulLogin.class); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.INFO); + } + + @AfterEach + void detachAppender() { + logger.detachAppender(appender); + } + + private ProbingConsulLogin probing() { + return new ProbingConsulLogin(kubernetes, m2m, 2, Duration.ZERO); + } + + private List infoRecords() { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.INFO) + .map(ILoggingEvent::getFormattedMessage) + .collect(Collectors.toList()); + } + + @Test + void successOnFirstAttemptSticksToTheNewWay() throws IOException { + Token token = new Token(SECRET_ID, null); + when(kubernetes.perform()).thenReturn(token); + + ProbingConsulLogin probing = probing(); + assertEquals(token, probing.perform()); + assertEquals(token, probing.perform()); + + verify(kubernetes, times(2)).perform(); + verifyNoInteractions(m2m); + } + + @Test + void anyExceptionFromTheNewWayLeadsToTheOldOneAndSticksIt() throws IOException { + Token token = new Token(SECRET_ID, null); + when(kubernetes.perform()).thenThrow(new IllegalArgumentException("Unknown token audience: netcracker")); + when(m2m.perform()).thenReturn(token); + + ProbingConsulLogin probing = probing(); + assertEquals(token, probing.perform()); + assertEquals(token, probing.perform()); + + verify(kubernetes, times(1)).perform(); + verify(m2m, times(2)).perform(); + } + + @Test + void errorPassesThrough() throws IOException { + when(kubernetes.perform()).thenThrow(new Error("Unable to locate implementation for TokenSource")); + + assertThrows(Error.class, () -> probing().perform()); + verifyNoInteractions(m2m); + } + + @Test + void probeSpendsFewerTriesThanTokenUpdater() throws IOException { + when(kubernetes.perform()).thenThrow(new IOException( + "login to consul failed: response code=500; body='" + TOKEN_REVIEW_UNREACHABLE + "'")); + when(m2m.perform()).thenReturn(new Token(SECRET_ID, null)); + + new ProbingConsulLogin(kubernetes, m2m).perform(); + + verify(kubernetes, times(ProbingConsulLogin.PROBE_TRIES)).perform(); + assertTrue(ProbingConsulLogin.PROBE_TRIES < TokenUpdater.DEFAULT_TRIES); + } + + @Test + void chosenWayIsLoggedOnceOnSuccess() throws IOException { + when(kubernetes.perform()).thenReturn(new Token(SECRET_ID, null)); + + ProbingConsulLogin probing = probing(); + probing.perform(); + probing.perform(); + + List records = infoRecords(); + assertEquals(1, records.size()); + assertTrue(records.get(0).contains("kubernetes")); + } + + @Test + void fallbackIsLoggedOnceWithReasonCodeAndBody() throws IOException { + when(kubernetes.perform()).thenThrow(new IOException( + "login to consul failed: response code=500; body='" + TOKEN_REVIEW_UNREACHABLE + "'")); + when(m2m.perform()).thenReturn(new Token(SECRET_ID, null)); + + ProbingConsulLogin probing = probing(); + probing.perform(); + probing.perform(); + + List records = infoRecords(); + assertEquals(1, records.size()); + assertTrue(records.get(0).contains("m2m")); + assertTrue(records.get(0).contains("IOException")); + assertTrue(records.get(0).contains("response code=500")); + assertTrue(records.get(0).contains("connection refused")); + } + + @Test + void notReadyConfigurationIsNamedInTheFallbackRecord() throws IOException { + when(kubernetes.perform()).thenThrow(new IOException( + "consul auth method is not ready: response code=403; body='" + AUTH_METHOD_NOT_FOUND + "'")); + when(m2m.perform()).thenReturn(new Token(SECRET_ID, null)); + + probing().perform(); + + List records = infoRecords(); + assertEquals(1, records.size()); + assertTrue(records.get(0).contains("not ready")); + assertTrue(records.get(0).contains("response code=403")); + } + + @Test + void longErrorBodyIsTruncatedInTheFallbackRecord() throws IOException { + String longBody = "x".repeat(4096); + when(kubernetes.perform()).thenThrow(new IOException( + "login to consul failed: response code=500; body='" + longBody + "'")); + when(m2m.perform()).thenReturn(new Token(SECRET_ID, null)); + + probing().perform(); + + String record = infoRecords().get(0); + assertTrue(record.length() < longBody.length()); + } + + @Test + void secretsNeverReachTheLog() throws IOException { + when(kubernetes.perform()).thenThrow(new IOException( + "login to consul failed: response code=500; body='" + TOKEN_REVIEW_UNREACHABLE + "'")); + when(m2m.perform()).thenReturn(new Token(SECRET_ID, null)); + + probing().perform(); + + String logged = String.join("\n", infoRecords()); + assertFalse(logged.contains(BEARER_TOKEN)); + assertFalse(logged.contains(SECRET_ID)); + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/SelfTokenReaderTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/SelfTokenReaderTest.java new file mode 100644 index 0000000000..663f97ea3d --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/SelfTokenReaderTest.java @@ -0,0 +1,71 @@ +package com.netcracker.cloud.consul.provider.common; + +import com.netcracker.cloud.consul.provider.common.client.ConsulClient; +import com.netcracker.cloud.consul.provider.common.client.ConsulClientResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.OffsetDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SelfTokenReaderTest { + + private static final String SELF_RESPONSE_WITH_EXPIRATION = + "{\"AccessorID\":\"test-accessor-id\",\"SecretID\":\"test-secret-id\",\"Description\":\"token created via login\"," + + "\"Local\":true,\"AuthMethod\":\"k8s-poc-ttl\",\"ExpirationTime\":\"2026-08-26T07:26:30.522472777Z\"," + + "\"CreateTime\":\"2026-08-26T07:21:30.522472777Z\",\"Hash\":\"test-hash\",\"CreateIndex\":53,\"ModifyIndex\":53}"; + + private static final String SELF_RESPONSE_WITHOUT_EXPIRATION = + "{\"AccessorID\":\"test-accessor-id\",\"SecretID\":\"test-secret-id\",\"Description\":\"token created via login\"," + + "\"Local\":true,\"AuthMethod\":\"k8s-poc\",\"CreateTime\":\"2026-08-26T07:21:18.613036445Z\"," + + "\"Hash\":\"test-hash\",\"CreateIndex\":52,\"ModifyIndex\":52}"; + + private ConsulClient consulClient; + private SelfTokenReader selfTokenReader; + + @BeforeEach + public void init() { + consulClient = mock(ConsulClient.class); + selfTokenReader = new SelfTokenReader(consulClient); + } + + @Test + void readReturnsSecretIdAndExpirationTime() throws IOException { + when(consulClient.getSelfToken(anyString())) + .thenReturn(new ConsulClientResponse(SELF_RESPONSE_WITH_EXPIRATION, 200)); + + Token token = selfTokenReader.read("test-current-secret-id"); + + verify(consulClient).getSelfToken(eq("test-current-secret-id")); + assertEquals("test-secret-id", token.getSecretId()); + assertEquals(OffsetDateTime.parse("2026-08-26T07:26:30.522472777Z"), token.getExpirationTime()); + } + + @Test + void readSurvivesMissingExpirationTime() throws IOException { + when(consulClient.getSelfToken(anyString())) + .thenReturn(new ConsulClientResponse(SELF_RESPONSE_WITHOUT_EXPIRATION, 200)); + + Token token = selfTokenReader.read("test-current-secret-id"); + + assertEquals("test-secret-id", token.getSecretId()); + assertNull(token.getExpirationTime()); + } + + @Test + void readFailsWithIOExceptionOnNonSuccessCode() { + when(consulClient.getSelfToken(anyString())) + .thenReturn(new ConsulClientResponse("token does not exist: ACL not found", 403)); + + assertThrows(IOException.class, () -> selfTokenReader.read("test-current-secret-id")); + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenProviderTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenProviderTest.java index 0479c9d67a..ec12ea76c6 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenProviderTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenProviderTest.java @@ -1,5 +1,6 @@ package com.netcracker.cloud.consul.provider.common; +import com.jayway.jsonpath.PathNotFoundException; import com.netcracker.cloud.consul.provider.common.client.ConsulClient; import com.netcracker.cloud.consul.provider.common.client.ConsulClientResponse; import org.junit.jupiter.api.BeforeEach; @@ -9,49 +10,84 @@ import java.time.OffsetDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.anyString; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class TokenProviderTest { + private static final String LOGIN_RESPONSE_WITHOUT_EXPIRATION = + "{\"AccessorID\":\"test-accessor-id\",\"SecretID\":\"test-secret-id\",\"Description\":\"token created via login\"," + + "\"Roles\":[{\"ID\":\"test-role-id\",\"Name\":\"poc-reader\"}],\"Local\":true,\"AuthMethod\":\"k8s-poc\"," + + "\"CreateTime\":\"2026-08-26T07:21:18.613036445Z\",\"Hash\":\"test-hash\",\"CreateIndex\":52,\"ModifyIndex\":52}"; + + private static final String LOGIN_RESPONSE_WITH_EXPIRATION = + "{\"AccessorID\":\"test-accessor-id\",\"SecretID\":\"test-secret-id\",\"Description\":\"token created via login\"," + + "\"Roles\":[{\"ID\":\"test-role-id\",\"Name\":\"poc-reader\"}],\"Local\":true,\"AuthMethod\":\"k8s-poc-ttl\"," + + "\"ExpirationTime\":\"2026-08-26T07:26:30.522472777Z\",\"CreateTime\":\"2026-08-26T07:21:30.522472777Z\"," + + "\"Hash\":\"test-hash\",\"CreateIndex\":53,\"ModifyIndex\":53}"; + private ConsulClient consulClient; + private ConsulLoginCredentials credentials; private TokenProvider tokenProvider; @BeforeEach public void init() { consulClient = mock(ConsulClient.class); - tokenProvider = new TokenProvider(consulClient, "test"); + credentials = new M2MLoginCredentials("test", () -> "test-m2m-token"); + tokenProvider = new TokenProvider(consulClient, credentials); + } + + @Test + void performReadsSecretIdAndExpirationTime() throws IOException { + when(consulClient.login(any(ConsulLoginCredentials.class))) + .thenReturn(new ConsulClientResponse(LOGIN_RESPONSE_WITH_EXPIRATION, 200)); + + Token token = tokenProvider.perform(); + + verify(consulClient).login(eq(credentials)); + assertEquals("test-secret-id", token.getSecretId()); + assertEquals(OffsetDateTime.parse("2026-08-26T07:26:30.522472777Z"), token.getExpirationTime()); + } + + @Test + void performReadsResponseWithoutExpirationTime() throws IOException { + when(consulClient.login(any(ConsulLoginCredentials.class))) + .thenReturn(new ConsulClientResponse(LOGIN_RESPONSE_WITHOUT_EXPIRATION, 200)); + + Token token = tokenProvider.perform(); + + assertEquals("test-secret-id", token.getSecretId()); + assertNull(token.getExpirationTime()); } @Test - void getSelfConsulToken() throws IOException { - String secretId = "test-secret-id"; - String currentToken = "test-current-token-id"; - OffsetDateTime expirationTime = OffsetDateTime.now().plusMinutes(30); - - when(consulClient.getSelfToken(anyString())) - .thenReturn(new ConsulClientResponse("{\"SecretID\": \"" + secretId + "\", \"ExpirationTime\": \"" + expirationTime + "\"}", 200)); - - Token newToken = tokenProvider.getSelf(currentToken); - assertEquals(secretId, newToken.getSecretId()); - assertEquals(expirationTime, newToken.getExpirationTime()); - verify(consulClient).getSelfToken(eq(currentToken)); + void performFailsWithIOExceptionOnEmptyBody() throws IOException { + when(consulClient.login(any(ConsulLoginCredentials.class))) + .thenReturn(new ConsulClientResponse("", 200)); + + assertThrows(IOException.class, () -> tokenProvider.perform()); } @Test - void getNewConsulToken() throws IOException { - String secretId = "test-secret-id"; - OffsetDateTime expirationTime = OffsetDateTime.now().plusMinutes(30); + void performFailsWithIOExceptionOnNonSuccessCode() throws IOException { + when(consulClient.login(any(ConsulLoginCredentials.class))) + .thenReturn(new ConsulClientResponse("{\"Error\":\"Permission denied\"}", 403)); + + assertThrows(IOException.class, () -> tokenProvider.perform()); + } - when(consulClient.login(anyString())) - .thenReturn(new ConsulClientResponse("{\"SecretID\": \"" + secretId + "\", \"ExpirationTime\": \"" + expirationTime + "\"}", 200)); + @Test + void performFailsWithPathNotFoundExceptionWhenSecretIdIsMissing() throws IOException { + when(consulClient.login(any(ConsulLoginCredentials.class))) + .thenReturn(new ConsulClientResponse("{\"AccessorID\":\"test-accessor-id\"}", 200)); - Token token = tokenProvider.getNewConsulToken(); - verify(consulClient).login("test"); - assertEquals(secretId, token.getSecretId()); - assertEquals(expirationTime.toEpochSecond(), token.getExpirationTime().toEpochSecond()); + assertThrows(PathNotFoundException.class, () -> tokenProvider.perform()); + verify(consulClient, times(1)).login(any(ConsulLoginCredentials.class)); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java index b8ecde8ce5..ea1369be82 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java @@ -1,7 +1,20 @@ package com.netcracker.cloud.consul.provider.common; +import com.netcracker.cloud.consul.provider.common.client.ConsulClient; +import com.netcracker.cloud.consul.provider.common.client.ConsulClientResponse; +import com.netcracker.cloud.security.core.utils.k8s.AudienceName; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class TokenStorageFactoryTest { @@ -23,18 +36,185 @@ void returnsFine() { @Test void cannotBuildWithoutUrlOrNamespaceOrM2MSupplier() { - Assertions.assertThrows(NullPointerException.class, () -> new TokenStorageFactory.CreateOptions.Builder() + Assertions.assertThrows(IllegalArgumentException.class, () -> new TokenStorageFactory.CreateOptions.Builder() .namespace(NAMESPACE) .m2mSupplier(() -> "token") .build()); - Assertions.assertThrows(NullPointerException.class, () -> new TokenStorageFactory.CreateOptions.Builder() + Assertions.assertThrows(IllegalArgumentException.class, () -> new TokenStorageFactory.CreateOptions.Builder() .consulUrl(CONSUL_URL) .m2mSupplier(() -> "token") .build()); - Assertions.assertThrows(NullPointerException.class, () -> new TokenStorageFactory.CreateOptions.Builder() + Assertions.assertThrows(IllegalArgumentException.class, () -> new TokenStorageFactory.CreateOptions.Builder() .consulUrl(CONSUL_URL) .namespace(NAMESPACE) .build()); } + @Test + void modeIsAutoWhenNotGiven() { + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .namespace(NAMESPACE) + .m2mSupplier(() -> "token") + .build(); + + Assertions.assertEquals(ConsulLoginMode.AUTO, opts.mode); + } + + @Test + void autoModeNamesTheMissingInputAndTheMode() { + IllegalArgumentException thrown = Assertions.assertThrows(IllegalArgumentException.class, + () -> new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .mode(ConsulLoginMode.AUTO) + .build()); + + Assertions.assertTrue(thrown.getMessage().contains("namespace"), thrown.getMessage()); + Assertions.assertTrue(thrown.getMessage().contains("auto"), thrown.getMessage()); + } + + @Test + void kubernetesModeNeedsNeitherNamespaceNorM2MSupplier() { + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .mode(ConsulLoginMode.KUBERNETES) + .build(); + + Assertions.assertEquals(ConsulLoginMode.KUBERNETES, opts.mode); + Assertions.assertNull(opts.namespace); + Assertions.assertNull(opts.m2mSupplier); + } + + @Test + void consulUrlIsRequiredInEveryMode() { + for (ConsulLoginMode mode : ConsulLoginMode.values()) { + Assertions.assertThrows(IllegalArgumentException.class, () -> new TokenStorageFactory.CreateOptions.Builder() + .mode(mode) + .namespace(NAMESPACE) + .m2mSupplier(() -> "token") + .build(), "mode " + mode); + } + } + + @Test + void builderSuppliesAuthMethodAndAudienceDefaults() { + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .mode(ConsulLoginMode.KUBERNETES) + .build(); + + Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_AUTH_METHOD, opts.authMethod); + Assertions.assertEquals(AudienceName.NETCRACKER, opts.audience); + } + + @Test + void authMethodAndAudienceAreTakenFromTheCallerWhenGiven() { + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .mode(ConsulLoginMode.KUBERNETES) + .authMethod("core-k8s") + .audience(AudienceName.DBAAS) + .build(); + + Assertions.assertEquals("core-k8s", opts.authMethod); + Assertions.assertEquals(AudienceName.DBAAS, opts.audience); + } + + @Test + void unknownModeIsNotCreated() { + Assertions.assertThrows(IllegalArgumentException.class, () -> ConsulLoginMode.valueOf("cloud-foundry")); + } + + private static ConsulLoginCredentials credentialsUsedBy(ConsulLogin login, ConsulClient client) throws IOException { + when(client.login(any(ConsulLoginCredentials.class))) + .thenReturn(new ConsulClientResponse("{\"SecretID\":\"test-secret-id\"}", 200)); + + login.perform(); + + ArgumentCaptor used = ArgumentCaptor.forClass(ConsulLoginCredentials.class); + verify(client).login(used.capture()); + return used.getValue(); + } + + @Test + void m2mModeBuildsTokenProviderOverM2MCredentials() throws IOException { + ConsulClient client = mock(ConsulClient.class); + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .mode(ConsulLoginMode.M2M) + .namespace(NAMESPACE) + .m2mSupplier(() -> "token") + .build(); + + ConsulLogin login = TokenStorageFactory.from(client, opts); + + Assertions.assertInstanceOf(TokenProvider.class, login); + Assertions.assertInstanceOf(M2MLoginCredentials.class, credentialsUsedBy(login, client)); + } + + @Test + void kubernetesModeBuildsTokenProviderOverKubernetesCredentials() throws IOException { + ConsulClient client = mock(ConsulClient.class); + AtomicInteger m2mCalls = new AtomicInteger(); + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .mode(ConsulLoginMode.KUBERNETES) + .authMethod("core-k8s") + .m2mSupplier(() -> { + m2mCalls.incrementAndGet(); + return "token"; + }) + .build(); + + ConsulLogin login = TokenStorageFactory.from(client, opts); + + Assertions.assertInstanceOf(TokenProvider.class, login); + ConsulLoginCredentials used = credentialsUsedBy(login, client); + Assertions.assertInstanceOf(KubernetesLoginCredentials.class, used); + Assertions.assertEquals("core-k8s", used.authMethod()); + Assertions.assertEquals(0, m2mCalls.get()); + } + + @Test + void autoModeBuildsProbingLoginOverBothWays() throws IOException { + ConsulClient client = mock(ConsulClient.class); + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .mode(ConsulLoginMode.AUTO) + .namespace(NAMESPACE) + .authMethod("core-k8s") + .m2mSupplier(() -> "token") + .build(); + + ConsulLogin login = TokenStorageFactory.from(client, opts); + + Assertions.assertInstanceOf(ProbingConsulLogin.class, login); + Assertions.assertInstanceOf(KubernetesLoginCredentials.class, credentialsUsedBy(login, client)); + } + + @Test + void autoModeFallsBackToM2MCredentials() throws IOException { + ConsulClient client = mock(ConsulClient.class); + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .mode(ConsulLoginMode.AUTO) + .namespace(NAMESPACE) + .m2mSupplier(() -> "token") + .build(); + + when(client.login(any(ConsulLoginCredentials.class))).thenAnswer(invocation -> { + if (invocation.getArgument(0) instanceof KubernetesLoginCredentials) { + throw new IOException("consul auth method is not ready: response code=403; body='ACL not found'"); + } + return new ConsulClientResponse("{\"SecretID\":\"test-secret-id\"}", 200); + }); + + Token token = TokenStorageFactory.from(client, opts).perform(); + + Assertions.assertEquals("test-secret-id", token.getSecretId()); + ArgumentCaptor used = ArgumentCaptor.forClass(ConsulLoginCredentials.class); + verify(client, atLeast(2)).login(used.capture()); + Assertions.assertInstanceOf(KubernetesLoginCredentials.class, used.getAllValues().get(0)); + Assertions.assertInstanceOf(M2MLoginCredentials.class, used.getAllValues().get(used.getAllValues().size() - 1)); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java index 3d9faa6c3d..9eabd2d79a 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java @@ -3,42 +3,46 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import java.io.IOException; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneId; -import java.time.temporal.ChronoUnit; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; class TokenUpdaterTest { private TokenUpdater tokenUpdater; - private TokenProvider tokenProvider; + private ConsulLogin consulLogin; + private SelfTokenReader selfTokenReader; private ScheduledExecutorService scheduledExecutorService; private final Instant currentTime = Instant.now(); @BeforeEach public void init() { - tokenProvider = Mockito.mock(TokenProvider.class); + consulLogin = Mockito.mock(ConsulLogin.class); + selfTokenReader = Mockito.mock(SelfTokenReader.class); scheduledExecutorService = Mockito.mock(ScheduledExecutorService.class); - tokenUpdater = new TokenUpdater(tokenProvider, scheduledExecutorService, Clock.fixed(currentTime, ZoneId.of("UTC")), 2); + tokenUpdater = new TokenUpdater(consulLogin, selfTokenReader, scheduledExecutorService, Clock.fixed(currentTime, ZoneId.of("UTC")), 2, Duration.ZERO); } @Test void mustGetNewTokenScheduleUpdates() throws IOException { String secretId = "test-token"; OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); - when(tokenProvider.getNewConsulToken()).thenReturn(new Token(secretId, secretExpirationTime)); + when(consulLogin.perform()).thenReturn(new Token(secretId, secretExpirationTime)); AtomicReference updater = new AtomicReference<>(""); tokenUpdater.watch(updater::set, ""); @@ -46,8 +50,8 @@ void mustGetNewTokenScheduleUpdates() throws IOException { verify(scheduledExecutorService).scheduleWithFixedDelay( any(), - eq(ChronoUnit.SECONDS.between(currentTime, secretExpirationTime.minusMinutes(5))), - eq(ChronoUnit.SECONDS.between(currentTime, secretExpirationTime.minusMinutes(5))), + eq(1440L), + eq(1440L), eq(TimeUnit.SECONDS) ); } @@ -56,15 +60,15 @@ void mustGetNewTokenScheduleUpdates() throws IOException { void mustUseSelfTokenIfProvidedScheduleUpdates() throws IOException { String secretId = "test-self-token"; OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); - when(tokenProvider.getSelf(secretId)).thenReturn(new Token(secretId, secretExpirationTime)); + when(selfTokenReader.read(secretId)).thenReturn(new Token(secretId, secretExpirationTime)); AtomicReference updater = new AtomicReference<>(""); tokenUpdater.watch(updater::set, secretId); verify(scheduledExecutorService).scheduleWithFixedDelay( any(), - eq(ChronoUnit.SECONDS.between(currentTime, secretExpirationTime.minusMinutes(5))), - eq(ChronoUnit.SECONDS.between(currentTime, secretExpirationTime.minusMinutes(5))), + eq(1440L), + eq(1440L), eq(TimeUnit.SECONDS) ); } @@ -73,21 +77,21 @@ void mustUseSelfTokenIfProvidedScheduleUpdates() throws IOException { void mustRetryOnFailure() throws IOException { String secretId = "test-self-token"; OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); - when(tokenProvider.getNewConsulToken()) + when(consulLogin.perform()) .thenThrow(new IOException()) .thenReturn(new Token(secretId, secretExpirationTime)); AtomicReference updater = new AtomicReference<>(""); tokenUpdater.watch(updater::set, ""); - verify(tokenProvider, times(2)).getNewConsulToken(); + verify(consulLogin, times(2)).perform(); } @Test void scheduledTaskMustRetryOnFailure() throws IOException { String secretId = "test-token"; OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); - when(tokenProvider.getNewConsulToken()) + when(consulLogin.perform()) .thenReturn(new Token(secretId, secretExpirationTime)) .thenThrow(new IOException()) .thenThrow(new IOException()) @@ -96,8 +100,8 @@ void scheduledTaskMustRetryOnFailure() throws IOException { AtomicReference updater = new AtomicReference<>(""); when(scheduledExecutorService.scheduleWithFixedDelay(any(), - eq(ChronoUnit.SECONDS.between(currentTime, secretExpirationTime.minusMinutes(5))), - eq(ChronoUnit.SECONDS.between(currentTime, secretExpirationTime.minusMinutes(5))), + eq(1440L), + eq(1440L), eq(TimeUnit.SECONDS))).thenAnswer(invocationOnMock -> { assertEquals(secretId, updater.get()); Runnable task = invocationOnMock.getArgument(0); @@ -108,4 +112,81 @@ void scheduledTaskMustRetryOnFailure() throws IOException { tokenUpdater.watch(updater::set, ""); assertEquals(secretId, updater.get()); } + + @Test + void scheduledTaskLoginsThroughTheSameConsulLogin() throws IOException { + String secretId = "test-token"; + String rotatedSecretId = "test-rotated-token"; + OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); + when(consulLogin.perform()) + .thenReturn(new Token(secretId, secretExpirationTime)) + .thenReturn(new Token(rotatedSecretId, secretExpirationTime)); + + AtomicReference updater = new AtomicReference<>(""); + when(scheduledExecutorService.scheduleWithFixedDelay(any(), anyLong(), anyLong(), eq(TimeUnit.SECONDS))) + .thenAnswer(invocationOnMock -> { + Runnable task = invocationOnMock.getArgument(0); + task.run(); + return null; + }); + + tokenUpdater.watch(updater::set, ""); + + assertEquals(rotatedSecretId, updater.get()); + verify(consulLogin, times(2)).perform(); + verifyNoInteractions(selfTokenReader); + } + + private long scheduledDelay(Instant now, OffsetDateTime expirationTime) throws IOException { + ScheduledExecutorService executor = Mockito.mock(ScheduledExecutorService.class); + ConsulLogin login = Mockito.mock(ConsulLogin.class); + when(login.perform()).thenReturn(new Token("test-token", expirationTime)); + + new TokenUpdater(login, Mockito.mock(SelfTokenReader.class), executor, + Clock.fixed(now, ZoneId.of("UTC")), 2, Duration.ZERO).watch(unused -> { + }, ""); + + ArgumentCaptor delay = ArgumentCaptor.forClass(Long.class); + verify(executor).scheduleWithFixedDelay(any(), delay.capture(), anyLong(), eq(TimeUnit.SECONDS)); + return delay.getValue(); + } + + @Test + void delayIsAFractionOfRemainingLifetime() throws IOException { + Instant loginTime = Instant.parse("2026-08-26T07:21:30.522472777Z"); + + assertEquals(48L, scheduledDelay(loginTime, OffsetDateTime.parse("2026-08-26T07:22:30.522472777Z"))); + assertEquals(240L, scheduledDelay(loginTime, OffsetDateTime.parse("2026-08-26T07:26:30.522472777Z"))); + assertEquals(2880L, scheduledDelay(loginTime, OffsetDateTime.parse("2026-08-26T08:21:30.522472777Z"))); + assertEquals(69120L, scheduledDelay(loginTime, OffsetDateTime.parse("2026-08-27T07:21:30.522472777Z"))); + } + + @Test + void delayKeepsLowerBoundWhenClockRanAhead() throws IOException { + Instant clockAhead = Instant.parse("2026-08-26T09:00:00Z"); + + assertEquals(TokenUpdater.MIN_DELAY_SECONDS, + scheduledDelay(clockAhead, OffsetDateTime.parse("2026-08-26T07:26:30.522472777Z"))); + } + + @Test + void retryWaitsConfiguredPauseBetweenAttempts() throws IOException { + Duration retryPause = Duration.ofMillis(200); + OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); + when(consulLogin.perform()) + .thenThrow(new IOException()) + .thenReturn(new Token("test-token", secretExpirationTime)); + + TokenUpdater updater = new TokenUpdater(consulLogin, selfTokenReader, scheduledExecutorService, + Clock.fixed(currentTime, ZoneId.of("UTC")), 2, retryPause); + + long startedAt = System.nanoTime(); + updater.watch(unused -> { + }, ""); + long elapsed = System.nanoTime() - startedAt; + + verify(consulLogin, times(2)).perform(); + Assertions.assertTrue(elapsed >= retryPause.toNanos(), + "expected at least one pause of " + retryPause + " between retries"); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulClientDefaultLoginTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulClientDefaultLoginTest.java new file mode 100644 index 0000000000..ddb17f5ca2 --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulClientDefaultLoginTest.java @@ -0,0 +1,60 @@ +package com.netcracker.cloud.consul.provider.common.client; + +import com.netcracker.cloud.consul.provider.common.ConsulLoginCredentials; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ConsulClientDefaultLoginTest { + + private static class ExternalConsulClient implements ConsulClient { + + private String requestedAuthMethod; + + @Override + public ConsulClientResponse getSelfToken(String currentSecretId) { + throw new UnsupportedOperationException(); + } + + @Override + public ConsulClientResponse login(String authMethod) { + requestedAuthMethod = authMethod; + return new ConsulClientResponse("{\"SecretID\": \"test-secret-id\"}", 200); + } + } + + private static ConsulLoginCredentials credentials(String authMethod, String bearerToken) { + return new ConsulLoginCredentials() { + @Override + public String authMethod() { + return authMethod; + } + + @Override + public String bearerToken() { + return bearerToken; + } + }; + } + + @Test + void externalImplementationKeepsWorkingThroughNewMethod() throws IOException { + ExternalConsulClient client = new ExternalConsulClient(); + + ConsulClientResponse response = client.login(credentials("test-auth-method", "test-bearer-token")); + + assertEquals(200, response.getCode()); + assertEquals("{\"SecretID\": \"test-secret-id\"}", response.getBodyJson()); + } + + @Test + void authMethodOfCredentialsGoesToOldMethod() throws IOException { + ExternalConsulClient client = new ExternalConsulClient(); + + client.login(credentials("test-auth-method", "test-bearer-token")); + + assertEquals("test-auth-method", client.requestedAuthMethod); + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClientTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClientTest.java index 1468cedc8b..0f11083a1a 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClientTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClientTest.java @@ -1,8 +1,11 @@ package com.netcracker.cloud.consul.provider.common.client; import com.google.gson.Gson; +import com.netcracker.cloud.consul.provider.common.ConsulLoginCredentials; import okhttp3.*; +import okio.Buffer; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,6 +16,8 @@ import static com.netcracker.cloud.consul.provider.common.client.ConsulClient.*; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -101,4 +106,77 @@ void loginSuccessTest() { fail(); } } + + private static ConsulLoginCredentials credentials(String authMethod, String bearerToken) { + return new ConsulLoginCredentials() { + @Override + public String authMethod() { + return authMethod; + } + + @Override + public String bearerToken() { + return bearerToken; + } + }; + } + + private static String bodyOf(Request request) throws IOException { + Buffer buffer = new Buffer(); + request.body().writeTo(buffer); + return buffer.readUtf8(); + } + + @Test + void loginByCredentialsFailsWithIOExceptionOnBrokenConnection() throws IOException { + OkHttpClient okHttpClient = mock(OkHttpClient.class); + Call call = mock(Call.class); + + when(okHttpClient.newCall(any())).thenReturn(call); + when(call.execute()).thenThrow(new IOException("connection reset")); + + ConsulClient consulClient = new ConsulOkHttpClient(okHttpClient, CONSUL_ADDRESS, () -> ""); + + IOException thrown = assertThrows(IOException.class, + () -> consulClient.login(credentials("core-k8s", "my-secret-bearer-token"))); + assertEquals("connection reset", thrown.getMessage()); + } + + @Test + void loginByCredentialsSendsAuthMethodAndBearerTokenFromCredentials() throws IOException { + String authMethod = "core-k8s"; + String bearerToken = "my-secret-bearer-token"; + + OkHttpClient okHttpClient = mock(OkHttpClient.class); + ResponseBody responseBody = mock(ResponseBody.class); + Call call = mock(Call.class); + + Request okHttpRequest = new Request.Builder() + .post(RequestBody.create(MediaType.parse(APPLICATION_JSON), "{}")) + .url(CONSUL_ADDRESS + V1_ACL_LOGIN) + .addHeader(CONTENT_TYPE, APPLICATION_JSON) + .build(); + Response okHttpResponse = new Response.Builder().request(okHttpRequest) + .protocol(Protocol.HTTP_2).message(TEXT) + .code(SUCCESS_CODE).body(responseBody).build(); + + when(responseBody.string()).thenReturn(TEXT); + when(okHttpClient.newCall(any())).thenReturn(call); + when(call.execute()).thenReturn(okHttpResponse); + + ConsulClient consulClient = new ConsulOkHttpClient(okHttpClient, CONSUL_ADDRESS, () -> "unused-m2m-token"); + ConsulClientResponse consulResponse = consulClient.login(credentials(authMethod, bearerToken)); + + ArgumentCaptor sent = ArgumentCaptor.forClass(Request.class); + Mockito.verify(okHttpClient, Mockito.times(1)).newCall(sent.capture()); + + Map body = new Gson().fromJson(bodyOf(sent.getValue()), Map.class); + assertEquals(authMethod, body.get(AUTH_METHOD_FIELD)); + assertEquals(bearerToken, body.get(BEARER_TOKEN_FIELD)); + assertEquals(CONSUL_ADDRESS + V1_ACL_LOGIN, sent.getValue().url().toString()); + assertEquals("POST", sent.getValue().method()); + assertTrue(APPLICATION_JSON.equals(sent.getValue().header(CONTENT_TYPE))); + assertEquals(SUCCESS_CODE, consulResponse.getCode()); + assertEquals(TEXT, consulResponse.getBodyJson()); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClientTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClientTest.java index 68c3f072a5..986b6d189f 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClientTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClientTest.java @@ -4,10 +4,13 @@ import com.netcracker.cloud.restclient.HttpMethod; import com.netcracker.cloud.restclient.MicroserviceRestClient; import com.netcracker.cloud.restclient.entity.RestClientResponseEntity; +import com.netcracker.cloud.restclient.exception.MicroserviceRestClientException; +import com.netcracker.cloud.consul.provider.common.ConsulLoginCredentials; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -62,4 +65,58 @@ void loginSuccess() { Mockito.eq(consulAddress + V1_ACL_LOGIN), Mockito.eq(HttpMethod.POST), Mockito.any(), Mockito.eq(jsonPayload), Mockito.eq(String.class)); } + + private static ConsulLoginCredentials credentials(String authMethod, String bearerToken) { + return new ConsulLoginCredentials() { + @Override + public String authMethod() { + return authMethod; + } + + @Override + public String bearerToken() { + return bearerToken; + } + }; + } + + @Test + void loginByCredentialsWrapsTransportFailureInIOException() { + String consulAddress = "consul:8301"; + MicroserviceRestClient restClient = Mockito.mock(MicroserviceRestClient.class); + + MicroserviceRestClientException cause = new MicroserviceRestClientException("consul is unreachable"); + Mockito.when(restClient.doRequest(Mockito.anyString(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.eq(String.class))) + .thenThrow(cause); + + ConsulClient consulClient = new ConsulRestClient(restClient, consulAddress, () -> ""); + + IOException thrown = Assertions.assertThrows(IOException.class, + () -> consulClient.login(credentials("core-k8s", "my-secret-bearer-token"))); + Assertions.assertSame(cause, thrown.getCause()); + } + + @Test + void loginByCredentialsSendsAuthMethodAndBearerTokenFromCredentials() throws IOException { + String authMethod = "core-k8s"; + String bearerToken = "my-secret-bearer-token"; + String consulAddress = "consul:8301"; + MicroserviceRestClient restClient = Mockito.mock(MicroserviceRestClient.class); + + Map payload = new HashMap<>(); + payload.put(AUTH_METHOD_FIELD, authMethod); + payload.put(BEARER_TOKEN_FIELD, bearerToken); + String jsonPayload = new Gson().toJson(payload); + + Mockito.when(restClient.doRequest(Mockito.anyString(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.eq(String.class))) + .thenReturn(new RestClientResponseEntity<>(jsonPayload, 200)); + + ConsulClient consulClient = new ConsulRestClient(restClient, consulAddress, () -> "unused-m2m-token"); + ConsulClientResponse consulResponse = consulClient.login(credentials(authMethod, bearerToken)); + + Assertions.assertEquals(200, consulResponse.getCode()); + Mockito.verify(restClient, Mockito.times(1)).doRequest( + Mockito.eq(consulAddress + V1_ACL_LOGIN), Mockito.eq(HttpMethod.POST), Mockito.any(), + Mockito.eq(jsonPayload), Mockito.eq(String.class)); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java index 065cfdac1c..4596afa09b 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java @@ -1,6 +1,7 @@ package com.netcracker.cloud.consul.provider.spring.common.config; import org.apache.commons.logging.Log; +import com.netcracker.cloud.consul.provider.common.ConsulLoginCredentials; import com.netcracker.cloud.consul.provider.common.TokenProvider; import com.netcracker.cloud.consul.provider.common.client.ConsulRestClient; import com.netcracker.cloud.consul.provider.spring.common.Utils; @@ -49,9 +50,21 @@ protected ConsulConfigProperties loadConfigProperties(ConfigDataLocationResolver ConsulProperties properties = resolverContext.getBootstrapContext().get(ConsulProperties.class); try { M2MManager m2MManager = resolverContext.getBootstrapContext().get(M2MManager.class); - ConsulRestClient client = createConsulRestClient(Utils.formatConsulAddress(properties), () -> m2MManager.getToken().getTokenValue()); - TokenProvider tokenProvider = new TokenProvider(client, getPropsOrEnvsMust(args(PROP_CLOUD_NAMESPACE), args(ENV_NAMESPACE, ENV_CLOUD_NAMESPACE))); - consulConfigProperties.setAclToken(tokenProvider.getNewConsulToken().getSecretId()); + Supplier m2mTokenSupplier = () -> m2MManager.getToken().getTokenValue(); + String namespace = getPropsOrEnvsMust(args(PROP_CLOUD_NAMESPACE), args(ENV_NAMESPACE, ENV_CLOUD_NAMESPACE)); + ConsulRestClient client = createConsulRestClient(Utils.formatConsulAddress(properties), m2mTokenSupplier); + TokenProvider tokenProvider = new TokenProvider(client, new ConsulLoginCredentials() { + @Override + public String authMethod() { + return namespace; + } + + @Override + public String bearerToken() { + return m2mTokenSupplier.get(); + } + }); + consulConfigProperties.setAclToken(tokenProvider.perform().getSecretId()); } catch (IOException e) { log.error("can not get consul token by m2m: ", e); } From 33850c76e25b18792604bc062eb78e00fff6418e Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Wed, 26 Aug 2026 16:41:00 +0400 Subject: [PATCH 02/20] feat(consul): read the login mode and its inputs in all four entry points Wire the four places that build CreateOptions to the new login mode, auth method and audience, and document the properties: - Spring property names live in ConsulM2MConfigDataLocationResolver; the RestTemplate and WebClient autoconfigurations reference them - values are read through Binder, which converts the lowercase mode written in configuration regardless of the environment conversion service - the ConfigData phase builds the chain through TokenStorageFactory.from and resolves M2MManager from the bootstrap registry only when the bearer token is actually needed, so the kubernetes mode never touches the registry - the Quarkus producer takes the three values as @ConfigProperty, so the mode can be switched without rebuilding the application - the WebClient module gets src/test and its first tests - the properties are documented for both stacks The Quarkus tests do not run yet: cloud-core-quarkus-bom-internal pins core-rest-libraries to the released 7.4.0, so the extension compiles against the published consul-config-provider-common instead of the reactor. --- .../config-sources/consul-client/README.md | 24 +++ .../client/ConsulClientConfiguration.java | 13 +- ...ClientConfigurationKubernetesModeTest.java | 60 +++++++ ...sulClientConfigurationM2MDisabledTest.java | 39 +++++ .../client/ConsulClientConfigurationTest.java | 32 ++++ .../client/ConsulLoginModeConfigTest.java | 32 ++++ .../consul-config-provider/README.md | 44 +++++ .../provider/common/TokenStorageFactory.java | 12 ++ .../ConsulM2MConfigDataLocationResolver.java | 41 ++--- ...nsulM2MConfigDataLocationResolverTest.java | 153 ++++++++++++++++++ ...onsulM2MRestTemplateAutoConfiguration.java | 29 +++- ...lM2MRestTemplateAutoConfigurationTest.java | 89 ++++++++++ .../pom.xml | 11 ++ .../ConsulM2MWebClientAutoConfiguration.java | 29 +++- ...nsulM2MWebClientAutoConfigurationTest.java | 89 ++++++++++ 15 files changed, 670 insertions(+), 27 deletions(-) create mode 100644 core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationKubernetesModeTest.java create mode 100644 core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationM2MDisabledTest.java create mode 100644 core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulLoginModeConfigTest.java create mode 100644 core-rest-libraries/consul-config-provider/README.md create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java diff --git a/core-quarkus-extensions/config-sources/consul-client/README.md b/core-quarkus-extensions/config-sources/consul-client/README.md index 40d18e151b..c992fadc9c 100644 --- a/core-quarkus-extensions/config-sources/consul-client/README.md +++ b/core-quarkus-extensions/config-sources/consul-client/README.md @@ -38,6 +38,26 @@ If no M2M auth needed(for localdev, tests, etc.) it can be disabled by setting p quarkus.consul-source-config.m2m.enabled=false ``` +#### Login properties + +The Consul ACL token is exchanged through `POST /v1/acl/login`. Two ways to obtain it are supported: `kubernetes` sends +the projected service account token of the pod, `m2m` sends an M2M token. The three properties below are read at runtime +when the `TokenStorage` bean is built, so the way can be switched without rebuilding the application. + +A pod keeps one way for its whole life: in `auto` mode the first login decides, and a failure of a later login does not +switch the way back. In `auto` mode the `kubernetes` way is tried first, and on failure the pod falls back to the `m2m` +way and logs one `INFO` record with the reason, the Consul response code, and a truncated response body. In `kubernetes` +mode there is no probe and no fallback. In `m2m` mode the auth method name and the audience are not read at all. + +An unknown value of `quarkus.consul-source-config.login.mode` fails the startup. With +`quarkus.consul-source-config.m2m.enabled=false` none of the three properties are read. + +The default auth method name is a placeholder. Set `quarkus.consul-source-config.login.auth-method` to the name of the +auth method registered in your Consul. + +The `kubernetes` way reads the token from `/var/run/secrets/tokens//token`. To run it outside a cluster, point +the token directory elsewhere with `com.netcracker.cloud.security.kubernetes.tokens.dir`. + #### Configuration properties | Property name | Description | Default value | |----------------------------------------------|---------------------------------------------------------------|-----------------------------------------------------------| @@ -45,3 +65,7 @@ quarkus.consul-source-config.m2m.enabled=false | quarkus.consul-source-config.agent.url | Consul agent URL | | | quarkus.consul-source-config.properties-root | List of properties roots | config/$namespace/application, config/$namespace/$appName | | quarkus.consul-source-config.wait-time | Maximum Value for Consul blocking queries wait time (seconds) | 570 | +| quarkus.consul-source-config.m2m.enabled | Enable the Consul ACL token exchange (bool, build time) | true | +| quarkus.consul-source-config.login.mode | Way to obtain the ACL token: auto, kubernetes or m2m | auto | +| quarkus.consul-source-config.login.auth-method | Consul auth method name, used by the kubernetes way | kubernetes-auth-method-placeholder | +| quarkus.consul-source-config.login.audience | Projected token audience, used by the kubernetes way | netcracker | diff --git a/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java b/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java index 0b471cc6e1..54af45c09a 100644 --- a/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java +++ b/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java @@ -1,5 +1,6 @@ package com.netcracker.cloud.quarkus.consul.client; +import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; import com.netcracker.cloud.consul.provider.common.OkHttpTokenStorageFactory; import com.netcracker.cloud.consul.provider.common.TokenStorage; import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; @@ -22,6 +23,10 @@ @Singleton public class ConsulClientConfiguration { + public static final String PROP_LOGIN_MODE = "quarkus.consul-source-config.login.mode"; + public static final String PROP_LOGIN_AUTH_METHOD = "quarkus.consul-source-config.login.auth-method"; + public static final String PROP_LOGIN_AUDIENCE = "quarkus.consul-source-config.login.audience"; + private static final Logger log = LoggerFactory.getLogger(ConsulClientConfiguration.class); @Produces @@ -68,11 +73,17 @@ public TokenStorageFactory tokenStorageFactory() { @UnlessBuildProperty(name = "quarkus.consul-source-config.m2m.enabled", stringValue = "false", enableIfMissing = true) public TokenStorage tokenStorage(TokenStorageFactory tokenStorageFactory, @ConfigProperty(name = "cloud.microservice.namespace") String namespace, - @ConfigProperty(name = "quarkus.consul-source-config.agent.url") String agentUrl) { + @ConfigProperty(name = "quarkus.consul-source-config.agent.url") String agentUrl, + @ConfigProperty(name = PROP_LOGIN_MODE) Optional mode, + @ConfigProperty(name = PROP_LOGIN_AUTH_METHOD) Optional authMethod, + @ConfigProperty(name = PROP_LOGIN_AUDIENCE) Optional audience) { return tokenStorageFactory.create(new TokenStorageFactory.CreateOptions.Builder() .consulUrl(agentUrl) .namespace(namespace) .m2mSupplier(() -> M2MManager.getInstance().getToken().getTokenValue()) + .mode(mode.orElse(null)) + .authMethod(authMethod.orElse(null)) + .audience(audience.orElse(null)) .build()); } diff --git a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationKubernetesModeTest.java b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationKubernetesModeTest.java new file mode 100644 index 0000000000..4d86772a66 --- /dev/null +++ b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationKubernetesModeTest.java @@ -0,0 +1,60 @@ +package com.netcracker.cloud.quarkus.consul.client; + +import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; +import com.netcracker.cloud.consul.provider.common.TokenStorage; +import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@QuarkusTest +@TestProfile(ConsulClientConfigurationKubernetesModeTest.Profile.class) +class ConsulClientConfigurationKubernetesModeTest { + + public static class Profile implements QuarkusTestProfile { + + @Override + public Map getConfigOverrides() { + return Map.of( + "cloud.microservice.name", "test-app", + "cloud.microservice.namespace", "test-namespace", + "quarkus.consul-source-config.enabled", "false", + "quarkus.consul-source-config.agent.url", "http://localhost:8500", + ConsulClientConfiguration.PROP_LOGIN_MODE, "kubernetes", + ConsulClientConfiguration.PROP_LOGIN_AUTH_METHOD, "core-k8s", + ConsulClientConfiguration.PROP_LOGIN_AUDIENCE, "dbaas" + ); + } + } + + @InjectMock + TokenStorageFactory tokenStorageFactory; + + @Inject + TokenStorage tokenStorage; + + @Test + void loginPropertiesReachCreateOptionsAtRuntime() { + when(tokenStorageFactory.create(any())).thenReturn(new ConsulClientConfigurationTest.NoopTokenStorage()); + + tokenStorage.get(); + + ArgumentCaptor options = + ArgumentCaptor.forClass(TokenStorageFactory.CreateOptions.class); + verify(tokenStorageFactory).create(options.capture()); + Assertions.assertEquals(ConsulLoginMode.KUBERNETES, options.getValue().getMode()); + Assertions.assertEquals("core-k8s", options.getValue().getAuthMethod()); + Assertions.assertEquals("dbaas", options.getValue().getAudience()); + } +} diff --git a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationM2MDisabledTest.java b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationM2MDisabledTest.java new file mode 100644 index 0000000000..988bddd6a2 --- /dev/null +++ b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationM2MDisabledTest.java @@ -0,0 +1,39 @@ +package com.netcracker.cloud.quarkus.consul.client; + +import com.netcracker.cloud.consul.provider.common.TokenStorage; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +@QuarkusTest +@TestProfile(ConsulClientConfigurationM2MDisabledTest.Profile.class) +class ConsulClientConfigurationM2MDisabledTest { + + public static class Profile implements QuarkusTestProfile { + + @Override + public Map getConfigOverrides() { + return Map.of( + "cloud.microservice.name", "test-app", + "cloud.microservice.namespace", "test-namespace", + "quarkus.consul-source-config.enabled", "false", + "quarkus.consul-source-config.agent.url", "http://localhost:8500", + "quarkus.consul-source-config.m2m.enabled", "false", + ConsulClientConfiguration.PROP_LOGIN_MODE, "cloud-foundry" + ); + } + } + + @Inject + TokenStorage tokenStorage; + + @Test + void disabledM2MKeepsTheStubAndReadsNoLoginProperties() { + Assertions.assertEquals("", tokenStorage.get()); + } +} diff --git a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationTest.java b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationTest.java index 0cce4cca98..238f94a8cf 100644 --- a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationTest.java +++ b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationTest.java @@ -1,13 +1,17 @@ package com.netcracker.cloud.quarkus.consul.client; +import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; import com.netcracker.cloud.consul.provider.common.TokenStorage; import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; +import com.netcracker.cloud.security.core.utils.k8s.AudienceName; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTestProfile; import io.quarkus.test.junit.TestProfile; import jakarta.inject.Inject; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.wildfly.common.Assert; import java.util.Map; @@ -15,6 +19,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @QuarkusTest @TestProfile(ConsulClientConfigurationTest.Profile.class) @@ -44,4 +49,31 @@ void test() { Assert.assertNotNull(tokenStorage); verify(tokenStorageFactory, never()).create(any()); } + + @Test + void defaultsAreTakenWhenNoLoginPropertyIsSet() { + when(tokenStorageFactory.create(any())).thenReturn(new NoopTokenStorage()); + + tokenStorage.get(); + + ArgumentCaptor options = + ArgumentCaptor.forClass(TokenStorageFactory.CreateOptions.class); + verify(tokenStorageFactory).create(options.capture()); + Assertions.assertEquals(ConsulLoginMode.AUTO, options.getValue().getMode()); + Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_AUTH_METHOD, options.getValue().getAuthMethod()); + Assertions.assertEquals(AudienceName.NETCRACKER, options.getValue().getAudience()); + } + + static class NoopTokenStorage implements TokenStorage { + + @Override + public String get() { + return ""; + } + + @Override + public void update(String token) { + // nothing + } + } } diff --git a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulLoginModeConfigTest.java b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulLoginModeConfigTest.java new file mode 100644 index 0000000000..9c6e427e0d --- /dev/null +++ b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulLoginModeConfigTest.java @@ -0,0 +1,32 @@ +package com.netcracker.cloud.quarkus.consul.client; + +import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; +import io.smallrye.config.SmallRyeConfig; +import io.smallrye.config.SmallRyeConfigBuilder; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +class ConsulLoginModeConfigTest { + + private static SmallRyeConfig configWithMode(String mode) { + return new SmallRyeConfigBuilder() + .withDefaultValues(Map.of(ConsulClientConfiguration.PROP_LOGIN_MODE, mode)) + .build(); + } + + @Test + void knownModeIsRead() { + Assertions.assertEquals(ConsulLoginMode.KUBERNETES, + configWithMode("kubernetes").getValue(ConsulClientConfiguration.PROP_LOGIN_MODE, ConsulLoginMode.class)); + } + + @Test + void unknownModeBreaksTheStart() { + SmallRyeConfig config = configWithMode("cloud-foundry"); + + Assertions.assertThrows(IllegalArgumentException.class, + () -> config.getValue(ConsulClientConfiguration.PROP_LOGIN_MODE, ConsulLoginMode.class)); + } +} diff --git a/core-rest-libraries/consul-config-provider/README.md b/core-rest-libraries/consul-config-provider/README.md new file mode 100644 index 0000000000..23f1dfdeff --- /dev/null +++ b/core-rest-libraries/consul-config-provider/README.md @@ -0,0 +1,44 @@ +# Consul config provider + +Obtains a Consul ACL token for a microservice and keeps it fresh. The token is exchanged through +`POST /v1/acl/login` and published as a `TokenStorage` bean. + +Two ways to obtain the token are supported: + +- `kubernetes` — the projected Kubernetes service account token of the pod is sent to a Consul auth method of type + `kubernetes`. +- `m2m` — an M2M token issued by `M2MManager` is sent to a Consul auth method named after the namespace. + +## Login properties + +The three properties below are read at startup, in the ConfigData phase and again when the `TokenStorage` bean is +built. A pod keeps one way of obtaining the token for its whole life: in `auto` mode the first login decides, and a +failure of a later login does not switch the way back. + +| Property name | Type | Default | Read when | +|-----------------------------------------------------|-------------------------------|--------------------------------------|--------------------------------------------------------------| +| `spring.cloud.consul.config.login.mode` | `auto`, `kubernetes` or `m2m` | `auto` | ConfigData phase and `TokenStorage` bean creation | +| `spring.cloud.consul.config.login.auth-method` | string | `kubernetes-auth-method-placeholder` | same, and only when the `kubernetes` way is used | +| `spring.cloud.consul.config.login.audience` | string | `netcracker` | same, and only when the `kubernetes` way is used | + +In `auto` mode the `kubernetes` way is tried first. If it fails, the pod falls back to the `m2m` way and logs one +`INFO` record with the reason, the Consul response code, and a truncated response body. In `kubernetes` mode there is +no probe and no fallback, and neither `M2MManager` nor the namespace is needed. In `m2m` mode the auth method name and +the audience are not read at all. + +An unknown value of `spring.cloud.consul.config.login.mode` fails the startup. + +The default auth method name is a placeholder. Set +`spring.cloud.consul.config.login.auth-method` to the name of the auth method registered in your Consul. + +To turn the ACL token exchange off altogether — for local runs and tests — set +`spring.cloud.consul.config.m2m.enabled=false`. None of the login properties are read then. + +## Reading the projected token outside a pod + +The `kubernetes` way reads the token from `/var/run/secrets/tokens//token`. To run it outside a cluster, +point the token directory elsewhere: + +```properties +com.netcracker.cloud.security.kubernetes.tokens.dir=/path/to/tokens +``` diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java index e448c44b37..b4c56141dc 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java @@ -58,6 +58,18 @@ public static class CreateOptions { String authMethod; String audience; + public ConsulLoginMode getMode() { + return mode; + } + + public String getAuthMethod() { + return authMethod; + } + + public String getAudience() { + return audience; + } + public static class Builder { CreateOptions options = new CreateOptions(); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java index 4596afa09b..12f2e0ecfa 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java @@ -1,8 +1,8 @@ package com.netcracker.cloud.consul.provider.spring.common.config; import org.apache.commons.logging.Log; -import com.netcracker.cloud.consul.provider.common.ConsulLoginCredentials; -import com.netcracker.cloud.consul.provider.common.TokenProvider; +import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; +import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; import com.netcracker.cloud.consul.provider.common.client.ConsulRestClient; import com.netcracker.cloud.consul.provider.spring.common.Utils; import com.netcracker.cloud.restclient.MicroserviceRestClient; @@ -10,6 +10,7 @@ import org.springframework.boot.bootstrap.BootstrapRegistry; import org.springframework.boot.context.config.ConfigDataLocation; import org.springframework.boot.context.config.ConfigDataLocationResolverContext; +import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.logging.DeferredLogFactory; import org.springframework.cloud.consul.ConsulProperties; import org.springframework.cloud.consul.config.ConsulConfigDataLocationResolver; @@ -30,6 +31,9 @@ public abstract class ConsulM2MConfigDataLocationResolver extends ConsulConfigDa public static final String PROP_CLOUD_NAMESPACE = "cloud.microservice.namespace"; public static final String PROP_CONSUL_M2M_ENABLED = "spring.cloud.consul.config.m2m.enabled"; + public static final String PROP_LOGIN_MODE = "spring.cloud.consul.config.login.mode"; + public static final String PROP_LOGIN_AUTH_METHOD = "spring.cloud.consul.config.login.auth-method"; + public static final String PROP_LOGIN_AUDIENCE = "spring.cloud.consul.config.login.audience"; static final String ENV_NAMESPACE = "NAMESPACE"; static final String ENV_CLOUD_NAMESPACE = "CLOUD_NAMESPACE"; @@ -43,28 +47,29 @@ protected ConsulM2MConfigDataLocationResolver(DeferredLogFactory log) { @Override protected ConsulConfigProperties loadConfigProperties(ConfigDataLocationResolverContext resolverContext) { ConsulConfigProperties consulConfigProperties = super.loadConfigProperties(resolverContext); - boolean isConsulM2MEnabled = resolverContext.getBinder().bind(PROP_CONSUL_M2M_ENABLED, Boolean.class).orElse(true); + Binder binder = resolverContext.getBinder(); + boolean isConsulM2MEnabled = binder.bind(PROP_CONSUL_M2M_ENABLED, Boolean.class).orElse(true); if (!isConsulM2MEnabled) { return consulConfigProperties; } ConsulProperties properties = resolverContext.getBootstrapContext().get(ConsulProperties.class); try { - M2MManager m2MManager = resolverContext.getBootstrapContext().get(M2MManager.class); - Supplier m2mTokenSupplier = () -> m2MManager.getToken().getTokenValue(); - String namespace = getPropsOrEnvsMust(args(PROP_CLOUD_NAMESPACE), args(ENV_NAMESPACE, ENV_CLOUD_NAMESPACE)); + ConsulLoginMode mode = binder.bind(PROP_LOGIN_MODE, ConsulLoginMode.class).orElse(null); + Supplier m2mTokenSupplier = () -> + resolverContext.getBootstrapContext().get(M2MManager.class).getToken().getTokenValue(); ConsulRestClient client = createConsulRestClient(Utils.formatConsulAddress(properties), m2mTokenSupplier); - TokenProvider tokenProvider = new TokenProvider(client, new ConsulLoginCredentials() { - @Override - public String authMethod() { - return namespace; - } - - @Override - public String bearerToken() { - return m2mTokenSupplier.get(); - } - }); - consulConfigProperties.setAclToken(tokenProvider.perform().getSecretId()); + + TokenStorageFactory.CreateOptions.Builder options = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(Utils.formatConsulAddress(properties)) + .mode(mode) + .authMethod(binder.bind(PROP_LOGIN_AUTH_METHOD, String.class).orElse(null)) + .audience(binder.bind(PROP_LOGIN_AUDIENCE, String.class).orElse(null)); + if (mode != ConsulLoginMode.KUBERNETES) { + options.namespace(getPropsOrEnvsMust(args(PROP_CLOUD_NAMESPACE), args(ENV_NAMESPACE, ENV_CLOUD_NAMESPACE))) + .m2mSupplier(m2mTokenSupplier); + } + + consulConfigProperties.setAclToken(TokenStorageFactory.from(client, options.build()).perform().getSecretId()); } catch (IOException e) { log.error("can not get consul token by m2m: ", e); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolverTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolverTest.java index 4ebd3af8cd..8b390716db 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolverTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolverTest.java @@ -4,6 +4,32 @@ import org.junit.jupiter.api.Test; import java.util.Map; +import com.netcracker.cloud.consul.provider.common.ConsulLoginCredentials; +import com.netcracker.cloud.consul.provider.common.client.ConsulClientResponse; +import com.netcracker.cloud.consul.provider.common.client.ConsulRestClient; +import com.netcracker.cloud.restclient.MicroserviceRestClient; +import com.netcracker.cloud.security.core.auth.M2MManager; +import com.netcracker.cloud.security.core.auth.Token; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.mockito.Mockito; +import org.springframework.boot.bootstrap.DefaultBootstrapContext; +import org.springframework.boot.bootstrap.BootstrapRegistry; +import org.springframework.boot.context.config.ConfigDataLocationResolverContext; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.cloud.consul.ConsulProperties; +import org.springframework.cloud.consul.config.ConsulConfigProperties; +import java.io.IOException; +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import static com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver.PROP_CLOUD_NAMESPACE; +import static com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver.PROP_CONSUL_M2M_ENABLED; +import static com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver.PROP_LOGIN_AUTH_METHOD; +import static com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver.PROP_LOGIN_MODE; import static com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver.args; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -38,4 +64,131 @@ void getPropsOrEnvsMust_envs() { String val = ConsulM2MConfigDataLocationResolver.getPropsOrEnvsMust(args("not.exists"), args(firstEnvKey)); assertEquals(firstEnvVal, val); } + + + private static final String SECRET_ID = "test-secret-id"; + + private final Map properties = new HashMap<>(); + private final AtomicInteger m2mLookups = new AtomicInteger(); + + private DefaultBootstrapContext bootstrapContext; + private String rejectedAuthMethod; + private boolean clientReadsBearerToken; + private ConfigDataLocationResolverContext resolverContext; + private ConsulRestClient consulRestClient; + + private static class TestResolver extends ConsulM2MConfigDataLocationResolver { + + private final ConsulRestClient consulRestClient; + + TestResolver(DeferredLogFactory log, ConsulRestClient consulRestClient) { + super(log); + this.consulRestClient = consulRestClient; + } + + @Override + protected MicroserviceRestClient createMicroserviceRestClient() { + return Mockito.mock(MicroserviceRestClient.class); + } + + @Override + protected ConsulRestClient createConsulRestClient(String consulAddr, Supplier m2mTokenSupplier) { + return consulRestClient; + } + } + + @BeforeEach + void init() throws IOException { + System.setProperty(PROP_CLOUD_NAMESPACE, "test-namespace"); + + M2MManager m2MManager = Mockito.mock(M2MManager.class); + Mockito.when(m2MManager.getToken()).thenReturn(Token.DUMMY_TOKEN); + + bootstrapContext = new DefaultBootstrapContext(); + bootstrapContext.register(ConsulProperties.class, BootstrapRegistry.InstanceSupplier.of(consulProperties())); + bootstrapContext.register(M2MManager.class, context -> { + m2mLookups.incrementAndGet(); + return m2MManager; + }); + + consulRestClient = Mockito.mock(ConsulRestClient.class); + Mockito.when(consulRestClient.login(Mockito.any(ConsulLoginCredentials.class))).thenAnswer(invocation -> { + ConsulLoginCredentials credentials = invocation.getArgument(0); + if (rejectedAuthMethod != null && rejectedAuthMethod.equals(credentials.authMethod())) { + throw new IOException("consul auth method is not ready: response code=403; body='ACL not found'"); + } + if (clientReadsBearerToken) { + credentials.bearerToken(); + } + return new ConsulClientResponse("{\"SecretID\":\"" + SECRET_ID + "\"}", 200); + }); + + resolverContext = Mockito.mock(ConfigDataLocationResolverContext.class); + Mockito.when(resolverContext.getBootstrapContext()).thenReturn(bootstrapContext); + Mockito.when(resolverContext.getBinder()) + .thenAnswer(invocation -> new Binder(new MapConfigurationPropertySource(properties))); + } + + @AfterEach + void clearNamespace() { + System.clearProperty(PROP_CLOUD_NAMESPACE); + } + + private static ConsulProperties consulProperties() { + ConsulProperties consulProperties = new ConsulProperties(); + consulProperties.setHost("consul"); + consulProperties.setPort(8500); + return consulProperties; + } + + private ConsulConfigProperties resolve() { + return new TestResolver(Mockito.mock(DeferredLogFactory.class, Mockito.RETURNS_DEEP_STUBS), consulRestClient) + .loadConfigProperties(resolverContext); + } + + @Test + void kubernetesModeWritesSecretIdWithoutTouchingTheBootstrapRegistry() { + properties.put(PROP_LOGIN_MODE, "kubernetes"); + properties.put(PROP_LOGIN_AUTH_METHOD, "core-k8s"); + + ConsulConfigProperties resolved = resolve(); + + Assertions.assertEquals(SECRET_ID, resolved.getAclToken()); + Assertions.assertEquals(0, m2mLookups.get()); + } + + @Test + void autoModeLeavesTheRegistryAloneWhileTheNewWayWorks() { + properties.put(PROP_LOGIN_MODE, "auto"); + properties.put(PROP_LOGIN_AUTH_METHOD, "core-k8s"); + + ConsulConfigProperties resolved = resolve(); + + Assertions.assertEquals(SECRET_ID, resolved.getAclToken()); + Assertions.assertEquals(0, m2mLookups.get()); + } + + @Test + void autoModeAsksTheRegistryOnlyWhenBearerTokenIsNeeded() { + properties.put(PROP_LOGIN_MODE, "auto"); + properties.put(PROP_LOGIN_AUTH_METHOD, "core-k8s"); + rejectedAuthMethod = "core-k8s"; + clientReadsBearerToken = true; + + ConsulConfigProperties resolved = resolve(); + + Assertions.assertEquals(SECRET_ID, resolved.getAclToken()); + Assertions.assertEquals(1, m2mLookups.get()); + } + + @Test + void disabledM2MSkipsTheLoginAltogether() throws IOException { + properties.put(PROP_CONSUL_M2M_ENABLED, "false"); + + ConsulConfigProperties resolved = resolve(); + + Assertions.assertNull(resolved.getAclToken()); + Assertions.assertEquals(0, m2mLookups.get()); + Mockito.verify(consulRestClient, Mockito.never()).login(Mockito.any(ConsulLoginCredentials.class)); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java index 1404674acc..3d05cc1a37 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java @@ -1,8 +1,10 @@ package com.netcracker.cloud.consul.provider.spring.resttemplate.config; +import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; import com.netcracker.cloud.consul.provider.common.TokenStorage; import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; import com.netcracker.cloud.consul.provider.spring.common.SpringTokenStorageFactory; +import com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver; import com.netcracker.cloud.consul.provider.spring.common.Utils; import com.netcracker.cloud.restclient.resttemplate.MicroserviceRestTemplate; import com.netcracker.cloud.security.core.auth.DummyM2MManager; @@ -11,11 +13,13 @@ import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.bind.Binder; import org.springframework.cloud.consul.ConditionalOnConsulEnabled; import org.springframework.cloud.consul.ConsulProperties; import org.springframework.cloud.consul.config.ConsulConfigProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; @Configuration @@ -25,17 +29,34 @@ public class ConsulM2MRestTemplateAutoConfiguration { private final static Logger LOGGER = LoggerFactory.getLogger(ConsulM2MRestTemplateAutoConfiguration.class); + public static final String PROP_LOGIN_MODE = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_MODE; + public static final String PROP_LOGIN_AUTH_METHOD = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_AUTH_METHOD; + public static final String PROP_LOGIN_AUDIENCE = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_AUDIENCE; + @Bean public TokenStorage consulTokenStorageViaM2MRestTemplate(ConsulConfigProperties consulConfigProperties, ConsulProperties consulProperties, - M2MManager m2MManager) { + M2MManager m2MManager, + Environment environment) { TokenStorageFactory factory = new SpringTokenStorageFactory(consulConfigProperties, new MicroserviceRestTemplate()); - return factory.create(new TokenStorageFactory.CreateOptions.Builder() + return factory.create(createOptions(environment, consulProperties, m2MManager, System.getenv("NAMESPACE"))); + } + + static TokenStorageFactory.CreateOptions createOptions(Environment environment, + ConsulProperties consulProperties, + M2MManager m2MManager, + String namespace) { + Binder binder = Binder.get(environment); + + return new TokenStorageFactory.CreateOptions.Builder() .consulUrl(Utils.formatConsulAddress(consulProperties)) - .namespace(System.getenv("NAMESPACE")) + .namespace(namespace) .m2mSupplier(() -> m2MManager.getToken().getTokenValue()) - .build()); + .mode(binder.bind(PROP_LOGIN_MODE, ConsulLoginMode.class).orElse(null)) + .authMethod(binder.bind(PROP_LOGIN_AUTH_METHOD, String.class).orElse(null)) + .audience(binder.bind(PROP_LOGIN_AUDIENCE, String.class).orElse(null)) + .build(); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java new file mode 100644 index 0000000000..2d3e4d7212 --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java @@ -0,0 +1,89 @@ +package com.netcracker.cloud.consul.provider.spring.resttemplate.config; + +import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; +import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; +import com.netcracker.cloud.security.core.auth.M2MManager; +import com.netcracker.cloud.security.core.utils.k8s.AudienceName; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.boot.context.properties.bind.BindException; +import org.springframework.cloud.consul.ConsulProperties; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; + +import java.util.HashMap; +import java.util.Map; + +import static com.netcracker.cloud.consul.provider.spring.resttemplate.config.ConsulM2MRestTemplateAutoConfiguration.PROP_LOGIN_AUDIENCE; +import static com.netcracker.cloud.consul.provider.spring.resttemplate.config.ConsulM2MRestTemplateAutoConfiguration.PROP_LOGIN_AUTH_METHOD; +import static com.netcracker.cloud.consul.provider.spring.resttemplate.config.ConsulM2MRestTemplateAutoConfiguration.PROP_LOGIN_MODE; + +class ConsulM2MRestTemplateAutoConfigurationTest { + + private final Map properties = new HashMap<>(); + private StandardEnvironment environment; + private ConsulProperties consulProperties; + private M2MManager m2MManager; + + @BeforeEach + void init() { + environment = new StandardEnvironment(); + environment.getPropertySources().addFirst(new MapPropertySource("test", properties)); + + consulProperties = new ConsulProperties(); + consulProperties.setHost("consul"); + consulProperties.setPort(8500); + + m2MManager = Mockito.mock(M2MManager.class); + } + + private TokenStorageFactory.CreateOptions options() { + return ConsulM2MRestTemplateAutoConfiguration.createOptions(environment, consulProperties, m2MManager, "ns"); + } + + @Test + void modeIsAutoWhenNothingIsConfigured() { + Assertions.assertEquals(ConsulLoginMode.AUTO, options().getMode()); + } + + @Test + void kubernetesModeReachesCreateOptionsAndNeedsNoM2MManager() { + properties.put(PROP_LOGIN_MODE, "kubernetes"); + + TokenStorageFactory.CreateOptions opts = options(); + + Assertions.assertEquals(ConsulLoginMode.KUBERNETES, opts.getMode()); + Mockito.verifyNoInteractions(m2MManager); + } + + @Test + void unknownModeBreaksTheBean() { + properties.put(PROP_LOGIN_MODE, "cloud-foundry"); + + Assertions.assertThrows(BindException.class, this::options); + } + + @Test + void authMethodAndAudienceDefaultsComeFromTheBuilder() { + properties.put(PROP_LOGIN_MODE, "kubernetes"); + + TokenStorageFactory.CreateOptions opts = options(); + + Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_AUTH_METHOD, opts.getAuthMethod()); + Assertions.assertEquals(AudienceName.NETCRACKER, opts.getAudience()); + } + + @Test + void authMethodAndAudienceAreReadFromTheConfiguration() { + properties.put(PROP_LOGIN_MODE, "kubernetes"); + properties.put(PROP_LOGIN_AUTH_METHOD, "core-k8s"); + properties.put(PROP_LOGIN_AUDIENCE, AudienceName.DBAAS); + + TokenStorageFactory.CreateOptions opts = options(); + + Assertions.assertEquals("core-k8s", opts.getAuthMethod()); + Assertions.assertEquals(AudienceName.DBAAS, opts.getAudience()); + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/pom.xml b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/pom.xml index 27e1ae0bc1..1b6341ee8a 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/pom.xml +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/pom.xml @@ -24,5 +24,16 @@ rest-security-adapters ${project.version} + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.mockito + mockito-core + test + diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java index 6939cf9e3d..bdbdf1e9a5 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java @@ -1,19 +1,23 @@ package com.netcracker.cloud.consul.provider.spring.webclient.config; +import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; import com.netcracker.cloud.consul.provider.common.TokenStorage; import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; import com.netcracker.cloud.consul.provider.spring.common.SpringTokenStorageFactory; +import com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver; import com.netcracker.cloud.consul.provider.spring.common.Utils; import com.netcracker.cloud.restclient.webclient.MicroserviceWebClient; import com.netcracker.cloud.security.common.reactive.DummyM2MManager; import com.netcracker.cloud.security.common.reactive.M2MManager; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.bind.Binder; import org.springframework.cloud.consul.ConditionalOnConsulEnabled; import org.springframework.cloud.consul.ConsulProperties; import org.springframework.cloud.consul.config.ConsulConfigProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; @Configuration @ConditionalOnConsulEnabled @@ -21,17 +25,34 @@ @ConditionalOnProperty(value = "spring.cloud.consul.config.m2m.enabled", havingValue = "true", matchIfMissing = true) public class ConsulM2MWebClientAutoConfiguration { + public static final String PROP_LOGIN_MODE = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_MODE; + public static final String PROP_LOGIN_AUTH_METHOD = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_AUTH_METHOD; + public static final String PROP_LOGIN_AUDIENCE = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_AUDIENCE; + @Bean public TokenStorage consulTokenStorageViaM2MWebClient(ConsulConfigProperties consulConfigProperties, ConsulProperties consulProperties, - M2MManager m2MManager) { + M2MManager m2MManager, + Environment environment) { TokenStorageFactory factory = new SpringTokenStorageFactory(consulConfigProperties, new MicroserviceWebClient()); - return factory.create(new TokenStorageFactory.CreateOptions.Builder() + return factory.create(createOptions(environment, consulProperties, m2MManager, System.getenv("NAMESPACE"))); + } + + static TokenStorageFactory.CreateOptions createOptions(Environment environment, + ConsulProperties consulProperties, + M2MManager m2MManager, + String namespace) { + Binder binder = Binder.get(environment); + + return new TokenStorageFactory.CreateOptions.Builder() .consulUrl(Utils.formatConsulAddress(consulProperties)) - .namespace(System.getenv("NAMESPACE")) + .namespace(namespace) .m2mSupplier(() -> m2MManager.getToken().block().getTokenValue()) - .build()); + .mode(binder.bind(PROP_LOGIN_MODE, ConsulLoginMode.class).orElse(null)) + .authMethod(binder.bind(PROP_LOGIN_AUTH_METHOD, String.class).orElse(null)) + .audience(binder.bind(PROP_LOGIN_AUDIENCE, String.class).orElse(null)) + .build(); } @Bean diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java new file mode 100644 index 0000000000..c741d6c4af --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java @@ -0,0 +1,89 @@ +package com.netcracker.cloud.consul.provider.spring.webclient.config; + +import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; +import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; +import com.netcracker.cloud.security.common.reactive.M2MManager; +import com.netcracker.cloud.security.core.utils.k8s.AudienceName; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.boot.context.properties.bind.BindException; +import org.springframework.cloud.consul.ConsulProperties; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; + +import java.util.HashMap; +import java.util.Map; + +import static com.netcracker.cloud.consul.provider.spring.webclient.config.ConsulM2MWebClientAutoConfiguration.PROP_LOGIN_AUDIENCE; +import static com.netcracker.cloud.consul.provider.spring.webclient.config.ConsulM2MWebClientAutoConfiguration.PROP_LOGIN_AUTH_METHOD; +import static com.netcracker.cloud.consul.provider.spring.webclient.config.ConsulM2MWebClientAutoConfiguration.PROP_LOGIN_MODE; + +class ConsulM2MWebClientAutoConfigurationTest { + + private final Map properties = new HashMap<>(); + private StandardEnvironment environment; + private ConsulProperties consulProperties; + private M2MManager m2MManager; + + @BeforeEach + void init() { + environment = new StandardEnvironment(); + environment.getPropertySources().addFirst(new MapPropertySource("test", properties)); + + consulProperties = new ConsulProperties(); + consulProperties.setHost("consul"); + consulProperties.setPort(8500); + + m2MManager = Mockito.mock(M2MManager.class); + } + + private TokenStorageFactory.CreateOptions options() { + return ConsulM2MWebClientAutoConfiguration.createOptions(environment, consulProperties, m2MManager, "ns"); + } + + @Test + void modeIsAutoWhenNothingIsConfigured() { + Assertions.assertEquals(ConsulLoginMode.AUTO, options().getMode()); + } + + @Test + void kubernetesModeReachesCreateOptionsAndNeedsNoM2MManager() { + properties.put(PROP_LOGIN_MODE, "kubernetes"); + + TokenStorageFactory.CreateOptions opts = options(); + + Assertions.assertEquals(ConsulLoginMode.KUBERNETES, opts.getMode()); + Mockito.verifyNoInteractions(m2MManager); + } + + @Test + void unknownModeBreaksTheBean() { + properties.put(PROP_LOGIN_MODE, "cloud-foundry"); + + Assertions.assertThrows(BindException.class, this::options); + } + + @Test + void authMethodAndAudienceDefaultsComeFromTheBuilder() { + properties.put(PROP_LOGIN_MODE, "kubernetes"); + + TokenStorageFactory.CreateOptions opts = options(); + + Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_AUTH_METHOD, opts.getAuthMethod()); + Assertions.assertEquals(AudienceName.NETCRACKER, opts.getAudience()); + } + + @Test + void authMethodAndAudienceAreReadFromTheConfiguration() { + properties.put(PROP_LOGIN_MODE, "kubernetes"); + properties.put(PROP_LOGIN_AUTH_METHOD, "core-k8s"); + properties.put(PROP_LOGIN_AUDIENCE, AudienceName.DBAAS); + + TokenStorageFactory.CreateOptions opts = options(); + + Assertions.assertEquals("core-k8s", opts.getAuthMethod()); + Assertions.assertEquals(AudienceName.DBAAS, opts.getAudience()); + } +} From b1cd52f460df3fcaba002969915d9c12e4b1c40c Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Thu, 27 Aug 2026 09:43:10 +0400 Subject: [PATCH 03/20] chore: todos @cf_ignore --- .../quarkus/consul/client/ConsulClientConfiguration.java | 1 + .../netcracker/cloud/consul/provider/common/ConsulLogin.java | 2 ++ .../cloud/consul/provider/common/ConsulLoginCredentials.java | 1 + .../cloud/consul/provider/common/ConsulLoginMode.java | 3 ++- .../cloud/consul/provider/common/ProbingConsulLogin.java | 4 ++++ .../cloud/consul/provider/common/SelfTokenReader.java | 1 + .../netcracker/cloud/consul/provider/common/TokenUpdater.java | 3 +++ .../consul/provider/common/client/ConsulOkHttpClient.java | 2 ++ .../cloud/consul/provider/common/client/ConsulRestClient.java | 4 ++++ .../config/ConsulM2MRestTemplateAutoConfiguration.java | 2 ++ 10 files changed, 22 insertions(+), 1 deletion(-) diff --git a/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java b/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java index 54af45c09a..862d28c5db 100644 --- a/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java +++ b/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java @@ -23,6 +23,7 @@ @Singleton public class ConsulClientConfiguration { + //todo vlla мне не нравится, что имена пропертей дублируются. Мы можем эти константы задать где то в одном месте кода и переиспользовать? public static final String PROP_LOGIN_MODE = "quarkus.consul-source-config.login.mode"; public static final String PROP_LOGIN_AUTH_METHOD = "quarkus.consul-source-config.login.auth-method"; public static final String PROP_LOGIN_AUDIENCE = "quarkus.consul-source-config.login.audience"; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLogin.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLogin.java index 347a130354..bb859cec6a 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLogin.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLogin.java @@ -2,6 +2,8 @@ import java.io.IOException; +//todo vlla мне не очень нравится нейминг этого класса и метода perform, а так же связка классов ProbingConsulLogin, ConsulLogin и TokenProvider. ProbingConsulLogin одновременно и наследник ConsulLogin, и аггрегирует в себе несколько других ConsulLogin. Думаю, мы тут можем подумать, какой паттенр лучше всего подходимт под эту ситуацию и можем сделать некоторый рефакторинг. Сначала проанализируй и предложи варианты, не исправляй сразу. +//возможно, достаточно просто переименовать ConsulLogin в ConsulTokenProvider, а сам старый TokenProvider во что-то еще public interface ConsulLogin { Token perform() throws IOException; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java index 7d7da71dca..9572702788 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java @@ -1,5 +1,6 @@ package com.netcracker.cloud.consul.provider.common; +//todo vlla может использовать get в именах методов? public interface ConsulLoginCredentials { String authMethod(); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java index 516075ffb0..f47a1564c4 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java @@ -1,7 +1,8 @@ package com.netcracker.cloud.consul.provider.common; +//todo vlla поступил комментарий от автора требований не использовать имя AUTO, а сделать более явным, использовать что-то со словом FALLBACK например. Тут и в соответствующей проперте public enum ConsulLoginMode { AUTO, KUBERNETES, - M2M + M2M //todo vlla я что то не нашел места, где у нас проставляется мод m2m. Мы точно ввели и читаем новую проперти для этого? } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java index f759c76fa5..3a1b69d8b5 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java @@ -43,6 +43,9 @@ public synchronized Token perform() throws IOException { try { Token token = probe(); chosen = probed; + //todo vlla мы тут смешиваем контексты. Переменные называются probed и fallback, что как бы подразумевает, что они могут быть любыми способами. При этом при удаче и неудаче мы используем KUBERNETES_WAY и M2M_WAY, сразу предполагая, что мы знаем какиеми они будут. + // надо выбрать один из подходов: генерализованный, когда ProbingConsulLogin не знает, что за ConsulLogin в него переданы (но тогда тип авторизации надо как-то получать из самих ConsulLogin), либо уж он захардкожен на пару kubernetes + m2m + // проанализируй оба подхода и давай выберем log.info("Consul ACL token is obtained by the {} auth method", KUBERNETES_WAY); return token; } catch (Exception e) { @@ -67,6 +70,7 @@ private Token probe() throws IOException { } } + //todo vlla может есть более изящный способ сделать паузы, нежели Thread.sleep в продакшен-коде? Поищи примеры в других модулях. private void pauseBeforeNextAttempt() { if (probePause.isZero() || probePause.isNegative()) { return; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java index 6e8caf4c5a..21071da61a 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.time.OffsetDateTime; +//todo vlla напомни, чем мы руководствовались, выделяя SelfTokenReader в отдельный класс? Почему мы не оставили getSelfToken рядом с логином? Разве так было бы не проще с точки зрения кода? public class SelfTokenReader { private static final Logger log = LoggerFactory.getLogger(SelfTokenReader.class); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java index dbdce749b5..441e3973b2 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java @@ -18,6 +18,7 @@ public class TokenUpdater { private static final Logger log = LoggerFactory.getLogger(TokenUpdater.class); static final int DEFAULT_TRIES = 10; private static final Duration DEFAULT_RETRY_PAUSE = Duration.ofSeconds(1); + //todo vlla это СУПЕР не понятные и излишние константы. Почеме не просто 0.8? private static final long DELAY_NUMERATOR = 8; private static final long DELAY_DENOMINATOR = 10; static final long MIN_DELAY_SECONDS = 10; @@ -69,6 +70,7 @@ synchronized public void watch(Consumer updater, String currentSecretId) } } + //todo vlla неудачное название private long relogonDelay(OffsetDateTime expirationTime) { long remaining = ChronoUnit.SECONDS.between(OffsetDateTime.now(clock), expirationTime); return Math.max(remaining * DELAY_NUMERATOR / DELAY_DENOMINATOR, MIN_DELAY_SECONDS); @@ -89,6 +91,7 @@ private Token withRetry(CheckedFunction c, int tries) { } } + //todo vlla аналогично: может есть более изящный способ сделать паузы, нежели Thread.sleep в продакшен-коде? private void pauseBeforeNextAttempt() { if (retryPause.isZero() || retryPause.isNegative()) { return; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java index 31de7f1084..0afe90139c 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java @@ -85,4 +85,6 @@ public ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOE ).execute(); return new ConsulClientResponse(response.body().string(), response.code()); } + + //todo vlla вынести общую часть в отдельный метод? Наверное нет, сложнее будет удалять. } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java index 36f7033708..22f38b465b 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java @@ -47,6 +47,9 @@ public ConsulClientResponse getSelfToken(String currentSecretId) { return new ConsulClientResponse(response.getResponseBody(), response.getHttpStatus()); } + //todo vlla есть предложение удалить реализацию этого метода и бросить UnsupportedOperationException. Во первых, он deprecated, во вторых - после наших изменений код проходит только через метод login(ConsulLoginCredentials credentials) и нокогда через старый login (тут я могу ошибаться надо проверить). + // нам это даст возможность не передавать m2mTokenSupplier в клиент и немного упростить com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver.loadConfigProperties + // но это только предложение, надо его проанализировать @Override public ConsulClientResponse login(String authMethod) { Map payload = new HashMap<>(); @@ -77,6 +80,7 @@ public ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOE try { response = client.doRequest(consulAddr + V1_ACL_LOGIN, HttpMethod.POST, headers, json, String.class); } catch (MicroserviceRestClientException e) { + //todo vlla добавить комментарий, зачем здесь сделан выброс эксепшена throw new IOException("can not perform login to consul: " + e.getMessage(), e); } return new ConsulClientResponse(response.getResponseBody(), response.getHttpStatus()); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java index 3d05cc1a37..b051a5337c 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java @@ -29,6 +29,7 @@ public class ConsulM2MRestTemplateAutoConfiguration { private final static Logger LOGGER = LoggerFactory.getLogger(ConsulM2MRestTemplateAutoConfiguration.class); + // todo vlla эм, а нафига их дублировать? public static final String PROP_LOGIN_MODE = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_MODE; public static final String PROP_LOGIN_AUTH_METHOD = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_AUTH_METHOD; public static final String PROP_LOGIN_AUDIENCE = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_AUDIENCE; @@ -53,6 +54,7 @@ static TokenStorageFactory.CreateOptions createOptions(Environment environment, .consulUrl(Utils.formatConsulAddress(consulProperties)) .namespace(namespace) .m2mSupplier(() -> m2MManager.getToken().getTokenValue()) + //todo vlla это же бин, мы не можем просто заинжектить значения этих пропертей? Через биндер же очень низкоуровнево? Или есть причины, почему надо именно через биндер? .mode(binder.bind(PROP_LOGIN_MODE, ConsulLoginMode.class).orElse(null)) .authMethod(binder.bind(PROP_LOGIN_AUTH_METHOD, String.class).orElse(null)) .audience(binder.bind(PROP_LOGIN_AUDIENCE, String.class).orElse(null)) From f8f5dd116591136417b63324e6a70af68b0c634c Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Thu, 27 Aug 2026 11:11:40 +0400 Subject: [PATCH 04/20] refactor(consul): apply review notes on the kubernetes login @cf_ignore - ConsulLoginCredentials exposes getAuthMethod and getBearerToken, matching the other getters in the module - the fallback mode is named KUBERNETES_WITH_M2M_FALLBACK instead of AUTO, so the property value says what the mode does - retries in TokenUpdater and in the probe are built on failsafe (LoginRetryPolicies) instead of a hand-rolled loop around Thread.sleep, and gain exponential backoff with jitter; the library was already on the classpath through k8s-utils - the delay fraction is one constant, and the method computing it is named after what it returns - the three Spring properties are bound into ConsulLoginProperties, shared by the ConfigData phase and both autoconfigurations; the duplicated name constants are gone - ConsulRestClient says why a transport failure is wrapped into IOException - the m2m mode gets coverage in the entry points, where only kubernetes and the default were checked before @cf_ignore covers the deferred russian TODO in ProbingConsulLogin, kept until the naming pass agreed with the owner. --- .../config-sources/consul-client/README.md | 6 +-- .../client/ConsulClientConfiguration.java | 1 - .../client/ConsulClientConfigurationTest.java | 2 +- .../client/ConsulLoginModeConfigTest.java | 11 +++-- .../consul-config-provider/README.md | 6 +-- .../common/ConsulLoginCredentials.java | 5 +-- .../provider/common/ConsulLoginMode.java | 5 +-- .../common/KubernetesLoginCredentials.java | 4 +- .../provider/common/LoginRetryPolicies.java | 28 ++++++++++++ .../provider/common/M2MLoginCredentials.java | 4 +- .../provider/common/ProbingConsulLogin.java | 30 ++++--------- .../provider/common/SelfTokenReader.java | 1 - .../provider/common/TokenStorageFactory.java | 4 +- .../consul/provider/common/TokenUpdater.java | 42 ++++++------------ .../provider/common/client/ConsulClient.java | 2 +- .../common/client/ConsulOkHttpClient.java | 7 ++- .../common/client/ConsulRestClient.java | 12 +++--- .../KubernetesLoginCredentialsTest.java | 10 ++--- .../common/M2MLoginCredentialsTest.java | 6 +-- .../common/TokenStorageFactoryTest.java | 20 ++++----- .../provider/common/TokenUpdaterTest.java | 7 +-- .../client/ConsulClientDefaultLoginTest.java | 4 +- .../common/client/ConsulOkHttpClientTest.java | 4 +- .../common/client/ConsulRestClientTest.java | 4 +- .../common/config/ConsulLoginProperties.java | 43 +++++++++++++++++++ .../ConsulM2MConfigDataLocationResolver.java | 14 +++--- ...nsulM2MConfigDataLocationResolverTest.java | 37 ++++++++++++---- ...onsulM2MRestTemplateAutoConfiguration.java | 27 ++++-------- ...lM2MRestTemplateAutoConfigurationTest.java | 39 ++++++----------- .../ConsulM2MWebClientAutoConfiguration.java | 25 ++++------- ...nsulM2MWebClientAutoConfigurationTest.java | 39 ++++++----------- 31 files changed, 232 insertions(+), 217 deletions(-) create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java diff --git a/core-quarkus-extensions/config-sources/consul-client/README.md b/core-quarkus-extensions/config-sources/consul-client/README.md index c992fadc9c..261991cddf 100644 --- a/core-quarkus-extensions/config-sources/consul-client/README.md +++ b/core-quarkus-extensions/config-sources/consul-client/README.md @@ -44,8 +44,8 @@ The Consul ACL token is exchanged through `POST /v1/acl/login`. Two ways to obta the projected service account token of the pod, `m2m` sends an M2M token. The three properties below are read at runtime when the `TokenStorage` bean is built, so the way can be switched without rebuilding the application. -A pod keeps one way for its whole life: in `auto` mode the first login decides, and a failure of a later login does not -switch the way back. In `auto` mode the `kubernetes` way is tried first, and on failure the pod falls back to the `m2m` +A pod keeps one way for its whole life: in `kubernetes-with-m2m-fallback` mode the first login decides, and a failure of a later login does not +switch the way back. In `kubernetes-with-m2m-fallback` mode the `kubernetes` way is tried first, and on failure the pod falls back to the `m2m` way and logs one `INFO` record with the reason, the Consul response code, and a truncated response body. In `kubernetes` mode there is no probe and no fallback. In `m2m` mode the auth method name and the audience are not read at all. @@ -66,6 +66,6 @@ the token directory elsewhere with `com.netcracker.cloud.security.kubernetes.tok | quarkus.consul-source-config.properties-root | List of properties roots | config/$namespace/application, config/$namespace/$appName | | quarkus.consul-source-config.wait-time | Maximum Value for Consul blocking queries wait time (seconds) | 570 | | quarkus.consul-source-config.m2m.enabled | Enable the Consul ACL token exchange (bool, build time) | true | -| quarkus.consul-source-config.login.mode | Way to obtain the ACL token: auto, kubernetes or m2m | auto | +| quarkus.consul-source-config.login.mode | Way to obtain the ACL token: kubernetes-with-m2m-fallback, kubernetes or m2m | kubernetes-with-m2m-fallback | | quarkus.consul-source-config.login.auth-method | Consul auth method name, used by the kubernetes way | kubernetes-auth-method-placeholder | | quarkus.consul-source-config.login.audience | Projected token audience, used by the kubernetes way | netcracker | diff --git a/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java b/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java index 862d28c5db..54af45c09a 100644 --- a/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java +++ b/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java @@ -23,7 +23,6 @@ @Singleton public class ConsulClientConfiguration { - //todo vlla мне не нравится, что имена пропертей дублируются. Мы можем эти константы задать где то в одном месте кода и переиспользовать? public static final String PROP_LOGIN_MODE = "quarkus.consul-source-config.login.mode"; public static final String PROP_LOGIN_AUTH_METHOD = "quarkus.consul-source-config.login.auth-method"; public static final String PROP_LOGIN_AUDIENCE = "quarkus.consul-source-config.login.audience"; diff --git a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationTest.java b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationTest.java index 238f94a8cf..7d4f89bc9d 100644 --- a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationTest.java +++ b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationTest.java @@ -59,7 +59,7 @@ void defaultsAreTakenWhenNoLoginPropertyIsSet() { ArgumentCaptor options = ArgumentCaptor.forClass(TokenStorageFactory.CreateOptions.class); verify(tokenStorageFactory).create(options.capture()); - Assertions.assertEquals(ConsulLoginMode.AUTO, options.getValue().getMode()); + Assertions.assertEquals(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK, options.getValue().getMode()); Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_AUTH_METHOD, options.getValue().getAuthMethod()); Assertions.assertEquals(AudienceName.NETCRACKER, options.getValue().getAudience()); } diff --git a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulLoginModeConfigTest.java b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulLoginModeConfigTest.java index 9c6e427e0d..4e89fa2811 100644 --- a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulLoginModeConfigTest.java +++ b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulLoginModeConfigTest.java @@ -16,10 +16,15 @@ private static SmallRyeConfig configWithMode(String mode) { .build(); } + private static ConsulLoginMode read(String mode) { + return configWithMode(mode).getValue(ConsulClientConfiguration.PROP_LOGIN_MODE, ConsulLoginMode.class); + } + @Test - void knownModeIsRead() { - Assertions.assertEquals(ConsulLoginMode.KUBERNETES, - configWithMode("kubernetes").getValue(ConsulClientConfiguration.PROP_LOGIN_MODE, ConsulLoginMode.class)); + void everyModeIsReadFromItsPropertyValue() { + Assertions.assertEquals(ConsulLoginMode.KUBERNETES, read("kubernetes")); + Assertions.assertEquals(ConsulLoginMode.M2M, read("m2m")); + Assertions.assertEquals(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK, read("kubernetes-with-m2m-fallback")); } @Test diff --git a/core-rest-libraries/consul-config-provider/README.md b/core-rest-libraries/consul-config-provider/README.md index 23f1dfdeff..b25021f402 100644 --- a/core-rest-libraries/consul-config-provider/README.md +++ b/core-rest-libraries/consul-config-provider/README.md @@ -12,16 +12,16 @@ Two ways to obtain the token are supported: ## Login properties The three properties below are read at startup, in the ConfigData phase and again when the `TokenStorage` bean is -built. A pod keeps one way of obtaining the token for its whole life: in `auto` mode the first login decides, and a +built. A pod keeps one way of obtaining the token for its whole life: in `kubernetes-with-m2m-fallback` mode the first login decides, and a failure of a later login does not switch the way back. | Property name | Type | Default | Read when | |-----------------------------------------------------|-------------------------------|--------------------------------------|--------------------------------------------------------------| -| `spring.cloud.consul.config.login.mode` | `auto`, `kubernetes` or `m2m` | `auto` | ConfigData phase and `TokenStorage` bean creation | +| `spring.cloud.consul.config.login.mode` | `kubernetes-with-m2m-fallback`, `kubernetes` or `m2m` | `kubernetes-with-m2m-fallback` | ConfigData phase and `TokenStorage` bean creation | | `spring.cloud.consul.config.login.auth-method` | string | `kubernetes-auth-method-placeholder` | same, and only when the `kubernetes` way is used | | `spring.cloud.consul.config.login.audience` | string | `netcracker` | same, and only when the `kubernetes` way is used | -In `auto` mode the `kubernetes` way is tried first. If it fails, the pod falls back to the `m2m` way and logs one +In `kubernetes-with-m2m-fallback` mode the `kubernetes` way is tried first. If it fails, the pod falls back to the `m2m` way and logs one `INFO` record with the reason, the Consul response code, and a truncated response body. In `kubernetes` mode there is no probe and no fallback, and neither `M2MManager` nor the namespace is needed. In `m2m` mode the auth method name and the audience are not read at all. diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java index 9572702788..2aa77afbdc 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java @@ -1,9 +1,8 @@ package com.netcracker.cloud.consul.provider.common; -//todo vlla может использовать get в именах методов? public interface ConsulLoginCredentials { - String authMethod(); + String getAuthMethod(); - String bearerToken(); + String getBearerToken(); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java index f47a1564c4..8c9839e4a1 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java @@ -1,8 +1,7 @@ package com.netcracker.cloud.consul.provider.common; -//todo vlla поступил комментарий от автора требований не использовать имя AUTO, а сделать более явным, использовать что-то со словом FALLBACK например. Тут и в соответствующей проперте public enum ConsulLoginMode { - AUTO, + KUBERNETES_WITH_M2M_FALLBACK, KUBERNETES, - M2M //todo vlla я что то не нашел места, где у нас проставляется мод m2m. Мы точно ввели и читаем новую проперти для этого? + M2M } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentials.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentials.java index 5306b7e4aa..19b328afbd 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentials.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentials.java @@ -13,12 +13,12 @@ final class KubernetesLoginCredentials implements ConsulLoginCredentials { } @Override - public String authMethod() { + public String getAuthMethod() { return authMethod; } @Override - public String bearerToken() { + public String getBearerToken() { return KubernetesAudienceToken.getToken(audience); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java new file mode 100644 index 0000000000..cb394466ae --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java @@ -0,0 +1,28 @@ +package com.netcracker.cloud.consul.provider.common; + +import net.jodah.failsafe.RetryPolicy; + +import java.io.IOException; +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +final class LoginRetryPolicies { + + static final double JITTER = 0.25; + private static final int MAX_BACKOFF_FACTOR = 8; + + private LoginRetryPolicies() { + } + + static RetryPolicy onTransportFailure(int attempts, Duration delay) { + RetryPolicy policy = new RetryPolicy() + .handle(IOException.class) + .withMaxAttempts(attempts); + if (delay.isZero() || delay.isNegative()) { + return policy; + } + return policy + .withBackoff(delay.toMillis(), delay.multipliedBy(MAX_BACKOFF_FACTOR).toMillis(), ChronoUnit.MILLIS) + .withJitter(JITTER); + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentials.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentials.java index c82b423979..14aa58a4a3 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentials.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentials.java @@ -13,12 +13,12 @@ final class M2MLoginCredentials implements ConsulLoginCredentials { } @Override - public String authMethod() { + public String getAuthMethod() { return namespace; } @Override - public String bearerToken() { + public String getBearerToken() { return m2mSupplier.get(); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java index 3a1b69d8b5..7e3957dbd0 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java @@ -1,5 +1,7 @@ package com.netcracker.cloud.consul.provider.common; +import net.jodah.failsafe.Failsafe; +import net.jodah.failsafe.FailsafeException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,29 +59,13 @@ public synchronized Token perform() throws IOException { } private Token probe() throws IOException { - int count = 0; - while (true) { - try { - return probed.perform(); - } catch (IOException e) { - if (++count >= tries) { - throw e; - } - pauseBeforeNextAttempt(); - } - } - } - - //todo vlla может есть более изящный способ сделать паузы, нежели Thread.sleep в продакшен-коде? Поищи примеры в других модулях. - private void pauseBeforeNextAttempt() { - if (probePause.isZero() || probePause.isNegative()) { - return; - } try { - Thread.sleep(probePause.toMillis()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("interrupted while probing the consul login", e); + return Failsafe.with(LoginRetryPolicies.onTransportFailure(tries, probePause) + .onFailedAttempt(event -> log.debug("Failed probe attempt {} of the {} auth method", + event.getAttemptCount(), KUBERNETES_WAY, event.getLastFailure()))) + .get(probed::perform); + } catch (FailsafeException e) { + throw (IOException) e.getCause(); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java index 21071da61a..6e8caf4c5a 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java @@ -10,7 +10,6 @@ import java.io.IOException; import java.time.OffsetDateTime; -//todo vlla напомни, чем мы руководствовались, выделяя SelfTokenReader в отдельный класс? Почему мы не оставили getSelfToken рядом с логином? Разве так было бы не проще с точки зрения кода? public class SelfTokenReader { private static final Logger log = LoggerFactory.getLogger(SelfTokenReader.class); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java index b4c56141dc..3ddd22d155 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java @@ -108,7 +108,7 @@ public Builder audience(String audience) { public CreateOptions build() { if (options.mode == null) { - options.mode = ConsulLoginMode.AUTO; + options.mode = ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK; } if (options.authMethod == null) { options.authMethod = DEFAULT_AUTH_METHOD; @@ -129,7 +129,7 @@ public CreateOptions build() { private static void require(boolean given, String input, ConsulLoginMode mode) { if (!given) { throw new IllegalArgumentException(String.format("%s is required in the %s consul login mode", - input, mode.name().toLowerCase(Locale.ROOT))); + input, mode.name().toLowerCase(Locale.ROOT).replace('_', '-'))); } } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java index 441e3973b2..f168378af9 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java @@ -1,5 +1,7 @@ package com.netcracker.cloud.consul.provider.common; +import net.jodah.failsafe.Failsafe; +import net.jodah.failsafe.FailsafeException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,9 +20,7 @@ public class TokenUpdater { private static final Logger log = LoggerFactory.getLogger(TokenUpdater.class); static final int DEFAULT_TRIES = 10; private static final Duration DEFAULT_RETRY_PAUSE = Duration.ofSeconds(1); - //todo vlla это СУПЕР не понятные и излишние константы. Почеме не просто 0.8? - private static final long DELAY_NUMERATOR = 8; - private static final long DELAY_DENOMINATOR = 10; + private static final double DELAY_FRACTION = 0.8; static final long MIN_DELAY_SECONDS = 10; private final ConsulLogin consulLogin; private final SelfTokenReader selfTokenReader; @@ -56,7 +56,7 @@ synchronized public void watch(Consumer updater, String currentSecretId) } if (token.getExpirationTime() != null) { - long delay = relogonDelay(token.getExpirationTime()); + long delay = reloginDelaySeconds(token.getExpirationTime()); Runnable task = () -> { log.debug("Get new consul token with {} retry attempts", tries); try { @@ -70,37 +70,19 @@ synchronized public void watch(Consumer updater, String currentSecretId) } } - //todo vlla неудачное название - private long relogonDelay(OffsetDateTime expirationTime) { + private long reloginDelaySeconds(OffsetDateTime expirationTime) { long remaining = ChronoUnit.SECONDS.between(OffsetDateTime.now(clock), expirationTime); - return Math.max(remaining * DELAY_NUMERATOR / DELAY_DENOMINATOR, MIN_DELAY_SECONDS); + return Math.max((long) (remaining * DELAY_FRACTION), MIN_DELAY_SECONDS); } private Token withRetry(CheckedFunction c, int tries) { - int count = 0; - while (true) { - try { - return c.apply(null); - } catch (IOException e) { - if (++count >= tries) { - throw new RuntimeException("can not update consul token: ", e); - } - log.debug("Failed {} retry attempt, exception: {}", count, e); - pauseBeforeNextAttempt(); - } - } - } - - //todo vlla аналогично: может есть более изящный способ сделать паузы, нежели Thread.sleep в продакшен-коде? - private void pauseBeforeNextAttempt() { - if (retryPause.isZero() || retryPause.isNegative()) { - return; - } try { - Thread.sleep(retryPause.toMillis()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("interrupted while waiting for the next consul token attempt", e); + return Failsafe.with(LoginRetryPolicies.onTransportFailure(tries, retryPause) + .onFailedAttempt(event -> log.debug("Failed attempt {} to get a consul token", + event.getAttemptCount(), event.getLastFailure()))) + .get(() -> c.apply(null)); + } catch (FailsafeException e) { + throw new RuntimeException("can not update consul token: ", e.getCause()); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulClient.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulClient.java index 1782b32fc2..96d3d20610 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulClient.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulClient.java @@ -20,6 +20,6 @@ public interface ConsulClient { ConsulClientResponse login(String authMethod); default ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOException { - return login(credentials.authMethod()); + return login(credentials.getAuthMethod()); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java index 0afe90139c..e6693a0cef 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java @@ -73,10 +73,10 @@ public ConsulClientResponse login(String authMethod) { @Override public ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOException { Map payload = new HashMap<>(); - payload.put(AUTH_METHOD_FIELD, credentials.authMethod()); - payload.put(BEARER_TOKEN_FIELD, credentials.bearerToken()); + payload.put(AUTH_METHOD_FIELD, credentials.getAuthMethod()); + payload.put(BEARER_TOKEN_FIELD, credentials.getBearerToken()); String json = new Gson().toJson(payload); - log.info("Perform login to {} with {} auth method", consulAddr, credentials.authMethod()); + log.info("Perform login to {} with {} auth method", consulAddr, credentials.getAuthMethod()); Response response = client.newCall(new Request.Builder() .post(RequestBody.create(MediaType.parse(APPLICATION_JSON), json)) .url(consulAddr + V1_ACL_LOGIN) @@ -86,5 +86,4 @@ public ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOE return new ConsulClientResponse(response.body().string(), response.code()); } - //todo vlla вынести общую часть в отдельный метод? Наверное нет, сложнее будет удалять. } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java index 22f38b465b..31a7177e9d 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java @@ -47,9 +47,6 @@ public ConsulClientResponse getSelfToken(String currentSecretId) { return new ConsulClientResponse(response.getResponseBody(), response.getHttpStatus()); } - //todo vlla есть предложение удалить реализацию этого метода и бросить UnsupportedOperationException. Во первых, он deprecated, во вторых - после наших изменений код проходит только через метод login(ConsulLoginCredentials credentials) и нокогда через старый login (тут я могу ошибаться надо проверить). - // нам это даст возможность не передавать m2mTokenSupplier в клиент и немного упростить com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver.loadConfigProperties - // но это только предложение, надо его проанализировать @Override public ConsulClientResponse login(String authMethod) { Map payload = new HashMap<>(); @@ -68,19 +65,20 @@ public ConsulClientResponse login(String authMethod) { @Override public ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOException { Map payload = new HashMap<>(); - payload.put(AUTH_METHOD_FIELD, credentials.authMethod()); - payload.put(BEARER_TOKEN_FIELD, credentials.bearerToken()); + payload.put(AUTH_METHOD_FIELD, credentials.getAuthMethod()); + payload.put(BEARER_TOKEN_FIELD, credentials.getBearerToken()); String json = new Gson().toJson(payload); Map> headers = new HashMap<>(); headers.put(CONTENT_TYPE, Collections.singletonList(APPLICATION_JSON)); - log.debug("Perform login to {} with {} auth method", consulAddr, credentials.authMethod()); + log.debug("Perform login to {} with {} auth method", consulAddr, credentials.getAuthMethod()); RestClientResponseEntity response; try { response = client.doRequest(consulAddr + V1_ACL_LOGIN, HttpMethod.POST, headers, json, String.class); } catch (MicroserviceRestClientException e) { - //todo vlla добавить комментарий, зачем здесь сделан выброс эксепшена + // Retries and their backoff act on IOException only, so a transport failure must reach the caller as one: + // otherwise a single dropped connection sticks the pod to the m2m auth method until it restarts. throw new IOException("can not perform login to consul: " + e.getMessage(), e); } return new ConsulClientResponse(response.getResponseBody(), response.getHttpStatus()); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentialsTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentialsTest.java index 8ac1da4a1d..be3ef66b4c 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentialsTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentialsTest.java @@ -35,7 +35,7 @@ private static void writeToken(String audience, String value) throws IOException @Test void authMethodIsTheGivenName() { KubernetesLoginCredentials credentials = new KubernetesLoginCredentials("core-k8s", AudienceName.NETCRACKER); - assertEquals("core-k8s", credentials.authMethod()); + assertEquals("core-k8s", credentials.getAuthMethod()); } @Test @@ -43,10 +43,10 @@ void bearerTokenIsReadOnEveryCallAndNotCached() throws IOException { writeToken(AudienceName.NETCRACKER, "first-projected-token"); KubernetesLoginCredentials credentials = new KubernetesLoginCredentials("core-k8s", AudienceName.NETCRACKER); - assertEquals("first-projected-token", credentials.bearerToken()); + assertEquals("first-projected-token", credentials.getBearerToken()); writeToken(AudienceName.NETCRACKER, "rotated-projected-token"); - assertEquals("rotated-projected-token", credentials.bearerToken()); + assertEquals("rotated-projected-token", credentials.getBearerToken()); } @Test @@ -54,7 +54,7 @@ void bearerTokenFailsWithRuntimeExceptionWhenTokenFileIsUnreadable() throws IOEx Files.createDirectories(tokensDir.resolve(AudienceName.MAAS).resolve("token")); KubernetesLoginCredentials credentials = new KubernetesLoginCredentials("core-k8s", AudienceName.MAAS); - RuntimeException thrown = assertThrows(RuntimeException.class, credentials::bearerToken); + RuntimeException thrown = assertThrows(RuntimeException.class, credentials::getBearerToken); assertInstanceOf(IOException.class, thrown.getCause()); } @@ -62,6 +62,6 @@ void bearerTokenFailsWithRuntimeExceptionWhenTokenFileIsUnreadable() throws IOEx void bearerTokenFailsWithIllegalArgumentExceptionForUnknownAudience() { KubernetesLoginCredentials credentials = new KubernetesLoginCredentials("core-k8s", "no-such-audience"); - assertThrows(IllegalArgumentException.class, credentials::bearerToken); + assertThrows(IllegalArgumentException.class, credentials::getBearerToken); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentialsTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentialsTest.java index d4e1cfc86e..c15764595e 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentialsTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentialsTest.java @@ -12,7 +12,7 @@ class M2MLoginCredentialsTest { @Test void authMethodIsNamespace() { M2MLoginCredentials credentials = new M2MLoginCredentials("test-namespace", () -> "test-m2m-token"); - assertEquals("test-namespace", credentials.authMethod()); + assertEquals("test-namespace", credentials.getAuthMethod()); } @Test @@ -22,8 +22,8 @@ void bearerTokenAsksSupplierOnEveryCall() { M2MLoginCredentials credentials = new M2MLoginCredentials("test-namespace", supplier); - assertEquals("test-m2m-token-1", credentials.bearerToken()); - assertEquals("test-m2m-token-2", credentials.bearerToken()); + assertEquals("test-m2m-token-1", credentials.getBearerToken()); + assertEquals("test-m2m-token-2", credentials.getBearerToken()); assertEquals(2, calls.get()); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java index ea1369be82..c371f8068e 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java @@ -51,26 +51,26 @@ void cannotBuildWithoutUrlOrNamespaceOrM2MSupplier() { } @Test - void modeIsAutoWhenNotGiven() { + void modeIsKubernetesWithM2MFallbackWhenNotGiven() { TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() .consulUrl(CONSUL_URL) .namespace(NAMESPACE) .m2mSupplier(() -> "token") .build(); - Assertions.assertEquals(ConsulLoginMode.AUTO, opts.mode); + Assertions.assertEquals(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK, opts.mode); } @Test - void autoModeNamesTheMissingInputAndTheMode() { + void fallbackModeNamesTheMissingInputAndTheMode() { IllegalArgumentException thrown = Assertions.assertThrows(IllegalArgumentException.class, () -> new TokenStorageFactory.CreateOptions.Builder() .consulUrl(CONSUL_URL) - .mode(ConsulLoginMode.AUTO) + .mode(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK) .build()); Assertions.assertTrue(thrown.getMessage().contains("namespace"), thrown.getMessage()); - Assertions.assertTrue(thrown.getMessage().contains("auto"), thrown.getMessage()); + Assertions.assertTrue(thrown.getMessage().contains("kubernetes-with-m2m-fallback"), thrown.getMessage()); } @Test @@ -171,16 +171,16 @@ void kubernetesModeBuildsTokenProviderOverKubernetesCredentials() throws IOExcep Assertions.assertInstanceOf(TokenProvider.class, login); ConsulLoginCredentials used = credentialsUsedBy(login, client); Assertions.assertInstanceOf(KubernetesLoginCredentials.class, used); - Assertions.assertEquals("core-k8s", used.authMethod()); + Assertions.assertEquals("core-k8s", used.getAuthMethod()); Assertions.assertEquals(0, m2mCalls.get()); } @Test - void autoModeBuildsProbingLoginOverBothWays() throws IOException { + void fallbackModeBuildsProbingLoginOverBothWays() throws IOException { ConsulClient client = mock(ConsulClient.class); TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() .consulUrl(CONSUL_URL) - .mode(ConsulLoginMode.AUTO) + .mode(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK) .namespace(NAMESPACE) .authMethod("core-k8s") .m2mSupplier(() -> "token") @@ -193,11 +193,11 @@ void autoModeBuildsProbingLoginOverBothWays() throws IOException { } @Test - void autoModeFallsBackToM2MCredentials() throws IOException { + void fallbackModeFallsBackToM2MCredentials() throws IOException { ConsulClient client = mock(ConsulClient.class); TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() .consulUrl(CONSUL_URL) - .mode(ConsulLoginMode.AUTO) + .mode(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK) .namespace(NAMESPACE) .m2mSupplier(() -> "token") .build(); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java index 9eabd2d79a..08b6e92a47 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java @@ -170,7 +170,7 @@ void delayKeepsLowerBoundWhenClockRanAhead() throws IOException { } @Test - void retryWaitsConfiguredPauseBetweenAttempts() throws IOException { + void retryWaitsBackoffDelayBetweenAttempts() throws IOException { Duration retryPause = Duration.ofMillis(200); OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); when(consulLogin.perform()) @@ -185,8 +185,9 @@ void retryWaitsConfiguredPauseBetweenAttempts() throws IOException { }, ""); long elapsed = System.nanoTime() - startedAt; + long lowestJitteredDelay = (long) (retryPause.toNanos() * (1 - LoginRetryPolicies.JITTER)); verify(consulLogin, times(2)).perform(); - Assertions.assertTrue(elapsed >= retryPause.toNanos(), - "expected at least one pause of " + retryPause + " between retries"); + Assertions.assertTrue(elapsed >= lowestJitteredDelay, + "expected a backoff delay of at least " + lowestJitteredDelay + " ns between retries, got " + elapsed); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulClientDefaultLoginTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulClientDefaultLoginTest.java index ddb17f5ca2..f7815fe17b 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulClientDefaultLoginTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulClientDefaultLoginTest.java @@ -28,12 +28,12 @@ public ConsulClientResponse login(String authMethod) { private static ConsulLoginCredentials credentials(String authMethod, String bearerToken) { return new ConsulLoginCredentials() { @Override - public String authMethod() { + public String getAuthMethod() { return authMethod; } @Override - public String bearerToken() { + public String getBearerToken() { return bearerToken; } }; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClientTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClientTest.java index 0f11083a1a..a3ef99b5fb 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClientTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClientTest.java @@ -110,12 +110,12 @@ void loginSuccessTest() { private static ConsulLoginCredentials credentials(String authMethod, String bearerToken) { return new ConsulLoginCredentials() { @Override - public String authMethod() { + public String getAuthMethod() { return authMethod; } @Override - public String bearerToken() { + public String getBearerToken() { return bearerToken; } }; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClientTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClientTest.java index 986b6d189f..5cf9f46d46 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClientTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClientTest.java @@ -69,12 +69,12 @@ void loginSuccess() { private static ConsulLoginCredentials credentials(String authMethod, String bearerToken) { return new ConsulLoginCredentials() { @Override - public String authMethod() { + public String getAuthMethod() { return authMethod; } @Override - public String bearerToken() { + public String getBearerToken() { return bearerToken; } }; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java new file mode 100644 index 0000000000..843bcec4e3 --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java @@ -0,0 +1,43 @@ +package com.netcracker.cloud.consul.provider.spring.common.config; + +import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Inputs of the Consul ACL token login. Every field stays {@code null} when the property is absent: the defaults + * belong to {@code TokenStorageFactory.CreateOptions.Builder}, so that an external caller of the builder gets them + * too. + */ +@ConfigurationProperties(prefix = ConsulLoginProperties.PREFIX) +public class ConsulLoginProperties { + + public static final String PREFIX = "spring.cloud.consul.config.login"; + + private ConsulLoginMode mode; + private String authMethod; + private String audience; + + public ConsulLoginMode getMode() { + return mode; + } + + public void setMode(ConsulLoginMode mode) { + this.mode = mode; + } + + public String getAuthMethod() { + return authMethod; + } + + public void setAuthMethod(String authMethod) { + this.authMethod = authMethod; + } + + public String getAudience() { + return audience; + } + + public void setAudience(String audience) { + this.audience = audience; + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java index 12f2e0ecfa..e764138d48 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java @@ -31,9 +31,6 @@ public abstract class ConsulM2MConfigDataLocationResolver extends ConsulConfigDa public static final String PROP_CLOUD_NAMESPACE = "cloud.microservice.namespace"; public static final String PROP_CONSUL_M2M_ENABLED = "spring.cloud.consul.config.m2m.enabled"; - public static final String PROP_LOGIN_MODE = "spring.cloud.consul.config.login.mode"; - public static final String PROP_LOGIN_AUTH_METHOD = "spring.cloud.consul.config.login.auth-method"; - public static final String PROP_LOGIN_AUDIENCE = "spring.cloud.consul.config.login.audience"; static final String ENV_NAMESPACE = "NAMESPACE"; static final String ENV_CLOUD_NAMESPACE = "CLOUD_NAMESPACE"; @@ -54,17 +51,18 @@ protected ConsulConfigProperties loadConfigProperties(ConfigDataLocationResolver } ConsulProperties properties = resolverContext.getBootstrapContext().get(ConsulProperties.class); try { - ConsulLoginMode mode = binder.bind(PROP_LOGIN_MODE, ConsulLoginMode.class).orElse(null); + ConsulLoginProperties login = binder.bind(ConsulLoginProperties.PREFIX, ConsulLoginProperties.class) + .orElseGet(ConsulLoginProperties::new); Supplier m2mTokenSupplier = () -> resolverContext.getBootstrapContext().get(M2MManager.class).getToken().getTokenValue(); ConsulRestClient client = createConsulRestClient(Utils.formatConsulAddress(properties), m2mTokenSupplier); TokenStorageFactory.CreateOptions.Builder options = new TokenStorageFactory.CreateOptions.Builder() .consulUrl(Utils.formatConsulAddress(properties)) - .mode(mode) - .authMethod(binder.bind(PROP_LOGIN_AUTH_METHOD, String.class).orElse(null)) - .audience(binder.bind(PROP_LOGIN_AUDIENCE, String.class).orElse(null)); - if (mode != ConsulLoginMode.KUBERNETES) { + .mode(login.getMode()) + .authMethod(login.getAuthMethod()) + .audience(login.getAudience()); + if (login.getMode() != ConsulLoginMode.KUBERNETES) { options.namespace(getPropsOrEnvsMust(args(PROP_CLOUD_NAMESPACE), args(ENV_NAMESPACE, ENV_CLOUD_NAMESPACE))) .m2mSupplier(m2mTokenSupplier); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolverTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolverTest.java index 8b390716db..200037a440 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolverTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolverTest.java @@ -17,6 +17,7 @@ import org.springframework.boot.bootstrap.DefaultBootstrapContext; import org.springframework.boot.bootstrap.BootstrapRegistry; import org.springframework.boot.context.config.ConfigDataLocationResolverContext; +import org.springframework.boot.context.properties.bind.BindException; import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; import org.springframework.boot.logging.DeferredLogFactory; @@ -28,8 +29,6 @@ import java.util.function.Supplier; import static com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver.PROP_CLOUD_NAMESPACE; import static com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver.PROP_CONSUL_M2M_ENABLED; -import static com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver.PROP_LOGIN_AUTH_METHOD; -import static com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver.PROP_LOGIN_MODE; import static com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver.args; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -67,6 +66,8 @@ void getPropsOrEnvsMust_envs() { private static final String SECRET_ID = "test-secret-id"; + private static final String PROP_LOGIN_MODE = ConsulLoginProperties.PREFIX + ".mode"; + private static final String PROP_LOGIN_AUTH_METHOD = ConsulLoginProperties.PREFIX + ".auth-method"; private final Map properties = new HashMap<>(); private final AtomicInteger m2mLookups = new AtomicInteger(); @@ -114,11 +115,11 @@ void init() throws IOException { consulRestClient = Mockito.mock(ConsulRestClient.class); Mockito.when(consulRestClient.login(Mockito.any(ConsulLoginCredentials.class))).thenAnswer(invocation -> { ConsulLoginCredentials credentials = invocation.getArgument(0); - if (rejectedAuthMethod != null && rejectedAuthMethod.equals(credentials.authMethod())) { + if (rejectedAuthMethod != null && rejectedAuthMethod.equals(credentials.getAuthMethod())) { throw new IOException("consul auth method is not ready: response code=403; body='ACL not found'"); } if (clientReadsBearerToken) { - credentials.bearerToken(); + credentials.getBearerToken(); } return new ConsulClientResponse("{\"SecretID\":\"" + SECRET_ID + "\"}", 200); }); @@ -158,8 +159,8 @@ void kubernetesModeWritesSecretIdWithoutTouchingTheBootstrapRegistry() { } @Test - void autoModeLeavesTheRegistryAloneWhileTheNewWayWorks() { - properties.put(PROP_LOGIN_MODE, "auto"); + void fallbackModeLeavesTheRegistryAloneWhileTheNewWayWorks() { + properties.put(PROP_LOGIN_MODE, "kubernetes-with-m2m-fallback"); properties.put(PROP_LOGIN_AUTH_METHOD, "core-k8s"); ConsulConfigProperties resolved = resolve(); @@ -169,8 +170,8 @@ void autoModeLeavesTheRegistryAloneWhileTheNewWayWorks() { } @Test - void autoModeAsksTheRegistryOnlyWhenBearerTokenIsNeeded() { - properties.put(PROP_LOGIN_MODE, "auto"); + void fallbackModeAsksTheRegistryOnlyWhenBearerTokenIsNeeded() { + properties.put(PROP_LOGIN_MODE, "kubernetes-with-m2m-fallback"); properties.put(PROP_LOGIN_AUTH_METHOD, "core-k8s"); rejectedAuthMethod = "core-k8s"; clientReadsBearerToken = true; @@ -191,4 +192,24 @@ void disabledM2MSkipsTheLoginAltogether() throws IOException { Assertions.assertEquals(0, m2mLookups.get()); Mockito.verify(consulRestClient, Mockito.never()).login(Mockito.any(ConsulLoginCredentials.class)); } + + @Test + void m2mModeSkipsTheNewWayEntirely() { + properties.put(PROP_LOGIN_MODE, "m2m"); + clientReadsBearerToken = true; + + ConsulConfigProperties resolved = resolve(); + + Assertions.assertEquals(SECRET_ID, resolved.getAclToken()); + Assertions.assertEquals(1, m2mLookups.get()); + } + + @Test + void unknownModeBreaksTheBinding() { + properties.put(PROP_LOGIN_MODE, "cloud-foundry"); + + Assertions.assertThrows(BindException.class, () -> + new Binder(new MapConfigurationPropertySource(properties)) + .bind(ConsulLoginProperties.PREFIX, ConsulLoginProperties.class)); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java index b051a5337c..f643ade494 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java @@ -1,10 +1,9 @@ package com.netcracker.cloud.consul.provider.spring.resttemplate.config; -import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; import com.netcracker.cloud.consul.provider.common.TokenStorage; import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; import com.netcracker.cloud.consul.provider.spring.common.SpringTokenStorageFactory; -import com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver; +import com.netcracker.cloud.consul.provider.spring.common.config.ConsulLoginProperties; import com.netcracker.cloud.consul.provider.spring.common.Utils; import com.netcracker.cloud.restclient.resttemplate.MicroserviceRestTemplate; import com.netcracker.cloud.security.core.auth.DummyM2MManager; @@ -13,51 +12,43 @@ import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.bind.Binder; import org.springframework.cloud.consul.ConditionalOnConsulEnabled; import org.springframework.cloud.consul.ConsulProperties; import org.springframework.cloud.consul.config.ConsulConfigProperties; import org.springframework.context.annotation.Bean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; @Configuration +@EnableConfigurationProperties(ConsulLoginProperties.class) @ConditionalOnConsulEnabled //@EnableM2MManager // TODO why it is commented out? @ConditionalOnProperty(value = "spring.cloud.consul.config.m2m.enabled", havingValue = "true", matchIfMissing = true) public class ConsulM2MRestTemplateAutoConfiguration { private final static Logger LOGGER = LoggerFactory.getLogger(ConsulM2MRestTemplateAutoConfiguration.class); - // todo vlla эм, а нафига их дублировать? - public static final String PROP_LOGIN_MODE = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_MODE; - public static final String PROP_LOGIN_AUTH_METHOD = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_AUTH_METHOD; - public static final String PROP_LOGIN_AUDIENCE = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_AUDIENCE; - @Bean public TokenStorage consulTokenStorageViaM2MRestTemplate(ConsulConfigProperties consulConfigProperties, ConsulProperties consulProperties, M2MManager m2MManager, - Environment environment) { + ConsulLoginProperties loginProperties) { TokenStorageFactory factory = new SpringTokenStorageFactory(consulConfigProperties, new MicroserviceRestTemplate()); - return factory.create(createOptions(environment, consulProperties, m2MManager, System.getenv("NAMESPACE"))); + return factory.create(createOptions(loginProperties, consulProperties, m2MManager, System.getenv("NAMESPACE"))); } - static TokenStorageFactory.CreateOptions createOptions(Environment environment, + static TokenStorageFactory.CreateOptions createOptions(ConsulLoginProperties loginProperties, ConsulProperties consulProperties, M2MManager m2MManager, String namespace) { - Binder binder = Binder.get(environment); - return new TokenStorageFactory.CreateOptions.Builder() .consulUrl(Utils.formatConsulAddress(consulProperties)) .namespace(namespace) .m2mSupplier(() -> m2MManager.getToken().getTokenValue()) - //todo vlla это же бин, мы не можем просто заинжектить значения этих пропертей? Через биндер же очень низкоуровнево? Или есть причины, почему надо именно через биндер? - .mode(binder.bind(PROP_LOGIN_MODE, ConsulLoginMode.class).orElse(null)) - .authMethod(binder.bind(PROP_LOGIN_AUTH_METHOD, String.class).orElse(null)) - .audience(binder.bind(PROP_LOGIN_AUDIENCE, String.class).orElse(null)) + .mode(loginProperties.getMode()) + .authMethod(loginProperties.getAuthMethod()) + .audience(loginProperties.getAudience()) .build(); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java index 2d3e4d7212..3282f0f57b 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java @@ -2,36 +2,23 @@ import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; +import com.netcracker.cloud.consul.provider.spring.common.config.ConsulLoginProperties; import com.netcracker.cloud.security.core.auth.M2MManager; import com.netcracker.cloud.security.core.utils.k8s.AudienceName; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import org.springframework.boot.context.properties.bind.BindException; import org.springframework.cloud.consul.ConsulProperties; -import org.springframework.core.env.MapPropertySource; -import org.springframework.core.env.StandardEnvironment; - -import java.util.HashMap; -import java.util.Map; - -import static com.netcracker.cloud.consul.provider.spring.resttemplate.config.ConsulM2MRestTemplateAutoConfiguration.PROP_LOGIN_AUDIENCE; -import static com.netcracker.cloud.consul.provider.spring.resttemplate.config.ConsulM2MRestTemplateAutoConfiguration.PROP_LOGIN_AUTH_METHOD; -import static com.netcracker.cloud.consul.provider.spring.resttemplate.config.ConsulM2MRestTemplateAutoConfiguration.PROP_LOGIN_MODE; class ConsulM2MRestTemplateAutoConfigurationTest { - private final Map properties = new HashMap<>(); - private StandardEnvironment environment; + private final ConsulLoginProperties loginProperties = new ConsulLoginProperties(); private ConsulProperties consulProperties; private M2MManager m2MManager; @BeforeEach void init() { - environment = new StandardEnvironment(); - environment.getPropertySources().addFirst(new MapPropertySource("test", properties)); - consulProperties = new ConsulProperties(); consulProperties.setHost("consul"); consulProperties.setPort(8500); @@ -40,17 +27,17 @@ void init() { } private TokenStorageFactory.CreateOptions options() { - return ConsulM2MRestTemplateAutoConfiguration.createOptions(environment, consulProperties, m2MManager, "ns"); + return ConsulM2MRestTemplateAutoConfiguration.createOptions(loginProperties, consulProperties, m2MManager, "ns"); } @Test - void modeIsAutoWhenNothingIsConfigured() { - Assertions.assertEquals(ConsulLoginMode.AUTO, options().getMode()); + void modeIsKubernetesWithM2MFallbackWhenNothingIsConfigured() { + Assertions.assertEquals(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK, options().getMode()); } @Test void kubernetesModeReachesCreateOptionsAndNeedsNoM2MManager() { - properties.put(PROP_LOGIN_MODE, "kubernetes"); + loginProperties.setMode(ConsulLoginMode.KUBERNETES); TokenStorageFactory.CreateOptions opts = options(); @@ -59,15 +46,15 @@ void kubernetesModeReachesCreateOptionsAndNeedsNoM2MManager() { } @Test - void unknownModeBreaksTheBean() { - properties.put(PROP_LOGIN_MODE, "cloud-foundry"); + void m2mModeReachesCreateOptions() { + loginProperties.setMode(ConsulLoginMode.M2M); - Assertions.assertThrows(BindException.class, this::options); + Assertions.assertEquals(ConsulLoginMode.M2M, options().getMode()); } @Test void authMethodAndAudienceDefaultsComeFromTheBuilder() { - properties.put(PROP_LOGIN_MODE, "kubernetes"); + loginProperties.setMode(ConsulLoginMode.KUBERNETES); TokenStorageFactory.CreateOptions opts = options(); @@ -77,9 +64,9 @@ void authMethodAndAudienceDefaultsComeFromTheBuilder() { @Test void authMethodAndAudienceAreReadFromTheConfiguration() { - properties.put(PROP_LOGIN_MODE, "kubernetes"); - properties.put(PROP_LOGIN_AUTH_METHOD, "core-k8s"); - properties.put(PROP_LOGIN_AUDIENCE, AudienceName.DBAAS); + loginProperties.setMode(ConsulLoginMode.KUBERNETES); + loginProperties.setAuthMethod("core-k8s"); + loginProperties.setAudience(AudienceName.DBAAS); TokenStorageFactory.CreateOptions opts = options(); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java index bdbdf1e9a5..8915caf3e7 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java @@ -1,57 +1,50 @@ package com.netcracker.cloud.consul.provider.spring.webclient.config; -import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; import com.netcracker.cloud.consul.provider.common.TokenStorage; import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; import com.netcracker.cloud.consul.provider.spring.common.SpringTokenStorageFactory; -import com.netcracker.cloud.consul.provider.spring.common.config.ConsulM2MConfigDataLocationResolver; +import com.netcracker.cloud.consul.provider.spring.common.config.ConsulLoginProperties; import com.netcracker.cloud.consul.provider.spring.common.Utils; import com.netcracker.cloud.restclient.webclient.MicroserviceWebClient; import com.netcracker.cloud.security.common.reactive.DummyM2MManager; import com.netcracker.cloud.security.common.reactive.M2MManager; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.bind.Binder; import org.springframework.cloud.consul.ConditionalOnConsulEnabled; import org.springframework.cloud.consul.ConsulProperties; import org.springframework.cloud.consul.config.ConsulConfigProperties; import org.springframework.context.annotation.Bean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; @Configuration +@EnableConfigurationProperties(ConsulLoginProperties.class) @ConditionalOnConsulEnabled //@EnableReactiveM2MManager @ConditionalOnProperty(value = "spring.cloud.consul.config.m2m.enabled", havingValue = "true", matchIfMissing = true) public class ConsulM2MWebClientAutoConfiguration { - public static final String PROP_LOGIN_MODE = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_MODE; - public static final String PROP_LOGIN_AUTH_METHOD = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_AUTH_METHOD; - public static final String PROP_LOGIN_AUDIENCE = ConsulM2MConfigDataLocationResolver.PROP_LOGIN_AUDIENCE; - @Bean public TokenStorage consulTokenStorageViaM2MWebClient(ConsulConfigProperties consulConfigProperties, ConsulProperties consulProperties, M2MManager m2MManager, - Environment environment) { + ConsulLoginProperties loginProperties) { TokenStorageFactory factory = new SpringTokenStorageFactory(consulConfigProperties, new MicroserviceWebClient()); - return factory.create(createOptions(environment, consulProperties, m2MManager, System.getenv("NAMESPACE"))); + return factory.create(createOptions(loginProperties, consulProperties, m2MManager, System.getenv("NAMESPACE"))); } - static TokenStorageFactory.CreateOptions createOptions(Environment environment, + static TokenStorageFactory.CreateOptions createOptions(ConsulLoginProperties loginProperties, ConsulProperties consulProperties, M2MManager m2MManager, String namespace) { - Binder binder = Binder.get(environment); - return new TokenStorageFactory.CreateOptions.Builder() .consulUrl(Utils.formatConsulAddress(consulProperties)) .namespace(namespace) .m2mSupplier(() -> m2MManager.getToken().block().getTokenValue()) - .mode(binder.bind(PROP_LOGIN_MODE, ConsulLoginMode.class).orElse(null)) - .authMethod(binder.bind(PROP_LOGIN_AUTH_METHOD, String.class).orElse(null)) - .audience(binder.bind(PROP_LOGIN_AUDIENCE, String.class).orElse(null)) + .mode(loginProperties.getMode()) + .authMethod(loginProperties.getAuthMethod()) + .audience(loginProperties.getAudience()) .build(); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java index c741d6c4af..43a8637336 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java @@ -2,36 +2,23 @@ import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; +import com.netcracker.cloud.consul.provider.spring.common.config.ConsulLoginProperties; import com.netcracker.cloud.security.common.reactive.M2MManager; import com.netcracker.cloud.security.core.utils.k8s.AudienceName; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import org.springframework.boot.context.properties.bind.BindException; import org.springframework.cloud.consul.ConsulProperties; -import org.springframework.core.env.MapPropertySource; -import org.springframework.core.env.StandardEnvironment; - -import java.util.HashMap; -import java.util.Map; - -import static com.netcracker.cloud.consul.provider.spring.webclient.config.ConsulM2MWebClientAutoConfiguration.PROP_LOGIN_AUDIENCE; -import static com.netcracker.cloud.consul.provider.spring.webclient.config.ConsulM2MWebClientAutoConfiguration.PROP_LOGIN_AUTH_METHOD; -import static com.netcracker.cloud.consul.provider.spring.webclient.config.ConsulM2MWebClientAutoConfiguration.PROP_LOGIN_MODE; class ConsulM2MWebClientAutoConfigurationTest { - private final Map properties = new HashMap<>(); - private StandardEnvironment environment; + private final ConsulLoginProperties loginProperties = new ConsulLoginProperties(); private ConsulProperties consulProperties; private M2MManager m2MManager; @BeforeEach void init() { - environment = new StandardEnvironment(); - environment.getPropertySources().addFirst(new MapPropertySource("test", properties)); - consulProperties = new ConsulProperties(); consulProperties.setHost("consul"); consulProperties.setPort(8500); @@ -40,17 +27,17 @@ void init() { } private TokenStorageFactory.CreateOptions options() { - return ConsulM2MWebClientAutoConfiguration.createOptions(environment, consulProperties, m2MManager, "ns"); + return ConsulM2MWebClientAutoConfiguration.createOptions(loginProperties, consulProperties, m2MManager, "ns"); } @Test - void modeIsAutoWhenNothingIsConfigured() { - Assertions.assertEquals(ConsulLoginMode.AUTO, options().getMode()); + void modeIsKubernetesWithM2MFallbackWhenNothingIsConfigured() { + Assertions.assertEquals(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK, options().getMode()); } @Test void kubernetesModeReachesCreateOptionsAndNeedsNoM2MManager() { - properties.put(PROP_LOGIN_MODE, "kubernetes"); + loginProperties.setMode(ConsulLoginMode.KUBERNETES); TokenStorageFactory.CreateOptions opts = options(); @@ -59,15 +46,15 @@ void kubernetesModeReachesCreateOptionsAndNeedsNoM2MManager() { } @Test - void unknownModeBreaksTheBean() { - properties.put(PROP_LOGIN_MODE, "cloud-foundry"); + void m2mModeReachesCreateOptions() { + loginProperties.setMode(ConsulLoginMode.M2M); - Assertions.assertThrows(BindException.class, this::options); + Assertions.assertEquals(ConsulLoginMode.M2M, options().getMode()); } @Test void authMethodAndAudienceDefaultsComeFromTheBuilder() { - properties.put(PROP_LOGIN_MODE, "kubernetes"); + loginProperties.setMode(ConsulLoginMode.KUBERNETES); TokenStorageFactory.CreateOptions opts = options(); @@ -77,9 +64,9 @@ void authMethodAndAudienceDefaultsComeFromTheBuilder() { @Test void authMethodAndAudienceAreReadFromTheConfiguration() { - properties.put(PROP_LOGIN_MODE, "kubernetes"); - properties.put(PROP_LOGIN_AUTH_METHOD, "core-k8s"); - properties.put(PROP_LOGIN_AUDIENCE, AudienceName.DBAAS); + loginProperties.setMode(ConsulLoginMode.KUBERNETES); + loginProperties.setAuthMethod("core-k8s"); + loginProperties.setAudience(AudienceName.DBAAS); TokenStorageFactory.CreateOptions opts = options(); From 72470240bc5efab535c688df7d0b5cdfed35221d Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Thu, 27 Aug 2026 12:01:32 +0400 Subject: [PATCH 05/20] refactor(consul): name the login types after what they provide ConsulLogin read as a verb phrase and told the reader nothing about what the type holds: - ConsulLogin becomes ConsulTokenProvider, and perform becomes getToken, the name already used for a token fetch that goes over the network in M2MManager and KubernetesAudienceToken - TokenProvider becomes LoginTokenProvider: it obtains a token by performing a Consul login with one set of credentials - ProbingConsulLogin becomes KubernetesWithM2MFallbackTokenProvider, matching the name of the mode it serves, and its fields are named after the two ways they hold, so the class no longer mixes generic field names with hardcoded log constants The type stays deleted whole once the m2m way goes away, so a generic fallback decorator would carry generality nothing uses. --- .../consul/provider/common/ConsulLogin.java | 10 --- .../provider/common/ConsulTokenProvider.java | 8 ++ ...ubernetesWithM2MFallbackTokenProvider.java | 76 ++++++++++++++++ ...nProvider.java => LoginTokenProvider.java} | 8 +- .../provider/common/ProbingConsulLogin.java | 79 ---------------- .../provider/common/TokenStorageFactory.java | 16 ++-- .../consul/provider/common/TokenUpdater.java | 14 +-- ...etesWithM2MFallbackTokenProviderTest.java} | 90 +++++++++---------- ...rTest.java => LoginTokenProviderTest.java} | 16 ++-- .../common/TokenStorageFactoryTest.java | 18 ++-- .../provider/common/TokenUpdaterTest.java | 28 +++--- .../ConsulM2MConfigDataLocationResolver.java | 2 +- 12 files changed, 180 insertions(+), 185 deletions(-) delete mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLogin.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java rename core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/{TokenProvider.java => LoginTokenProvider.java} (84%) delete mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java rename core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/{ProbingConsulLoginTest.java => KubernetesWithM2MFallbackTokenProviderTest.java} (63%) rename core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/{TokenProviderTest.java => LoginTokenProviderTest.java} (90%) diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLogin.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLogin.java deleted file mode 100644 index bb859cec6a..0000000000 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLogin.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.netcracker.cloud.consul.provider.common; - -import java.io.IOException; - -//todo vlla мне не очень нравится нейминг этого класса и метода perform, а так же связка классов ProbingConsulLogin, ConsulLogin и TokenProvider. ProbingConsulLogin одновременно и наследник ConsulLogin, и аггрегирует в себе несколько других ConsulLogin. Думаю, мы тут можем подумать, какой паттенр лучше всего подходимт под эту ситуацию и можем сделать некоторый рефакторинг. Сначала проанализируй и предложи варианты, не исправляй сразу. -//возможно, достаточно просто переименовать ConsulLogin в ConsulTokenProvider, а сам старый TokenProvider во что-то еще -public interface ConsulLogin { - - Token perform() throws IOException; -} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java new file mode 100644 index 0000000000..a3522dc972 --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java @@ -0,0 +1,8 @@ +package com.netcracker.cloud.consul.provider.common; + +import java.io.IOException; + +public interface ConsulTokenProvider { + + Token getToken() throws IOException; +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java new file mode 100644 index 0000000000..4fd53b9084 --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java @@ -0,0 +1,76 @@ +package com.netcracker.cloud.consul.provider.common; + +import net.jodah.failsafe.Failsafe; +import net.jodah.failsafe.FailsafeException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; + +final class KubernetesWithM2MFallbackTokenProvider implements ConsulTokenProvider { + + static final int PROBE_TRIES = 3; + static final int ERROR_MESSAGE_LIMIT = 512; + + private static final Duration DEFAULT_PROBE_PAUSE = Duration.ofSeconds(1); + private static final String KUBERNETES_WAY = "kubernetes"; + private static final String M2M_WAY = "m2m"; + + private static final Logger log = LoggerFactory.getLogger(KubernetesWithM2MFallbackTokenProvider.class); + + private final ConsulTokenProvider kubernetesProvider; + private final ConsulTokenProvider m2mProvider; + private final int tries; + private final Duration probeDelay; + + private volatile ConsulTokenProvider chosen; + + KubernetesWithM2MFallbackTokenProvider(ConsulTokenProvider kubernetesProvider, ConsulTokenProvider m2mProvider) { + this(kubernetesProvider, m2mProvider, PROBE_TRIES, DEFAULT_PROBE_PAUSE); + } + + KubernetesWithM2MFallbackTokenProvider(ConsulTokenProvider kubernetesProvider, ConsulTokenProvider m2mProvider, int tries, Duration probeDelay) { + this.kubernetesProvider = kubernetesProvider; + this.m2mProvider = m2mProvider; + this.tries = tries; + this.probeDelay = probeDelay; + } + + @Override + public synchronized Token getToken() throws IOException { + if (chosen != null) { + return chosen.getToken(); + } + try { + Token token = probe(); + chosen = kubernetesProvider; + log.info("Consul ACL token is obtained by the {} auth method", KUBERNETES_WAY); + return token; + } catch (Exception e) { + chosen = m2mProvider; + log.info("Consul login by the {} auth method failed, falling back to the {} one until the pod restarts: {}", + KUBERNETES_WAY, M2M_WAY, describe(e)); + return m2mProvider.getToken(); + } + } + + private Token probe() throws IOException { + try { + return Failsafe.with(LoginRetryPolicies.onTransportFailure(tries, probeDelay) + .onFailedAttempt(event -> log.debug("Failed probe attempt {} of the {} auth method", + event.getAttemptCount(), KUBERNETES_WAY, event.getLastFailure()))) + .get(kubernetesProvider::getToken); + } catch (FailsafeException e) { + throw (IOException) e.getCause(); + } + } + + private static String describe(Exception e) { + String message = e.getMessage() == null ? "" : e.getMessage(); + if (message.length() > ERROR_MESSAGE_LIMIT) { + message = message.substring(0, ERROR_MESSAGE_LIMIT) + "..."; + } + return e.getClass().getSimpleName() + ": " + message; + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java similarity index 84% rename from core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenProvider.java rename to core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java index a86b7aa9ab..c88863d51e 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java @@ -10,20 +10,20 @@ import java.io.IOException; import java.time.OffsetDateTime; -public class TokenProvider implements ConsulLogin { +public class LoginTokenProvider implements ConsulTokenProvider { - private static final Logger log = LoggerFactory.getLogger(TokenProvider.class); + private static final Logger log = LoggerFactory.getLogger(LoginTokenProvider.class); private final ConsulClient client; private final ConsulLoginCredentials credentials; - public TokenProvider(ConsulClient client, ConsulLoginCredentials credentials) { + public LoginTokenProvider(ConsulClient client, ConsulLoginCredentials credentials) { this.client = client; this.credentials = credentials; } @Override - public Token perform() throws IOException { + public Token getToken() throws IOException { ConsulClientResponse response = client.login(credentials); String responseBody = response.getBodyJson(); if (responseBody == null || responseBody.isEmpty()) { diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java deleted file mode 100644 index 7e3957dbd0..0000000000 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLogin.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.netcracker.cloud.consul.provider.common; - -import net.jodah.failsafe.Failsafe; -import net.jodah.failsafe.FailsafeException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.time.Duration; - -final class ProbingConsulLogin implements ConsulLogin { - - static final int PROBE_TRIES = 3; - static final int ERROR_MESSAGE_LIMIT = 512; - - private static final Duration DEFAULT_PROBE_PAUSE = Duration.ofSeconds(1); - private static final String KUBERNETES_WAY = "kubernetes"; - private static final String M2M_WAY = "m2m"; - - private static final Logger log = LoggerFactory.getLogger(ProbingConsulLogin.class); - - private final ConsulLogin probed; - private final ConsulLogin fallback; - private final int tries; - private final Duration probePause; - - private volatile ConsulLogin chosen; - - ProbingConsulLogin(ConsulLogin probed, ConsulLogin fallback) { - this(probed, fallback, PROBE_TRIES, DEFAULT_PROBE_PAUSE); - } - - ProbingConsulLogin(ConsulLogin probed, ConsulLogin fallback, int tries, Duration probePause) { - this.probed = probed; - this.fallback = fallback; - this.tries = tries; - this.probePause = probePause; - } - - @Override - public synchronized Token perform() throws IOException { - if (chosen != null) { - return chosen.perform(); - } - try { - Token token = probe(); - chosen = probed; - //todo vlla мы тут смешиваем контексты. Переменные называются probed и fallback, что как бы подразумевает, что они могут быть любыми способами. При этом при удаче и неудаче мы используем KUBERNETES_WAY и M2M_WAY, сразу предполагая, что мы знаем какиеми они будут. - // надо выбрать один из подходов: генерализованный, когда ProbingConsulLogin не знает, что за ConsulLogin в него переданы (но тогда тип авторизации надо как-то получать из самих ConsulLogin), либо уж он захардкожен на пару kubernetes + m2m - // проанализируй оба подхода и давай выберем - log.info("Consul ACL token is obtained by the {} auth method", KUBERNETES_WAY); - return token; - } catch (Exception e) { - chosen = fallback; - log.info("Consul login by the {} auth method failed, falling back to the {} one until the pod restarts: {}", - KUBERNETES_WAY, M2M_WAY, describe(e)); - return fallback.perform(); - } - } - - private Token probe() throws IOException { - try { - return Failsafe.with(LoginRetryPolicies.onTransportFailure(tries, probePause) - .onFailedAttempt(event -> log.debug("Failed probe attempt {} of the {} auth method", - event.getAttemptCount(), KUBERNETES_WAY, event.getLastFailure()))) - .get(probed::perform); - } catch (FailsafeException e) { - throw (IOException) e.getCause(); - } - } - - private static String describe(Exception e) { - String message = e.getMessage() == null ? "" : e.getMessage(); - if (message.length() > ERROR_MESSAGE_LIMIT) { - message = message.substring(0, ERROR_MESSAGE_LIMIT) + "..."; - } - return e.getClass().getSimpleName() + ": " + message; - } -} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java index 3ddd22d155..5f76133f07 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java @@ -24,23 +24,23 @@ public TokenStorage create(CreateOptions config) { return tokenStorage; } - public static ConsulLogin from(ConsulClient client, CreateOptions options) { + public static ConsulTokenProvider from(ConsulClient client, CreateOptions options) { switch (options.mode) { case M2M: - return m2mLogin(client, options); + return m2mProvider(client, options); case KUBERNETES: - return kubernetesLogin(client, options); + return kubernetesProvider(client, options); default: - return new ProbingConsulLogin(kubernetesLogin(client, options), m2mLogin(client, options)); + return new KubernetesWithM2MFallbackTokenProvider(kubernetesProvider(client, options), m2mProvider(client, options)); } } - private static ConsulLogin m2mLogin(ConsulClient client, CreateOptions options) { - return new TokenProvider(client, new M2MLoginCredentials(options.namespace, options.m2mSupplier)); + private static ConsulTokenProvider m2mProvider(ConsulClient client, CreateOptions options) { + return new LoginTokenProvider(client, new M2MLoginCredentials(options.namespace, options.m2mSupplier)); } - private static ConsulLogin kubernetesLogin(ConsulClient client, CreateOptions options) { - return new TokenProvider(client, new KubernetesLoginCredentials(options.authMethod, options.audience)); + private static ConsulTokenProvider kubernetesProvider(ConsulClient client, CreateOptions options) { + return new LoginTokenProvider(client, new KubernetesLoginCredentials(options.authMethod, options.audience)); } abstract protected TokenStorage createTokenStorage(CreateOptions config); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java index f168378af9..a72616123d 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java @@ -22,22 +22,22 @@ public class TokenUpdater { private static final Duration DEFAULT_RETRY_PAUSE = Duration.ofSeconds(1); private static final double DELAY_FRACTION = 0.8; static final long MIN_DELAY_SECONDS = 10; - private final ConsulLogin consulLogin; + private final ConsulTokenProvider tokenProvider; private final SelfTokenReader selfTokenReader; private ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); private Clock clock = Clock.systemDefaultZone(); private final Integer tries; private final Duration retryPause; - public TokenUpdater(ConsulLogin consulLogin, SelfTokenReader selfTokenReader) { - this.consulLogin = consulLogin; + public TokenUpdater(ConsulTokenProvider tokenProvider, SelfTokenReader selfTokenReader) { + this.tokenProvider = tokenProvider; this.selfTokenReader = selfTokenReader; this.tries = DEFAULT_TRIES; this.retryPause = DEFAULT_RETRY_PAUSE; } - TokenUpdater(ConsulLogin consulLogin, SelfTokenReader selfTokenReader, ScheduledExecutorService executor, Clock clock, int tries, Duration retryPause) { - this.consulLogin = consulLogin; + TokenUpdater(ConsulTokenProvider tokenProvider, SelfTokenReader selfTokenReader, ScheduledExecutorService executor, Clock clock, int tries, Duration retryPause) { + this.tokenProvider = tokenProvider; this.selfTokenReader = selfTokenReader; this.executor = executor; this.clock = clock; @@ -49,7 +49,7 @@ synchronized public void watch(Consumer updater, String currentSecretId) log.debug("Start token refreshing process for consul"); Token token; if (currentSecretId == null || currentSecretId.isEmpty()) { - token = withRetry(unused -> consulLogin.perform(), tries); + token = withRetry(unused -> tokenProvider.getToken(), tries); updater.accept(token.getSecretId()); } else { token = withRetry(unused -> selfTokenReader.read(currentSecretId), tries); @@ -60,7 +60,7 @@ synchronized public void watch(Consumer updater, String currentSecretId) Runnable task = () -> { log.debug("Get new consul token with {} retry attempts", tries); try { - Token newToken = withRetry(unused -> consulLogin.perform(), tries); + Token newToken = withRetry(unused -> tokenProvider.getToken(), tries); updater.accept(newToken.getSecretId()); } catch (Exception e) { log.error("Error occurred during getting new consul token. Will try in {} second.", delay, e); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLoginTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java similarity index 63% rename from core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLoginTest.java rename to core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java index 2feea7d97b..a9c1c2d164 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/ProbingConsulLoginTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java @@ -24,7 +24,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -class ProbingConsulLoginTest { +class KubernetesWithM2MFallbackTokenProviderTest { private static final String BEARER_TOKEN = "my-secret-bearer-token"; private static final String SECRET_ID = "my-secret-acl-token"; @@ -35,17 +35,17 @@ class ProbingConsulLoginTest { "Post \"https://kubernetes.default.svc/apis/authentication.k8s.io/v1/tokenreviews\": " + "dial tcp 10.96.0.1:443: connect: connection refused"; - private ConsulLogin kubernetes; - private ConsulLogin m2m; + private ConsulTokenProvider kubernetes; + private ConsulTokenProvider m2m; private ListAppender appender; private ch.qos.logback.classic.Logger logger; @BeforeEach void init() { - kubernetes = mock(ConsulLogin.class); - m2m = mock(ConsulLogin.class); + kubernetes = mock(ConsulTokenProvider.class); + m2m = mock(ConsulTokenProvider.class); - logger = ((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(ProbingConsulLogin.class); + logger = ((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(KubernetesWithM2MFallbackTokenProvider.class); appender = new ListAppender<>(); appender.start(); logger.addAppender(appender); @@ -57,8 +57,8 @@ void detachAppender() { logger.detachAppender(appender); } - private ProbingConsulLogin probing() { - return new ProbingConsulLogin(kubernetes, m2m, 2, Duration.ZERO); + private KubernetesWithM2MFallbackTokenProvider probing() { + return new KubernetesWithM2MFallbackTokenProvider(kubernetes, m2m, 2, Duration.ZERO); } private List infoRecords() { @@ -71,57 +71,57 @@ private List infoRecords() { @Test void successOnFirstAttemptSticksToTheNewWay() throws IOException { Token token = new Token(SECRET_ID, null); - when(kubernetes.perform()).thenReturn(token); + when(kubernetes.getToken()).thenReturn(token); - ProbingConsulLogin probing = probing(); - assertEquals(token, probing.perform()); - assertEquals(token, probing.perform()); + KubernetesWithM2MFallbackTokenProvider probing = probing(); + assertEquals(token, probing.getToken()); + assertEquals(token, probing.getToken()); - verify(kubernetes, times(2)).perform(); + verify(kubernetes, times(2)).getToken(); verifyNoInteractions(m2m); } @Test void anyExceptionFromTheNewWayLeadsToTheOldOneAndSticksIt() throws IOException { Token token = new Token(SECRET_ID, null); - when(kubernetes.perform()).thenThrow(new IllegalArgumentException("Unknown token audience: netcracker")); - when(m2m.perform()).thenReturn(token); + when(kubernetes.getToken()).thenThrow(new IllegalArgumentException("Unknown token audience: netcracker")); + when(m2m.getToken()).thenReturn(token); - ProbingConsulLogin probing = probing(); - assertEquals(token, probing.perform()); - assertEquals(token, probing.perform()); + KubernetesWithM2MFallbackTokenProvider probing = probing(); + assertEquals(token, probing.getToken()); + assertEquals(token, probing.getToken()); - verify(kubernetes, times(1)).perform(); - verify(m2m, times(2)).perform(); + verify(kubernetes, times(1)).getToken(); + verify(m2m, times(2)).getToken(); } @Test void errorPassesThrough() throws IOException { - when(kubernetes.perform()).thenThrow(new Error("Unable to locate implementation for TokenSource")); + when(kubernetes.getToken()).thenThrow(new Error("Unable to locate implementation for TokenSource")); - assertThrows(Error.class, () -> probing().perform()); + assertThrows(Error.class, () -> probing().getToken()); verifyNoInteractions(m2m); } @Test void probeSpendsFewerTriesThanTokenUpdater() throws IOException { - when(kubernetes.perform()).thenThrow(new IOException( + when(kubernetes.getToken()).thenThrow(new IOException( "login to consul failed: response code=500; body='" + TOKEN_REVIEW_UNREACHABLE + "'")); - when(m2m.perform()).thenReturn(new Token(SECRET_ID, null)); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); - new ProbingConsulLogin(kubernetes, m2m).perform(); + new KubernetesWithM2MFallbackTokenProvider(kubernetes, m2m).getToken(); - verify(kubernetes, times(ProbingConsulLogin.PROBE_TRIES)).perform(); - assertTrue(ProbingConsulLogin.PROBE_TRIES < TokenUpdater.DEFAULT_TRIES); + verify(kubernetes, times(KubernetesWithM2MFallbackTokenProvider.PROBE_TRIES)).getToken(); + assertTrue(KubernetesWithM2MFallbackTokenProvider.PROBE_TRIES < TokenUpdater.DEFAULT_TRIES); } @Test void chosenWayIsLoggedOnceOnSuccess() throws IOException { - when(kubernetes.perform()).thenReturn(new Token(SECRET_ID, null)); + when(kubernetes.getToken()).thenReturn(new Token(SECRET_ID, null)); - ProbingConsulLogin probing = probing(); - probing.perform(); - probing.perform(); + KubernetesWithM2MFallbackTokenProvider probing = probing(); + probing.getToken(); + probing.getToken(); List records = infoRecords(); assertEquals(1, records.size()); @@ -130,13 +130,13 @@ void chosenWayIsLoggedOnceOnSuccess() throws IOException { @Test void fallbackIsLoggedOnceWithReasonCodeAndBody() throws IOException { - when(kubernetes.perform()).thenThrow(new IOException( + when(kubernetes.getToken()).thenThrow(new IOException( "login to consul failed: response code=500; body='" + TOKEN_REVIEW_UNREACHABLE + "'")); - when(m2m.perform()).thenReturn(new Token(SECRET_ID, null)); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); - ProbingConsulLogin probing = probing(); - probing.perform(); - probing.perform(); + KubernetesWithM2MFallbackTokenProvider probing = probing(); + probing.getToken(); + probing.getToken(); List records = infoRecords(); assertEquals(1, records.size()); @@ -148,11 +148,11 @@ void fallbackIsLoggedOnceWithReasonCodeAndBody() throws IOException { @Test void notReadyConfigurationIsNamedInTheFallbackRecord() throws IOException { - when(kubernetes.perform()).thenThrow(new IOException( + when(kubernetes.getToken()).thenThrow(new IOException( "consul auth method is not ready: response code=403; body='" + AUTH_METHOD_NOT_FOUND + "'")); - when(m2m.perform()).thenReturn(new Token(SECRET_ID, null)); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); - probing().perform(); + probing().getToken(); List records = infoRecords(); assertEquals(1, records.size()); @@ -163,11 +163,11 @@ void notReadyConfigurationIsNamedInTheFallbackRecord() throws IOException { @Test void longErrorBodyIsTruncatedInTheFallbackRecord() throws IOException { String longBody = "x".repeat(4096); - when(kubernetes.perform()).thenThrow(new IOException( + when(kubernetes.getToken()).thenThrow(new IOException( "login to consul failed: response code=500; body='" + longBody + "'")); - when(m2m.perform()).thenReturn(new Token(SECRET_ID, null)); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); - probing().perform(); + probing().getToken(); String record = infoRecords().get(0); assertTrue(record.length() < longBody.length()); @@ -175,11 +175,11 @@ void longErrorBodyIsTruncatedInTheFallbackRecord() throws IOException { @Test void secretsNeverReachTheLog() throws IOException { - when(kubernetes.perform()).thenThrow(new IOException( + when(kubernetes.getToken()).thenThrow(new IOException( "login to consul failed: response code=500; body='" + TOKEN_REVIEW_UNREACHABLE + "'")); - when(m2m.perform()).thenReturn(new Token(SECRET_ID, null)); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); - probing().perform(); + probing().getToken(); String logged = String.join("\n", infoRecords()); assertFalse(logged.contains(BEARER_TOKEN)); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenProviderTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/LoginTokenProviderTest.java similarity index 90% rename from core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenProviderTest.java rename to core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/LoginTokenProviderTest.java index ec12ea76c6..a5e07f0bf2 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenProviderTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/LoginTokenProviderTest.java @@ -19,7 +19,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -class TokenProviderTest { +class LoginTokenProviderTest { private static final String LOGIN_RESPONSE_WITHOUT_EXPIRATION = "{\"AccessorID\":\"test-accessor-id\",\"SecretID\":\"test-secret-id\",\"Description\":\"token created via login\"," + @@ -34,13 +34,13 @@ class TokenProviderTest { private ConsulClient consulClient; private ConsulLoginCredentials credentials; - private TokenProvider tokenProvider; + private LoginTokenProvider tokenProvider; @BeforeEach public void init() { consulClient = mock(ConsulClient.class); credentials = new M2MLoginCredentials("test", () -> "test-m2m-token"); - tokenProvider = new TokenProvider(consulClient, credentials); + tokenProvider = new LoginTokenProvider(consulClient, credentials); } @Test @@ -48,7 +48,7 @@ void performReadsSecretIdAndExpirationTime() throws IOException { when(consulClient.login(any(ConsulLoginCredentials.class))) .thenReturn(new ConsulClientResponse(LOGIN_RESPONSE_WITH_EXPIRATION, 200)); - Token token = tokenProvider.perform(); + Token token = tokenProvider.getToken(); verify(consulClient).login(eq(credentials)); assertEquals("test-secret-id", token.getSecretId()); @@ -60,7 +60,7 @@ void performReadsResponseWithoutExpirationTime() throws IOException { when(consulClient.login(any(ConsulLoginCredentials.class))) .thenReturn(new ConsulClientResponse(LOGIN_RESPONSE_WITHOUT_EXPIRATION, 200)); - Token token = tokenProvider.perform(); + Token token = tokenProvider.getToken(); assertEquals("test-secret-id", token.getSecretId()); assertNull(token.getExpirationTime()); @@ -71,7 +71,7 @@ void performFailsWithIOExceptionOnEmptyBody() throws IOException { when(consulClient.login(any(ConsulLoginCredentials.class))) .thenReturn(new ConsulClientResponse("", 200)); - assertThrows(IOException.class, () -> tokenProvider.perform()); + assertThrows(IOException.class, () -> tokenProvider.getToken()); } @Test @@ -79,7 +79,7 @@ void performFailsWithIOExceptionOnNonSuccessCode() throws IOException { when(consulClient.login(any(ConsulLoginCredentials.class))) .thenReturn(new ConsulClientResponse("{\"Error\":\"Permission denied\"}", 403)); - assertThrows(IOException.class, () -> tokenProvider.perform()); + assertThrows(IOException.class, () -> tokenProvider.getToken()); } @Test @@ -87,7 +87,7 @@ void performFailsWithPathNotFoundExceptionWhenSecretIdIsMissing() throws IOExcep when(consulClient.login(any(ConsulLoginCredentials.class))) .thenReturn(new ConsulClientResponse("{\"AccessorID\":\"test-accessor-id\"}", 200)); - assertThrows(PathNotFoundException.class, () -> tokenProvider.perform()); + assertThrows(PathNotFoundException.class, () -> tokenProvider.getToken()); verify(consulClient, times(1)).login(any(ConsulLoginCredentials.class)); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java index c371f8068e..45fdd5dc0c 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java @@ -125,11 +125,11 @@ void unknownModeIsNotCreated() { Assertions.assertThrows(IllegalArgumentException.class, () -> ConsulLoginMode.valueOf("cloud-foundry")); } - private static ConsulLoginCredentials credentialsUsedBy(ConsulLogin login, ConsulClient client) throws IOException { + private static ConsulLoginCredentials credentialsUsedBy(ConsulTokenProvider login, ConsulClient client) throws IOException { when(client.login(any(ConsulLoginCredentials.class))) .thenReturn(new ConsulClientResponse("{\"SecretID\":\"test-secret-id\"}", 200)); - login.perform(); + login.getToken(); ArgumentCaptor used = ArgumentCaptor.forClass(ConsulLoginCredentials.class); verify(client).login(used.capture()); @@ -146,9 +146,9 @@ void m2mModeBuildsTokenProviderOverM2MCredentials() throws IOException { .m2mSupplier(() -> "token") .build(); - ConsulLogin login = TokenStorageFactory.from(client, opts); + ConsulTokenProvider login = TokenStorageFactory.from(client, opts); - Assertions.assertInstanceOf(TokenProvider.class, login); + Assertions.assertInstanceOf(LoginTokenProvider.class, login); Assertions.assertInstanceOf(M2MLoginCredentials.class, credentialsUsedBy(login, client)); } @@ -166,9 +166,9 @@ void kubernetesModeBuildsTokenProviderOverKubernetesCredentials() throws IOExcep }) .build(); - ConsulLogin login = TokenStorageFactory.from(client, opts); + ConsulTokenProvider login = TokenStorageFactory.from(client, opts); - Assertions.assertInstanceOf(TokenProvider.class, login); + Assertions.assertInstanceOf(LoginTokenProvider.class, login); ConsulLoginCredentials used = credentialsUsedBy(login, client); Assertions.assertInstanceOf(KubernetesLoginCredentials.class, used); Assertions.assertEquals("core-k8s", used.getAuthMethod()); @@ -186,9 +186,9 @@ void fallbackModeBuildsProbingLoginOverBothWays() throws IOException { .m2mSupplier(() -> "token") .build(); - ConsulLogin login = TokenStorageFactory.from(client, opts); + ConsulTokenProvider login = TokenStorageFactory.from(client, opts); - Assertions.assertInstanceOf(ProbingConsulLogin.class, login); + Assertions.assertInstanceOf(KubernetesWithM2MFallbackTokenProvider.class, login); Assertions.assertInstanceOf(KubernetesLoginCredentials.class, credentialsUsedBy(login, client)); } @@ -209,7 +209,7 @@ void fallbackModeFallsBackToM2MCredentials() throws IOException { return new ConsulClientResponse("{\"SecretID\":\"test-secret-id\"}", 200); }); - Token token = TokenStorageFactory.from(client, opts).perform(); + Token token = TokenStorageFactory.from(client, opts).getToken(); Assertions.assertEquals("test-secret-id", token.getSecretId()); ArgumentCaptor used = ArgumentCaptor.forClass(ConsulLoginCredentials.class); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java index 08b6e92a47..4f351689dd 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java @@ -24,7 +24,7 @@ class TokenUpdaterTest { private TokenUpdater tokenUpdater; - private ConsulLogin consulLogin; + private ConsulTokenProvider tokenProvider; private SelfTokenReader selfTokenReader; private ScheduledExecutorService scheduledExecutorService; private final Instant currentTime = Instant.now(); @@ -32,17 +32,17 @@ class TokenUpdaterTest { @BeforeEach public void init() { - consulLogin = Mockito.mock(ConsulLogin.class); + tokenProvider = Mockito.mock(ConsulTokenProvider.class); selfTokenReader = Mockito.mock(SelfTokenReader.class); scheduledExecutorService = Mockito.mock(ScheduledExecutorService.class); - tokenUpdater = new TokenUpdater(consulLogin, selfTokenReader, scheduledExecutorService, Clock.fixed(currentTime, ZoneId.of("UTC")), 2, Duration.ZERO); + tokenUpdater = new TokenUpdater(tokenProvider, selfTokenReader, scheduledExecutorService, Clock.fixed(currentTime, ZoneId.of("UTC")), 2, Duration.ZERO); } @Test void mustGetNewTokenScheduleUpdates() throws IOException { String secretId = "test-token"; OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); - when(consulLogin.perform()).thenReturn(new Token(secretId, secretExpirationTime)); + when(tokenProvider.getToken()).thenReturn(new Token(secretId, secretExpirationTime)); AtomicReference updater = new AtomicReference<>(""); tokenUpdater.watch(updater::set, ""); @@ -77,21 +77,21 @@ void mustUseSelfTokenIfProvidedScheduleUpdates() throws IOException { void mustRetryOnFailure() throws IOException { String secretId = "test-self-token"; OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); - when(consulLogin.perform()) + when(tokenProvider.getToken()) .thenThrow(new IOException()) .thenReturn(new Token(secretId, secretExpirationTime)); AtomicReference updater = new AtomicReference<>(""); tokenUpdater.watch(updater::set, ""); - verify(consulLogin, times(2)).perform(); + verify(tokenProvider, times(2)).getToken(); } @Test void scheduledTaskMustRetryOnFailure() throws IOException { String secretId = "test-token"; OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); - when(consulLogin.perform()) + when(tokenProvider.getToken()) .thenReturn(new Token(secretId, secretExpirationTime)) .thenThrow(new IOException()) .thenThrow(new IOException()) @@ -118,7 +118,7 @@ void scheduledTaskLoginsThroughTheSameConsulLogin() throws IOException { String secretId = "test-token"; String rotatedSecretId = "test-rotated-token"; OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); - when(consulLogin.perform()) + when(tokenProvider.getToken()) .thenReturn(new Token(secretId, secretExpirationTime)) .thenReturn(new Token(rotatedSecretId, secretExpirationTime)); @@ -133,14 +133,14 @@ void scheduledTaskLoginsThroughTheSameConsulLogin() throws IOException { tokenUpdater.watch(updater::set, ""); assertEquals(rotatedSecretId, updater.get()); - verify(consulLogin, times(2)).perform(); + verify(tokenProvider, times(2)).getToken(); verifyNoInteractions(selfTokenReader); } private long scheduledDelay(Instant now, OffsetDateTime expirationTime) throws IOException { ScheduledExecutorService executor = Mockito.mock(ScheduledExecutorService.class); - ConsulLogin login = Mockito.mock(ConsulLogin.class); - when(login.perform()).thenReturn(new Token("test-token", expirationTime)); + ConsulTokenProvider login = Mockito.mock(ConsulTokenProvider.class); + when(login.getToken()).thenReturn(new Token("test-token", expirationTime)); new TokenUpdater(login, Mockito.mock(SelfTokenReader.class), executor, Clock.fixed(now, ZoneId.of("UTC")), 2, Duration.ZERO).watch(unused -> { @@ -173,11 +173,11 @@ void delayKeepsLowerBoundWhenClockRanAhead() throws IOException { void retryWaitsBackoffDelayBetweenAttempts() throws IOException { Duration retryPause = Duration.ofMillis(200); OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); - when(consulLogin.perform()) + when(tokenProvider.getToken()) .thenThrow(new IOException()) .thenReturn(new Token("test-token", secretExpirationTime)); - TokenUpdater updater = new TokenUpdater(consulLogin, selfTokenReader, scheduledExecutorService, + TokenUpdater updater = new TokenUpdater(tokenProvider, selfTokenReader, scheduledExecutorService, Clock.fixed(currentTime, ZoneId.of("UTC")), 2, retryPause); long startedAt = System.nanoTime(); @@ -186,7 +186,7 @@ void retryWaitsBackoffDelayBetweenAttempts() throws IOException { long elapsed = System.nanoTime() - startedAt; long lowestJitteredDelay = (long) (retryPause.toNanos() * (1 - LoginRetryPolicies.JITTER)); - verify(consulLogin, times(2)).perform(); + verify(tokenProvider, times(2)).getToken(); Assertions.assertTrue(elapsed >= lowestJitteredDelay, "expected a backoff delay of at least " + lowestJitteredDelay + " ns between retries, got " + elapsed); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java index e764138d48..b944493b1f 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java @@ -67,7 +67,7 @@ protected ConsulConfigProperties loadConfigProperties(ConfigDataLocationResolver .m2mSupplier(m2mTokenSupplier); } - consulConfigProperties.setAclToken(TokenStorageFactory.from(client, options.build()).perform().getSecretId()); + consulConfigProperties.setAclToken(TokenStorageFactory.from(client, options.build()).getToken().getSecretId()); } catch (IOException e) { log.error("can not get consul token by m2m: ", e); } From 19fea9bf6e655b668ed7347d3486355e4c470867 Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Thu, 27 Aug 2026 12:46:10 +0400 Subject: [PATCH 06/20] chore: refactoring + todos @cf_ignore --- .../provider/common/LoginRetryPolicies.java | 1 + .../consul/provider/common/TokenUpdater.java | 31 +++++++++++-------- ...netesWithM2MFallbackTokenProviderTest.java | 1 - .../common/config/ConsulLoginProperties.java | 1 + 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java index cb394466ae..1b0dd4d541 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java @@ -6,6 +6,7 @@ import java.time.Duration; import java.time.temporal.ChronoUnit; +//todo vlla javadoc final class LoginRetryPolicies { static final double JITTER = 0.25; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java index a72616123d..e0dea8a42f 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java @@ -2,6 +2,7 @@ import net.jodah.failsafe.Failsafe; import net.jodah.failsafe.FailsafeException; +import net.jodah.failsafe.RetryPolicy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,10 +19,11 @@ public class TokenUpdater { private static final Logger log = LoggerFactory.getLogger(TokenUpdater.class); - static final int DEFAULT_TRIES = 10; + private static final int DEFAULT_TRIES = 10; private static final Duration DEFAULT_RETRY_PAUSE = Duration.ofSeconds(1); - private static final double DELAY_FRACTION = 0.8; + private static final double DELAY_MULTIPLIER = 0.8; static final long MIN_DELAY_SECONDS = 10; + private final ConsulTokenProvider tokenProvider; private final SelfTokenReader selfTokenReader; private ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); @@ -49,10 +51,10 @@ synchronized public void watch(Consumer updater, String currentSecretId) log.debug("Start token refreshing process for consul"); Token token; if (currentSecretId == null || currentSecretId.isEmpty()) { - token = withRetry(unused -> tokenProvider.getToken(), tries); + token = withRetry(tokenProvider::getToken, tries); updater.accept(token.getSecretId()); } else { - token = withRetry(unused -> selfTokenReader.read(currentSecretId), tries); + token = withRetry(() -> selfTokenReader.read(currentSecretId), tries); } if (token.getExpirationTime() != null) { @@ -60,7 +62,7 @@ synchronized public void watch(Consumer updater, String currentSecretId) Runnable task = () -> { log.debug("Get new consul token with {} retry attempts", tries); try { - Token newToken = withRetry(unused -> tokenProvider.getToken(), tries); + Token newToken = withRetry(tokenProvider::getToken, tries); updater.accept(newToken.getSecretId()); } catch (Exception e) { log.error("Error occurred during getting new consul token. Will try in {} second.", delay, e); @@ -72,22 +74,25 @@ synchronized public void watch(Consumer updater, String currentSecretId) private long reloginDelaySeconds(OffsetDateTime expirationTime) { long remaining = ChronoUnit.SECONDS.between(OffsetDateTime.now(clock), expirationTime); - return Math.max((long) (remaining * DELAY_FRACTION), MIN_DELAY_SECONDS); + return Math.max((long) (remaining * DELAY_MULTIPLIER), MIN_DELAY_SECONDS); } - private Token withRetry(CheckedFunction c, int tries) { + private Token withRetry(CheckedFunction c, int tries) { try { - return Failsafe.with(LoginRetryPolicies.onTransportFailure(tries, retryPause) - .onFailedAttempt(event -> log.debug("Failed attempt {} to get a consul token", - event.getAttemptCount(), event.getLastFailure()))) - .get(() -> c.apply(null)); + return Failsafe.with(getRetryPolicy(tries)).get(c::apply); } catch (FailsafeException e) { throw new RuntimeException("can not update consul token: ", e.getCause()); } } + private RetryPolicy getRetryPolicy(int tries) { + return LoginRetryPolicies.onTransportFailure(tries, retryPause) + .onFailedAttempt(event -> log.debug("Failed attempt {} to get a consul token", + event.getAttemptCount(), event.getLastFailure())); + } + @FunctionalInterface - public interface CheckedFunction { - R apply(T t) throws IOException; + public interface CheckedFunction { + R apply() throws IOException; } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java index a9c1c2d164..4ee039eff9 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java @@ -112,7 +112,6 @@ void probeSpendsFewerTriesThanTokenUpdater() throws IOException { new KubernetesWithM2MFallbackTokenProvider(kubernetes, m2m).getToken(); verify(kubernetes, times(KubernetesWithM2MFallbackTokenProvider.PROBE_TRIES)).getToken(); - assertTrue(KubernetesWithM2MFallbackTokenProvider.PROBE_TRIES < TokenUpdater.DEFAULT_TRIES); } @Test diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java index 843bcec4e3..85e230b4ac 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java @@ -8,6 +8,7 @@ * belong to {@code TokenStorageFactory.CreateOptions.Builder}, so that an external caller of the builder gets them * too. */ +//todo vlla lombok? @ConfigurationProperties(prefix = ConsulLoginProperties.PREFIX) public class ConsulLoginProperties { From 3fc1abf8c9bcbc762c14e8299d9ae5808115f97e Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Thu, 27 Aug 2026 13:52:43 +0400 Subject: [PATCH 07/20] docs(consul): document the login types Javadoc for the types and methods whose contract is not visible from the signature: what IOException means for a caller, why the delay is a share of the remaining lifetime, why the fallback choice sticks until the pod restarts, why the defaults live in the builder, and why the ConfigData phase binds the mode instead of injecting it. Getters, constructors and the plain value types are left alone. Also folds the review note on the retry supplier: the custom CheckedFunction had lost its argument and became a supplier, so failsafe's own CheckedSupplier replaces it. --- .../common/ConsulLoginCredentials.java | 7 +++++ .../provider/common/ConsulLoginMode.java | 6 ++++ .../provider/common/ConsulTokenProvider.java | 9 ++++++ .../common/KubernetesLoginCredentials.java | 4 +++ ...ubernetesWithM2MFallbackTokenProvider.java | 11 ++++++++ .../provider/common/LoginRetryPolicies.java | 11 +++++++- .../provider/common/LoginTokenProvider.java | 10 +++++++ .../provider/common/M2MLoginCredentials.java | 5 ++++ .../provider/common/SelfTokenReader.java | 10 +++++++ .../provider/common/TokenStorageFactory.java | 11 ++++++++ .../consul/provider/common/TokenUpdater.java | 28 ++++++++++++++----- .../provider/common/client/ConsulClient.java | 5 ++++ .../common/client/ConsulOkHttpClient.java | 4 +++ .../ConsulM2MConfigDataLocationResolver.java | 6 ++++ ...onsulM2MRestTemplateAutoConfiguration.java | 4 +++ .../ConsulM2MWebClientAutoConfiguration.java | 4 +++ 16 files changed, 127 insertions(+), 8 deletions(-) diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java index 2aa77afbdc..12394df821 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginCredentials.java @@ -1,8 +1,15 @@ package com.netcracker.cloud.consul.provider.common; +/** + * The pair Consul expects in a login request: the auth method to log in to, and the bearer token proving the identity. + */ public interface ConsulLoginCredentials { String getAuthMethod(); + /** + * Reads the bearer token from its source. Called before every login attempt, so the source stays free to rotate + * the token; implementations cache nothing of their own. + */ String getBearerToken(); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java index 8c9839e4a1..b6dc8ca68b 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java @@ -1,7 +1,13 @@ package com.netcracker.cloud.consul.provider.common; +/** + * Which way of obtaining a Consul ACL token a pod uses. A pod keeps one way for its whole life. + */ public enum ConsulLoginMode { + /** Tries the kubernetes way once, and falls back to m2m for the rest of the pod's life if that fails. */ KUBERNETES_WITH_M2M_FALLBACK, + /** Only the kubernetes way, with no probe and no fallback. A failed login is a failure. */ KUBERNETES, + /** Only the m2m way. The auth method name and the audience are not read. */ M2M } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java index a3522dc972..c15fbafbd0 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java @@ -2,7 +2,16 @@ import java.io.IOException; +/** + * Obtains a fresh Consul ACL token. Implementations either perform one login or choose between several ways of + * performing it. + */ public interface ConsulTokenProvider { + /** + * Performs a Consul login and returns the issued token. + * + * @throws IOException on a transport failure or a non-2xx answer from Consul; the caller may retry + */ Token getToken() throws IOException; } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentials.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentials.java index 19b328afbd..a0e4fc4fa9 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentials.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesLoginCredentials.java @@ -2,6 +2,10 @@ import com.netcracker.cloud.security.core.utils.k8s.KubernetesAudienceToken; +/** + * Credentials of the kubernetes way: the bearer token is the projected service account token of the pod, read by + * audience through the {@code TokenSource} SPI. + */ final class KubernetesLoginCredentials implements ConsulLoginCredentials { private final String authMethod; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java index 4fd53b9084..3d985b3751 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java @@ -8,6 +8,12 @@ import java.io.IOException; import java.time.Duration; +/** + * Probes the kubernetes way and falls back to m2m if the probe fails. The choice sticks until the pod restarts, so a + * later failure never switches the way back, and the pod logs one {@code INFO} record telling which way it settled on. + * The whole class goes away with the m2m way, which is why it names the pair it serves instead of taking two + * interchangeable providers. + */ final class KubernetesWithM2MFallbackTokenProvider implements ConsulTokenProvider { static final int PROBE_TRIES = 3; @@ -37,6 +43,11 @@ final class KubernetesWithM2MFallbackTokenProvider implements ConsulTokenProvide this.probeDelay = probeDelay; } + /** + * Returns a token from the way already chosen, or, on the first call, chooses one. The probe spends fewer attempts + * than the scheduler so that an unmigrated pod pays little for it. Any {@link Exception} out of the kubernetes way + * means the fallback; an {@link Error} passes through untouched. + */ @Override public synchronized Token getToken() throws IOException { if (chosen != null) { diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java index 1b0dd4d541..15852b4f0b 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java @@ -6,7 +6,11 @@ import java.time.Duration; import java.time.temporal.ChronoUnit; -//todo vlla javadoc +/** + * Retry policies shared by the login callers. Only {@link IOException} is retried: by the exception contract of the + * module it means a transport failure or a non-2xx answer, where another attempt may help, while anything else means + * the input itself is wrong. + */ final class LoginRetryPolicies { static final double JITTER = 0.25; @@ -15,6 +19,11 @@ final class LoginRetryPolicies { private LoginRetryPolicies() { } + /** + * Builds a policy of at most {@code attempts} attempts with an exponential delay and jitter, so that a fleet + * restarted during a Consul outage does not retry in lockstep. A zero or negative {@code delay} retries without + * waiting, which keeps tests fast. + */ static RetryPolicy onTransportFailure(int attempts, Duration delay) { RetryPolicy policy = new RetryPolicy() .handle(IOException.class) diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java index c88863d51e..3de9cf9d4d 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java @@ -10,6 +10,10 @@ import java.io.IOException; import java.time.OffsetDateTime; +/** + * Obtains a token by performing a Consul login with one set of credentials. Knows nothing about which way the + * credentials represent. + */ public class LoginTokenProvider implements ConsulTokenProvider { private static final Logger log = LoggerFactory.getLogger(LoginTokenProvider.class); @@ -22,6 +26,12 @@ public LoginTokenProvider(ConsulClient client, ConsulLoginCredentials credential this.credentials = credentials; } + /** + * @throws IOException on an empty body or a non-2xx answer; the message carries the response code, and a + * {@code 403} is reported as a Consul configuration that is not ready yet + * @throws com.jayway.jsonpath.PathNotFoundException when the answer carries no {@code SecretID}; retrying that + * does not help + */ @Override public Token getToken() throws IOException { ConsulClientResponse response = client.login(credentials); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentials.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentials.java index 14aa58a4a3..c423b3f2f4 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentials.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/M2MLoginCredentials.java @@ -2,6 +2,11 @@ import java.util.function.Supplier; +/** + * Credentials of the m2m way, kept for the migration period. The auth method is the namespace of the microservice, + * and the supplier resolves the M2M token lazily, so the ConfigData phase does not touch the bootstrap registry + * until a login actually happens. + */ final class M2MLoginCredentials implements ConsulLoginCredentials { private final String namespace; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java index 6e8caf4c5a..4584c83471 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java @@ -10,6 +10,10 @@ import java.io.IOException; import java.time.OffsetDateTime; +/** + * Reads the expiration of an ACL token the pod already holds. The operation does not depend on how the token was + * obtained, which is why it lives apart from {@link ConsulTokenProvider}. + */ public class SelfTokenReader { private static final Logger log = LoggerFactory.getLogger(SelfTokenReader.class); @@ -20,6 +24,12 @@ public SelfTokenReader(ConsulClient client) { this.client = client; } + /** + * Reads the token behind {@code currentSecretId}. A token without an expiration is valid: Consul omits the field + * for an auth method without {@code MaxTokenTTL}. + * + * @throws IOException on a transport failure or a non-2xx answer + */ public Token read(String currentSecretId) throws IOException { ConsulClientResponse response = client.getSelfToken(currentSecretId); String bodyJson = response.getBodyJson(); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java index 5f76133f07..cdf9e249a1 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java @@ -24,6 +24,11 @@ public TokenStorage create(CreateOptions config) { return tokenStorage; } + /** + * Builds the provider the mode asks for. The single place that knows both ways exist; everything below it sees one + * {@link ConsulTokenProvider}. The client comes separately because the caller owns the transport: the ConfigData + * phase builds its own. + */ public static ConsulTokenProvider from(ConsulClient client, CreateOptions options) { switch (options.mode) { case M2M: @@ -106,6 +111,12 @@ public Builder audience(String audience) { return this; } + /** + * Applies the defaults and checks the inputs the mode needs. Defaults live here rather than in the entry + * points so that an external caller of the builder gets them too. + * + * @throws IllegalArgumentException naming the missing input and the mode that requires it + */ public CreateOptions build() { if (options.mode == null) { options.mode = ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java index e0dea8a42f..a85ab1afc5 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java @@ -3,6 +3,7 @@ import net.jodah.failsafe.Failsafe; import net.jodah.failsafe.FailsafeException; import net.jodah.failsafe.RetryPolicy; +import net.jodah.failsafe.function.CheckedSupplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,6 +17,10 @@ import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +/** + * Keeps the ACL token of the pod fresh: obtains the first one, then relogins on a schedule while the pod lives. Knows + * nothing about how the token is obtained. + */ public class TokenUpdater { private static final Logger log = LoggerFactory.getLogger(TokenUpdater.class); @@ -47,6 +52,15 @@ public TokenUpdater(ConsulTokenProvider tokenProvider, SelfTokenReader selfToken this.retryPause = retryPause; } + /** + * Obtains the token and, when it expires, schedules a relogin. An empty {@code currentSecretId} means a login; + * otherwise the pod already holds a token and only its expiration is read. Only a token with an expiration is + * scheduled for: Consul omits the field for auth methods without {@code MaxTokenTTL}, and such a token never + * expires. + * + * @param updater receives every new {@code SecretID}, including the ones from scheduled relogins + * @throws RuntimeException when the attempts run out + */ synchronized public void watch(Consumer updater, String currentSecretId) { log.debug("Start token refreshing process for consul"); Token token; @@ -72,14 +86,19 @@ synchronized public void watch(Consumer updater, String currentSecretId) } } + /** + * Returns the delay before the next relogin as a share of the remaining lifetime. The same value serves as the + * period of the schedule, so a constant offset from the expiration would degenerate on short-lived tokens. The + * lower bound covers clocks that ran ahead of the expiration. + */ private long reloginDelaySeconds(OffsetDateTime expirationTime) { long remaining = ChronoUnit.SECONDS.between(OffsetDateTime.now(clock), expirationTime); return Math.max((long) (remaining * DELAY_MULTIPLIER), MIN_DELAY_SECONDS); } - private Token withRetry(CheckedFunction c, int tries) { + private Token withRetry(CheckedSupplier c, int tries) { try { - return Failsafe.with(getRetryPolicy(tries)).get(c::apply); + return Failsafe.with(getRetryPolicy(tries)).get(c); } catch (FailsafeException e) { throw new RuntimeException("can not update consul token: ", e.getCause()); } @@ -90,9 +109,4 @@ private RetryPolicy getRetryPolicy(int tries) { .onFailedAttempt(event -> log.debug("Failed attempt {} to get a consul token", event.getAttemptCount(), event.getLastFailure())); } - - @FunctionalInterface - public interface CheckedFunction { - R apply() throws IOException; - } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulClient.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulClient.java index 96d3d20610..f4b873cac3 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulClient.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulClient.java @@ -19,6 +19,11 @@ public interface ConsulClient { @Deprecated(forRemoval = true) ConsulClientResponse login(String authMethod); + /** + * Logs in with the given credentials. The default implementation delegates to the deprecated {@link + * #login(String)}, so an implementation written before the credentials existed keeps working: it sends its own + * bearer token and ignores the one in {@code credentials}. + */ default ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOException { return login(credentials.getAuthMethod()); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java index e6693a0cef..eaa2af532f 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java @@ -70,6 +70,10 @@ public ConsulClientResponse login(String authMethod) { return new ConsulClientResponse(responseBody, response.code()); } + /** + * Unlike the deprecated {@link #login(String)}, lets a transport {@link IOException} out instead of wrapping it: + * retries and the fallback decision act on that type. + */ @Override public ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOException { Map payload = new HashMap<>(); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java index b944493b1f..dd6399c708 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java @@ -41,6 +41,12 @@ protected ConsulM2MConfigDataLocationResolver(DeferredLogFactory log) { this.log = log.getLog(ConsulM2MConfigDataLocationResolver.class); } + /** + * Logs in once and writes the {@code SecretID} into {@link ConsulConfigProperties}, so that Consul is readable + * before the application context exists. The phase runs without a context, so the mode is bound through {@link + * Binder} rather than injected, and a failed login is logged rather than thrown: the application still starts, + * just without an ACL token. + */ @Override protected ConsulConfigProperties loadConfigProperties(ConfigDataLocationResolverContext resolverContext) { ConsulConfigProperties consulConfigProperties = super.loadConfigProperties(resolverContext); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java index f643ade494..b9ecde37ed 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java @@ -38,6 +38,10 @@ public TokenStorage consulTokenStorageViaM2MRestTemplate(ConsulConfigProperties return factory.create(createOptions(loginProperties, consulProperties, m2MManager, System.getenv("NAMESPACE"))); } + /** + * Collects the raw inputs of the login. Values absent from the configuration stay {@code null} here, so that the + * builder applies the defaults. + */ static TokenStorageFactory.CreateOptions createOptions(ConsulLoginProperties loginProperties, ConsulProperties consulProperties, M2MManager m2MManager, diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java index 8915caf3e7..e2cb68e3f6 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java @@ -34,6 +34,10 @@ public TokenStorage consulTokenStorageViaM2MWebClient(ConsulConfigProperties con return factory.create(createOptions(loginProperties, consulProperties, m2MManager, System.getenv("NAMESPACE"))); } + /** + * Collects the raw inputs of the login. Values absent from the configuration stay {@code null} here, so that the + * builder applies the defaults. + */ static TokenStorageFactory.CreateOptions createOptions(ConsulLoginProperties loginProperties, ConsulProperties consulProperties, M2MManager m2MManager, From cd5a844f2445e852f9be31794d0d5aa0c17e8a3d Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Thu, 27 Aug 2026 16:50:11 +0400 Subject: [PATCH 08/20] feat(consul): retry the kubernetes way while on the m2m fallback A service can be on the new library and the new projected tokens while Consul has not registered the auth method yet. Such a pod fell back to m2m and stayed there until someone restarted it. Now it moves over on its own once Consul is ready: - the fallback is temporary and the switch is permanent, so the state is a ratchet: probe, fall back, recheck, kubernetes for good - the recheck rides on the scheduled relogin instead of a timer of its own, throttled by the new fallback-recheck-interval property, five hours by default - the relogin schedule now follows the expiration of the token just received rather than of the first one: the two auth methods carry different MaxTokenTTL, and a fixed period would leave the new token unrefreshed after the switch - a second INFO record marks the switch; failed rechecks stay silent so an unmigrated fleet does not log on every relogin Rescheduling outlives the migration and fixes an ordinary operation on its own: changing MaxTokenTTL on an auth method without restarting the pods. Without MaxTokenTTL nothing is scheduled and nothing is rechecked, which is acceptable: such a token never expires and needs no new one. --- .../config-sources/consul-client/README.md | 14 +- .../client/ConsulClientConfiguration.java | 8 +- ...ClientConfigurationKubernetesModeTest.java | 5 +- .../consul-config-provider/README.md | 21 ++- ...ubernetesWithM2MFallbackTokenProvider.java | 79 +++++++---- .../provider/common/TokenStorageFactory.java | 18 ++- .../consul/provider/common/TokenUpdater.java | 36 +++-- ...netesWithM2MFallbackTokenProviderTest.java | 123 +++++++++++++++++- .../common/TokenStorageFactoryTest.java | 23 ++++ .../provider/common/TokenUpdaterTest.java | 95 ++++++++++---- .../common/config/ConsulLoginProperties.java | 11 ++ .../ConsulM2MConfigDataLocationResolver.java | 3 +- ...onsulM2MRestTemplateAutoConfiguration.java | 1 + ...lM2MRestTemplateAutoConfigurationTest.java | 15 +++ .../ConsulM2MWebClientAutoConfiguration.java | 1 + ...nsulM2MWebClientAutoConfigurationTest.java | 15 +++ 16 files changed, 396 insertions(+), 72 deletions(-) diff --git a/core-quarkus-extensions/config-sources/consul-client/README.md b/core-quarkus-extensions/config-sources/consul-client/README.md index 261991cddf..53f852b275 100644 --- a/core-quarkus-extensions/config-sources/consul-client/README.md +++ b/core-quarkus-extensions/config-sources/consul-client/README.md @@ -44,10 +44,15 @@ The Consul ACL token is exchanged through `POST /v1/acl/login`. Two ways to obta the projected service account token of the pod, `m2m` sends an M2M token. The three properties below are read at runtime when the `TokenStorage` bean is built, so the way can be switched without rebuilding the application. -A pod keeps one way for its whole life: in `kubernetes-with-m2m-fallback` mode the first login decides, and a failure of a later login does not -switch the way back. In `kubernetes-with-m2m-fallback` mode the `kubernetes` way is tried first, and on failure the pod falls back to the `m2m` -way and logs one `INFO` record with the reason, the Consul response code, and a truncated response body. In `kubernetes` -mode there is no probe and no fallback. In `m2m` mode the auth method name and the audience are not read at all. +In `kubernetes-with-m2m-fallback` mode the `kubernetes` way is tried first, and on failure the pod falls back to the +`m2m` way and logs one `INFO` record with the reason, the Consul response code, and a truncated response body. In +`kubernetes` mode there is no probe and no fallback. In `m2m` mode the auth method name and the audience are not read +at all. + +The fallback is temporary. Once `fallback-recheck-interval` has passed, the next scheduled relogin tries the +`kubernetes` way again, and the first success switches the pod over for good. Going back to `m2m` never happens. The +recheck rides on the scheduled relogin, so it needs `MaxTokenTTL` on the auth method: without it the token never +expires, nothing is scheduled, and nothing is rechecked. An unknown value of `quarkus.consul-source-config.login.mode` fails the startup. With `quarkus.consul-source-config.m2m.enabled=false` none of the three properties are read. @@ -69,3 +74,4 @@ the token directory elsewhere with `com.netcracker.cloud.security.kubernetes.tok | quarkus.consul-source-config.login.mode | Way to obtain the ACL token: kubernetes-with-m2m-fallback, kubernetes or m2m | kubernetes-with-m2m-fallback | | quarkus.consul-source-config.login.auth-method | Consul auth method name, used by the kubernetes way | kubernetes-auth-method-placeholder | | quarkus.consul-source-config.login.audience | Projected token audience, used by the kubernetes way | netcracker | +| quarkus.consul-source-config.login.fallback-recheck-interval | How often the fallback retries the kubernetes way | PT5H | diff --git a/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java b/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java index 54af45c09a..945b833c59 100644 --- a/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java +++ b/core-quarkus-extensions/config-sources/consul-client/runtime/src/main/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfiguration.java @@ -18,6 +18,7 @@ import java.net.MalformedURLException; import java.net.URL; +import java.time.Duration; import java.util.Optional; @Singleton @@ -26,6 +27,8 @@ public class ConsulClientConfiguration { public static final String PROP_LOGIN_MODE = "quarkus.consul-source-config.login.mode"; public static final String PROP_LOGIN_AUTH_METHOD = "quarkus.consul-source-config.login.auth-method"; public static final String PROP_LOGIN_AUDIENCE = "quarkus.consul-source-config.login.audience"; + public static final String PROP_LOGIN_FALLBACK_RECHECK_INTERVAL = + "quarkus.consul-source-config.login.fallback-recheck-interval"; private static final Logger log = LoggerFactory.getLogger(ConsulClientConfiguration.class); @@ -76,7 +79,9 @@ public TokenStorage tokenStorage(TokenStorageFactory tokenStorageFactory, @ConfigProperty(name = "quarkus.consul-source-config.agent.url") String agentUrl, @ConfigProperty(name = PROP_LOGIN_MODE) Optional mode, @ConfigProperty(name = PROP_LOGIN_AUTH_METHOD) Optional authMethod, - @ConfigProperty(name = PROP_LOGIN_AUDIENCE) Optional audience) { + @ConfigProperty(name = PROP_LOGIN_AUDIENCE) Optional audience, + @ConfigProperty(name = PROP_LOGIN_FALLBACK_RECHECK_INTERVAL) + Optional fallbackRecheckInterval) { return tokenStorageFactory.create(new TokenStorageFactory.CreateOptions.Builder() .consulUrl(agentUrl) .namespace(namespace) @@ -84,6 +89,7 @@ public TokenStorage tokenStorage(TokenStorageFactory tokenStorageFactory, .mode(mode.orElse(null)) .authMethod(authMethod.orElse(null)) .audience(audience.orElse(null)) + .fallbackRecheckInterval(fallbackRecheckInterval.orElse(null)) .build()); } diff --git a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationKubernetesModeTest.java b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationKubernetesModeTest.java index 4d86772a66..433817cfd8 100644 --- a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationKubernetesModeTest.java +++ b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulClientConfigurationKubernetesModeTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import java.time.Duration; import java.util.Map; import static org.mockito.ArgumentMatchers.any; @@ -33,7 +34,8 @@ public Map getConfigOverrides() { "quarkus.consul-source-config.agent.url", "http://localhost:8500", ConsulClientConfiguration.PROP_LOGIN_MODE, "kubernetes", ConsulClientConfiguration.PROP_LOGIN_AUTH_METHOD, "core-k8s", - ConsulClientConfiguration.PROP_LOGIN_AUDIENCE, "dbaas" + ConsulClientConfiguration.PROP_LOGIN_AUDIENCE, "dbaas", + ConsulClientConfiguration.PROP_LOGIN_FALLBACK_RECHECK_INTERVAL, "PT30M" ); } } @@ -56,5 +58,6 @@ void loginPropertiesReachCreateOptionsAtRuntime() { Assertions.assertEquals(ConsulLoginMode.KUBERNETES, options.getValue().getMode()); Assertions.assertEquals("core-k8s", options.getValue().getAuthMethod()); Assertions.assertEquals("dbaas", options.getValue().getAudience()); + Assertions.assertEquals(Duration.ofMinutes(30), options.getValue().getFallbackRecheckInterval()); } } diff --git a/core-rest-libraries/consul-config-provider/README.md b/core-rest-libraries/consul-config-provider/README.md index b25021f402..873913a344 100644 --- a/core-rest-libraries/consul-config-provider/README.md +++ b/core-rest-libraries/consul-config-provider/README.md @@ -12,19 +12,26 @@ Two ways to obtain the token are supported: ## Login properties The three properties below are read at startup, in the ConfigData phase and again when the `TokenStorage` bean is -built. A pod keeps one way of obtaining the token for its whole life: in `kubernetes-with-m2m-fallback` mode the first login decides, and a -failure of a later login does not switch the way back. +built. In `kubernetes-with-m2m-fallback` mode the first login picks the way, and a later failure never switches it +back on its own. | Property name | Type | Default | Read when | |-----------------------------------------------------|-------------------------------|--------------------------------------|--------------------------------------------------------------| | `spring.cloud.consul.config.login.mode` | `kubernetes-with-m2m-fallback`, `kubernetes` or `m2m` | `kubernetes-with-m2m-fallback` | ConfigData phase and `TokenStorage` bean creation | | `spring.cloud.consul.config.login.auth-method` | string | `kubernetes-auth-method-placeholder` | same, and only when the `kubernetes` way is used | | `spring.cloud.consul.config.login.audience` | string | `netcracker` | same, and only when the `kubernetes` way is used | - -In `kubernetes-with-m2m-fallback` mode the `kubernetes` way is tried first. If it fails, the pod falls back to the `m2m` way and logs one -`INFO` record with the reason, the Consul response code, and a truncated response body. In `kubernetes` mode there is -no probe and no fallback, and neither `M2MManager` nor the namespace is needed. In `m2m` mode the auth method name and -the audience are not read at all. +| `spring.cloud.consul.config.login.fallback-recheck-interval` | duration | `5h` | same, and only in the fallback mode | + +In `kubernetes-with-m2m-fallback` mode the `kubernetes` way is tried first. If it fails, the pod falls back to the +`m2m` way and logs one `INFO` record with the reason, the Consul response code, and a truncated response body. In +`kubernetes` mode there is no probe and no fallback, and neither `M2MManager` nor the namespace is needed. In `m2m` +mode the auth method name and the audience are not read at all. + +The fallback is temporary. Once `fallback-recheck-interval` has passed, the next scheduled relogin tries the +`kubernetes` way again, and the first success switches the pod over for good, with a second `INFO` record. Going back +to `m2m` never happens. The recheck rides on the scheduled relogin instead of a timer of its own, so it needs +`MaxTokenTTL` on the auth method: without it Consul issues a token that never expires, nothing is scheduled, and +nothing is rechecked. An unknown value of `spring.cloud.consul.config.login.mode` fails the startup. diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java index 3d985b3751..5f42894d82 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java @@ -6,13 +6,18 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; /** - * Probes the kubernetes way and falls back to m2m if the probe fails. The choice sticks until the pod restarts, so a - * later failure never switches the way back, and the pod logs one {@code INFO} record telling which way it settled on. - * The whole class goes away with the m2m way, which is why it names the pair it serves instead of taking two - * interchangeable providers. + * Probes the kubernetes way and falls back to m2m if the probe fails. The fallback is temporary: every so often the + * pod tries the kubernetes way again, and the first success switches it over for good. Going back to m2m never + * happens, so the state is a ratchet, and a later failure of the kubernetes way is a plain failure. + * + *

The recheck rides on the scheduled relogin rather than on a timer of its own, so it happens only while someone + * asks for tokens. The whole class goes away with the m2m way, which is why it names the pair it serves instead of + * taking two interchangeable providers. */ final class KubernetesWithM2MFallbackTokenProvider implements ConsulTokenProvider { @@ -29,43 +34,73 @@ final class KubernetesWithM2MFallbackTokenProvider implements ConsulTokenProvide private final ConsulTokenProvider m2mProvider; private final int tries; private final Duration probeDelay; + private final Duration recheckInterval; + private final Clock clock; - private volatile ConsulTokenProvider chosen; + private volatile boolean kubernetesConfirmed; + private Instant fellBackAt; - KubernetesWithM2MFallbackTokenProvider(ConsulTokenProvider kubernetesProvider, ConsulTokenProvider m2mProvider) { - this(kubernetesProvider, m2mProvider, PROBE_TRIES, DEFAULT_PROBE_PAUSE); + KubernetesWithM2MFallbackTokenProvider(ConsulTokenProvider kubernetesProvider, ConsulTokenProvider m2mProvider, + Duration recheckInterval) { + this(kubernetesProvider, m2mProvider, PROBE_TRIES, DEFAULT_PROBE_PAUSE, recheckInterval, Clock.systemUTC()); } - KubernetesWithM2MFallbackTokenProvider(ConsulTokenProvider kubernetesProvider, ConsulTokenProvider m2mProvider, int tries, Duration probeDelay) { + KubernetesWithM2MFallbackTokenProvider(ConsulTokenProvider kubernetesProvider, ConsulTokenProvider m2mProvider, + int tries, Duration probeDelay, Duration recheckInterval, Clock clock) { this.kubernetesProvider = kubernetesProvider; this.m2mProvider = m2mProvider; this.tries = tries; this.probeDelay = probeDelay; + this.recheckInterval = recheckInterval; + this.clock = clock; } /** - * Returns a token from the way already chosen, or, on the first call, chooses one. The probe spends fewer attempts - * than the scheduler so that an unmigrated pod pays little for it. Any {@link Exception} out of the kubernetes way - * means the fallback; an {@link Error} passes through untouched. + * Returns a token from the kubernetes way once it is confirmed, and otherwise probes it whenever the recheck + * interval has passed since the last failure. The probe spends fewer attempts than the scheduler so that an + * unmigrated pod pays little for it. Any {@link Exception} out of the kubernetes way means the m2m way for now; + * an {@link Error} passes through untouched. */ @Override public synchronized Token getToken() throws IOException { - if (chosen != null) { - return chosen.getToken(); + if (kubernetesConfirmed) { + return kubernetesProvider.getToken(); } - try { - Token token = probe(); - chosen = kubernetesProvider; + if (recheckIsDue()) { + try { + Token token = probe(); + confirmKubernetes(); + return token; + } catch (Exception e) { + fallBack(e); + } + } + return m2mProvider.getToken(); + } + + private boolean recheckIsDue() { + return fellBackAt == null || !clock.instant().isBefore(fellBackAt.plus(recheckInterval)); + } + + private void confirmKubernetes() { + boolean afterFallback = fellBackAt != null; + kubernetesConfirmed = true; + if (afterFallback) { + log.info("Consul ACL token is obtained by the {} auth method from now on, the fallback to the {} one is over", + KUBERNETES_WAY, M2M_WAY); + } else { log.info("Consul ACL token is obtained by the {} auth method", KUBERNETES_WAY); - return token; - } catch (Exception e) { - chosen = m2mProvider; - log.info("Consul login by the {} auth method failed, falling back to the {} one until the pod restarts: {}", - KUBERNETES_WAY, M2M_WAY, describe(e)); - return m2mProvider.getToken(); } } + private void fallBack(Exception e) { + if (fellBackAt == null) { + log.info("Consul login by the {} auth method failed, falling back to the {} one and retrying it every {}: {}", + KUBERNETES_WAY, M2M_WAY, recheckInterval, describe(e)); + } + fellBackAt = clock.instant(); + } + private Token probe() throws IOException { try { return Failsafe.with(LoginRetryPolicies.onTransportFailure(tries, probeDelay) diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java index cdf9e249a1..6c7aff3fff 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java @@ -3,6 +3,7 @@ import com.netcracker.cloud.consul.provider.common.client.ConsulClient; import com.netcracker.cloud.security.core.utils.k8s.AudienceName; +import java.time.Duration; import java.util.Locale; import java.util.function.Supplier; @@ -36,7 +37,8 @@ public static ConsulTokenProvider from(ConsulClient client, CreateOptions option case KUBERNETES: return kubernetesProvider(client, options); default: - return new KubernetesWithM2MFallbackTokenProvider(kubernetesProvider(client, options), m2mProvider(client, options)); + return new KubernetesWithM2MFallbackTokenProvider(kubernetesProvider(client, options), + m2mProvider(client, options), options.fallbackRecheckInterval); } } @@ -55,6 +57,7 @@ private static ConsulTokenProvider kubernetesProvider(ConsulClient client, Creat public static class CreateOptions { public static final String DEFAULT_AUTH_METHOD = "kubernetes-auth-method-placeholder"; + public static final Duration DEFAULT_FALLBACK_RECHECK_INTERVAL = Duration.ofHours(5); String consulUrl; String namespace; @@ -62,6 +65,7 @@ public static class CreateOptions { ConsulLoginMode mode; String authMethod; String audience; + Duration fallbackRecheckInterval; public ConsulLoginMode getMode() { return mode; @@ -75,6 +79,10 @@ public String getAudience() { return audience; } + public Duration getFallbackRecheckInterval() { + return fallbackRecheckInterval; + } + public static class Builder { CreateOptions options = new CreateOptions(); @@ -111,6 +119,11 @@ public Builder audience(String audience) { return this; } + public Builder fallbackRecheckInterval(Duration interval) { + options.fallbackRecheckInterval = interval; + return this; + } + /** * Applies the defaults and checks the inputs the mode needs. Defaults live here rather than in the entry * points so that an external caller of the builder gets them too. @@ -127,6 +140,9 @@ public CreateOptions build() { if (options.audience == null) { options.audience = AudienceName.NETCRACKER; } + if (options.fallbackRecheckInterval == null) { + options.fallbackRecheckInterval = DEFAULT_FALLBACK_RECHECK_INTERVAL; + } require(options.consulUrl != null, "consulUrl", options.mode); if (options.mode != ConsulLoginMode.KUBERNETES) { require(options.namespace != null, "namespace", options.mode); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java index a85ab1afc5..f2d701e328 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java @@ -72,20 +72,34 @@ synchronized public void watch(Consumer updater, String currentSecretId) } if (token.getExpirationTime() != null) { - long delay = reloginDelaySeconds(token.getExpirationTime()); - Runnable task = () -> { - log.debug("Get new consul token with {} retry attempts", tries); - try { - Token newToken = withRetry(tokenProvider::getToken, tries); - updater.accept(newToken.getSecretId()); - } catch (Exception e) { - log.error("Error occurred during getting new consul token. Will try in {} second.", delay, e); - } - }; - executor.scheduleWithFixedDelay(task, delay, delay, TimeUnit.SECONDS); + scheduleRelogin(updater, reloginDelaySeconds(token.getExpirationTime())); } } + /** + * Schedules one relogin and, from its result, the next one. The period follows the expiration of the token just + * received rather than of the first one: the way of obtaining the token can change while the pod lives, and the + * two auth methods carry different {@code MaxTokenTTL}. A failed relogin keeps the previous period, and a token + * without an expiration ends the schedule. + */ + private void scheduleRelogin(Consumer updater, long delay) { + executor.schedule(() -> { + log.debug("Get new consul token with {} retry attempts", tries); + try { + Token newToken = withRetry(tokenProvider::getToken, tries); + updater.accept(newToken.getSecretId()); + if (newToken.getExpirationTime() == null) { + log.debug("Consul token has no expiration time, stop refreshing"); + return; + } + scheduleRelogin(updater, reloginDelaySeconds(newToken.getExpirationTime())); + } catch (Exception e) { + log.error("Error occurred during getting new consul token. Will try in {} seconds.", delay, e); + scheduleRelogin(updater, delay); + } + }, delay, TimeUnit.SECONDS); + } + /** * Returns the delay before the next relogin as a share of the remaining lifetime. The same value serves as the * period of the schedule, so a constant offset from the expiration would degenerate on short-lived tokens. The diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java index 4ee039eff9..98cbe312d5 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java @@ -10,7 +10,11 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.List; import java.util.stream.Collectors; @@ -35,8 +39,11 @@ class KubernetesWithM2MFallbackTokenProviderTest { "Post \"https://kubernetes.default.svc/apis/authentication.k8s.io/v1/tokenreviews\": " + "dial tcp 10.96.0.1:443: connect: connection refused"; + private static final Duration RECHECK_INTERVAL = Duration.ofHours(5); + private ConsulTokenProvider kubernetes; private ConsulTokenProvider m2m; + private TestClock clock; private ListAppender appender; private ch.qos.logback.classic.Logger logger; @@ -44,6 +51,7 @@ class KubernetesWithM2MFallbackTokenProviderTest { void init() { kubernetes = mock(ConsulTokenProvider.class); m2m = mock(ConsulTokenProvider.class); + clock = new TestClock(Instant.parse("2026-08-26T07:00:00Z")); logger = ((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(KubernetesWithM2MFallbackTokenProvider.class); appender = new ListAppender<>(); @@ -58,7 +66,7 @@ void detachAppender() { } private KubernetesWithM2MFallbackTokenProvider probing() { - return new KubernetesWithM2MFallbackTokenProvider(kubernetes, m2m, 2, Duration.ZERO); + return new KubernetesWithM2MFallbackTokenProvider(kubernetes, m2m, 2, Duration.ZERO, RECHECK_INTERVAL, clock); } private List infoRecords() { @@ -109,7 +117,7 @@ void probeSpendsFewerTriesThanTokenUpdater() throws IOException { "login to consul failed: response code=500; body='" + TOKEN_REVIEW_UNREACHABLE + "'")); when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); - new KubernetesWithM2MFallbackTokenProvider(kubernetes, m2m).getToken(); + new KubernetesWithM2MFallbackTokenProvider(kubernetes, m2m, RECHECK_INTERVAL).getToken(); verify(kubernetes, times(KubernetesWithM2MFallbackTokenProvider.PROBE_TRIES)).getToken(); } @@ -184,4 +192,115 @@ void secretsNeverReachTheLog() throws IOException { assertFalse(logged.contains(BEARER_TOKEN)); assertFalse(logged.contains(SECRET_ID)); } + + private static final class TestClock extends Clock { + + private Instant now; + + private TestClock(Instant now) { + this.now = now; + } + + private void advance(Duration duration) { + now = now.plus(duration); + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now; + } + } + + @Test + void fallbackIsNotRecheckedBeforeTheIntervalPasses() throws IOException { + when(kubernetes.getToken()).thenThrow(new IllegalArgumentException("Unknown token audience: netcracker")); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); + + KubernetesWithM2MFallbackTokenProvider provider = probing(); + provider.getToken(); + clock.advance(RECHECK_INTERVAL.minusMinutes(1)); + provider.getToken(); + + verify(kubernetes, times(1)).getToken(); + verify(m2m, times(2)).getToken(); + } + + @Test + void fallbackIsRecheckedOnceTheIntervalPasses() throws IOException { + when(kubernetes.getToken()).thenThrow(new IllegalArgumentException("Unknown token audience: netcracker")); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); + + KubernetesWithM2MFallbackTokenProvider provider = probing(); + provider.getToken(); + clock.advance(RECHECK_INTERVAL); + provider.getToken(); + + verify(kubernetes, times(2)).getToken(); + verify(m2m, times(2)).getToken(); + } + + @Test + void successfulRecheckSwitchesToTheNewWayForGood() throws IOException { + Token kubernetesToken = new Token("kubernetes-secret-id", null); + when(kubernetes.getToken()) + .thenThrow(new IllegalArgumentException("Unknown token audience: netcracker")) + .thenReturn(kubernetesToken); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); + + KubernetesWithM2MFallbackTokenProvider provider = probing(); + provider.getToken(); + clock.advance(RECHECK_INTERVAL); + + assertEquals(kubernetesToken, provider.getToken()); + assertEquals(kubernetesToken, provider.getToken()); + verify(m2m, times(1)).getToken(); + } + + @Test + void failedRecheckKeepsTheOldWayAndPostponesTheNextOne() throws IOException { + when(kubernetes.getToken()).thenThrow(new IllegalArgumentException("Unknown token audience: netcracker")); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); + + KubernetesWithM2MFallbackTokenProvider provider = probing(); + provider.getToken(); + clock.advance(RECHECK_INTERVAL); + provider.getToken(); + clock.advance(RECHECK_INTERVAL.minusMinutes(1)); + provider.getToken(); + + verify(kubernetes, times(2)).getToken(); + verify(m2m, times(3)).getToken(); + } + + @Test + void onlyTheSwitchAddsASecondLogRecord() throws IOException { + when(kubernetes.getToken()) + .thenThrow(new IllegalArgumentException("Unknown token audience: netcracker")) + .thenThrow(new IllegalArgumentException("Unknown token audience: netcracker")) + .thenReturn(new Token("kubernetes-secret-id", null)); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); + + KubernetesWithM2MFallbackTokenProvider provider = probing(); + provider.getToken(); + clock.advance(RECHECK_INTERVAL); + provider.getToken(); + assertEquals(1, infoRecords().size(), infoRecords().toString()); + + clock.advance(RECHECK_INTERVAL); + provider.getToken(); + + List records = infoRecords(); + assertEquals(2, records.size(), records.toString()); + assertTrue(records.get(1).contains("kubernetes"), records.get(1)); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java index 45fdd5dc0c..dd4e79e711 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java @@ -8,6 +8,7 @@ import org.mockito.ArgumentCaptor; import java.io.IOException; +import java.time.Duration; import java.util.concurrent.atomic.AtomicInteger; import static org.mockito.ArgumentMatchers.any; @@ -217,4 +218,26 @@ void fallbackModeFallsBackToM2MCredentials() throws IOException { Assertions.assertInstanceOf(KubernetesLoginCredentials.class, used.getAllValues().get(0)); Assertions.assertInstanceOf(M2MLoginCredentials.class, used.getAllValues().get(used.getAllValues().size() - 1)); } + + @Test + void builderSuppliesTheFallbackRecheckIntervalDefault() { + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .mode(ConsulLoginMode.KUBERNETES) + .build(); + + Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_FALLBACK_RECHECK_INTERVAL, + opts.getFallbackRecheckInterval()); + } + + @Test + void fallbackRecheckIntervalIsTakenFromTheCallerWhenGiven() { + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .mode(ConsulLoginMode.KUBERNETES) + .fallbackRecheckInterval(Duration.ofMinutes(30)) + .build(); + + Assertions.assertEquals(Duration.ofMinutes(30), opts.getFallbackRecheckInterval()); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java index 4f351689dd..82c02dec4c 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java @@ -14,6 +14,7 @@ import java.time.ZoneId; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -48,9 +49,8 @@ void mustGetNewTokenScheduleUpdates() throws IOException { tokenUpdater.watch(updater::set, ""); assertEquals(secretId, updater.get()); - verify(scheduledExecutorService).scheduleWithFixedDelay( - any(), - eq(1440L), + verify(scheduledExecutorService).schedule( + any(Runnable.class), eq(1440L), eq(TimeUnit.SECONDS) ); @@ -65,9 +65,8 @@ void mustUseSelfTokenIfProvidedScheduleUpdates() throws IOException { AtomicReference updater = new AtomicReference<>(""); tokenUpdater.watch(updater::set, secretId); - verify(scheduledExecutorService).scheduleWithFixedDelay( - any(), - eq(1440L), + verify(scheduledExecutorService).schedule( + any(Runnable.class), eq(1440L), eq(TimeUnit.SECONDS) ); @@ -99,15 +98,11 @@ void scheduledTaskMustRetryOnFailure() throws IOException { AtomicReference updater = new AtomicReference<>(""); - when(scheduledExecutorService.scheduleWithFixedDelay(any(), - eq(1440L), - eq(1440L), - eq(TimeUnit.SECONDS))).thenAnswer(invocationOnMock -> { - assertEquals(secretId, updater.get()); - Runnable task = invocationOnMock.getArgument(0); - Assertions.assertDoesNotThrow(() -> task.run()); - return null; - }); + when(scheduledExecutorService.schedule(any(Runnable.class), eq(1440L), eq(TimeUnit.SECONDS))) + .thenAnswer(invocationOnMock -> { + assertEquals(secretId, updater.get()); + return null; + }); tokenUpdater.watch(updater::set, ""); assertEquals(secretId, updater.get()); @@ -123,12 +118,7 @@ void scheduledTaskLoginsThroughTheSameConsulLogin() throws IOException { .thenReturn(new Token(rotatedSecretId, secretExpirationTime)); AtomicReference updater = new AtomicReference<>(""); - when(scheduledExecutorService.scheduleWithFixedDelay(any(), anyLong(), anyLong(), eq(TimeUnit.SECONDS))) - .thenAnswer(invocationOnMock -> { - Runnable task = invocationOnMock.getArgument(0); - task.run(); - return null; - }); + runScheduledTaskOnce(); tokenUpdater.watch(updater::set, ""); @@ -147,7 +137,7 @@ private long scheduledDelay(Instant now, OffsetDateTime expirationTime) throws I }, ""); ArgumentCaptor delay = ArgumentCaptor.forClass(Long.class); - verify(executor).scheduleWithFixedDelay(any(), delay.capture(), anyLong(), eq(TimeUnit.SECONDS)); + verify(executor).schedule(any(Runnable.class), delay.capture(), eq(TimeUnit.SECONDS)); return delay.getValue(); } @@ -190,4 +180,65 @@ void retryWaitsBackoffDelayBetweenAttempts() throws IOException { Assertions.assertTrue(elapsed >= lowestJitteredDelay, "expected a backoff delay of at least " + lowestJitteredDelay + " ns between retries, got " + elapsed); } + + private void runScheduledTaskOnce() { + AtomicInteger runs = new AtomicInteger(); + when(scheduledExecutorService.schedule(any(Runnable.class), anyLong(), eq(TimeUnit.SECONDS))) + .thenAnswer(invocationOnMock -> { + if (runs.getAndIncrement() == 0) { + Runnable task = invocationOnMock.getArgument(0); + task.run(); + } + return null; + }); + } + + @Test + void nextReloginIsScheduledByTheExpirationOfTheNewToken() throws IOException { + OffsetDateTime firstExpiration = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); + OffsetDateTime secondExpiration = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(10); + when(tokenProvider.getToken()) + .thenReturn(new Token("test-token", firstExpiration)) + .thenReturn(new Token("test-rotated-token", secondExpiration)); + + runScheduledTaskOnce(); + tokenUpdater.watch(unused -> { + }, ""); + + ArgumentCaptor delays = ArgumentCaptor.forClass(Long.class); + verify(scheduledExecutorService, times(2)).schedule(any(Runnable.class), delays.capture(), eq(TimeUnit.SECONDS)); + assertEquals(1440L, delays.getAllValues().get(0)); + assertEquals(480L, delays.getAllValues().get(1)); + } + + @Test + void aFailedReloginKeepsTheSchedule() throws IOException { + OffsetDateTime expiration = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); + when(tokenProvider.getToken()) + .thenReturn(new Token("test-token", expiration)) + .thenThrow(new IOException()) + .thenThrow(new IOException()); + + runScheduledTaskOnce(); + tokenUpdater.watch(unused -> { + }, ""); + + ArgumentCaptor delays = ArgumentCaptor.forClass(Long.class); + verify(scheduledExecutorService, times(2)).schedule(any(Runnable.class), delays.capture(), eq(TimeUnit.SECONDS)); + assertEquals(1440L, delays.getAllValues().get(1)); + } + + @Test + void aTokenWithoutExpirationStopsTheSchedule() throws IOException { + OffsetDateTime expiration = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); + when(tokenProvider.getToken()) + .thenReturn(new Token("test-token", expiration)) + .thenReturn(new Token("test-endless-token", null)); + + runScheduledTaskOnce(); + tokenUpdater.watch(unused -> { + }, ""); + + verify(scheduledExecutorService, times(1)).schedule(any(Runnable.class), anyLong(), eq(TimeUnit.SECONDS)); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java index 85e230b4ac..3d9abceb12 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java @@ -1,6 +1,8 @@ package com.netcracker.cloud.consul.provider.spring.common.config; import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; + +import java.time.Duration; import org.springframework.boot.context.properties.ConfigurationProperties; /** @@ -17,6 +19,7 @@ public class ConsulLoginProperties { private ConsulLoginMode mode; private String authMethod; private String audience; + private Duration fallbackRecheckInterval; public ConsulLoginMode getMode() { return mode; @@ -41,4 +44,12 @@ public String getAudience() { public void setAudience(String audience) { this.audience = audience; } + + public Duration getFallbackRecheckInterval() { + return fallbackRecheckInterval; + } + + public void setFallbackRecheckInterval(Duration fallbackRecheckInterval) { + this.fallbackRecheckInterval = fallbackRecheckInterval; + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java index dd6399c708..b64b1f118b 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java @@ -67,7 +67,8 @@ protected ConsulConfigProperties loadConfigProperties(ConfigDataLocationResolver .consulUrl(Utils.formatConsulAddress(properties)) .mode(login.getMode()) .authMethod(login.getAuthMethod()) - .audience(login.getAudience()); + .audience(login.getAudience()) + .fallbackRecheckInterval(login.getFallbackRecheckInterval()); if (login.getMode() != ConsulLoginMode.KUBERNETES) { options.namespace(getPropsOrEnvsMust(args(PROP_CLOUD_NAMESPACE), args(ENV_NAMESPACE, ENV_CLOUD_NAMESPACE))) .m2mSupplier(m2mTokenSupplier); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java index b9ecde37ed..0c3b9509e2 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java @@ -53,6 +53,7 @@ static TokenStorageFactory.CreateOptions createOptions(ConsulLoginProperties log .mode(loginProperties.getMode()) .authMethod(loginProperties.getAuthMethod()) .audience(loginProperties.getAudience()) + .fallbackRecheckInterval(loginProperties.getFallbackRecheckInterval()) .build(); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java index 3282f0f57b..7a8dc3748b 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java @@ -11,6 +11,8 @@ import org.mockito.Mockito; import org.springframework.cloud.consul.ConsulProperties; +import java.time.Duration; + class ConsulM2MRestTemplateAutoConfigurationTest { private final ConsulLoginProperties loginProperties = new ConsulLoginProperties(); @@ -73,4 +75,17 @@ void authMethodAndAudienceAreReadFromTheConfiguration() { Assertions.assertEquals("core-k8s", opts.getAuthMethod()); Assertions.assertEquals(AudienceName.DBAAS, opts.getAudience()); } + + @Test + void fallbackRecheckIntervalDefaultComesFromTheBuilder() { + Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_FALLBACK_RECHECK_INTERVAL, + options().getFallbackRecheckInterval()); + } + + @Test + void fallbackRecheckIntervalIsReadFromTheConfiguration() { + loginProperties.setFallbackRecheckInterval(Duration.ofMinutes(30)); + + Assertions.assertEquals(Duration.ofMinutes(30), options().getFallbackRecheckInterval()); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java index e2cb68e3f6..d2262c99f9 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java @@ -49,6 +49,7 @@ static TokenStorageFactory.CreateOptions createOptions(ConsulLoginProperties log .mode(loginProperties.getMode()) .authMethod(loginProperties.getAuthMethod()) .audience(loginProperties.getAudience()) + .fallbackRecheckInterval(loginProperties.getFallbackRecheckInterval()) .build(); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java index 43a8637336..d35cf687a5 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java @@ -11,6 +11,8 @@ import org.mockito.Mockito; import org.springframework.cloud.consul.ConsulProperties; +import java.time.Duration; + class ConsulM2MWebClientAutoConfigurationTest { private final ConsulLoginProperties loginProperties = new ConsulLoginProperties(); @@ -73,4 +75,17 @@ void authMethodAndAudienceAreReadFromTheConfiguration() { Assertions.assertEquals("core-k8s", opts.getAuthMethod()); Assertions.assertEquals(AudienceName.DBAAS, opts.getAudience()); } + + @Test + void fallbackRecheckIntervalDefaultComesFromTheBuilder() { + Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_FALLBACK_RECHECK_INTERVAL, + options().getFallbackRecheckInterval()); + } + + @Test + void fallbackRecheckIntervalIsReadFromTheConfiguration() { + loginProperties.setFallbackRecheckInterval(Duration.ofMinutes(30)); + + Assertions.assertEquals(Duration.ofMinutes(30), options().getFallbackRecheckInterval()); + } } From fce487acad08fc4b2dd324b5f35b82fe1a7f6c89 Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Fri, 28 Aug 2026 11:02:01 +0400 Subject: [PATCH 09/20] fix(consul): log which auth method issued the ACL token on the spring stack ConsulRestClient logged the login at debug while ConsulOkHttpClient logged it at info, so a Quarkus pod named its auth method and a Spring pod did not. Pods that reuse a token obtained in the ConfigData phase never log in at all, so raising the level alone would still leave them silent; SelfTokenReader now names the auth method taken from the answer of Consul, which is the method that actually issued the token in use. Consul omits AuthMethod for a token no login produced, and that case reads as unknown rather than failing the parse. --- .../cloud/consul/provider/common/SelfTokenReader.java | 10 +++++++++- .../provider/common/client/ConsulRestClient.java | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java index 4584c83471..0de3cf450e 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java @@ -44,7 +44,15 @@ public Token read(String currentSecretId) throws IOException { } catch (PathNotFoundException ex) { // No Expiration Time. Nothing to do. } - log.debug("Got self token from Consul"); + log.info("Got self token from Consul, issued by the {} auth method", readAuthMethod(bodyJson)); return new Token(secretId, expirationTime); } + + private static String readAuthMethod(String bodyJson) { + try { + return JsonPath.read(bodyJson, "$.AuthMethod"); + } catch (PathNotFoundException ex) { + return "unknown"; + } + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java index 31a7177e9d..c36776f780 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java @@ -57,7 +57,7 @@ public ConsulClientResponse login(String authMethod) { Map> headers = new HashMap<>(); headers.put(CONTENT_TYPE, Collections.singletonList(APPLICATION_JSON)); - log.debug("Perform login to {} with {} auth method", consulAddr, authMethod); + log.info("Perform login to {} with {} auth method", consulAddr, authMethod); RestClientResponseEntity response = client.doRequest(consulAddr + V1_ACL_LOGIN, HttpMethod.POST, headers, json, String.class); return new ConsulClientResponse(response.getResponseBody(), response.getHttpStatus()); } @@ -72,7 +72,7 @@ public ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOE Map> headers = new HashMap<>(); headers.put(CONTENT_TYPE, Collections.singletonList(APPLICATION_JSON)); - log.debug("Perform login to {} with {} auth method", consulAddr, credentials.getAuthMethod()); + log.info("Perform login to {} with {} auth method", consulAddr, credentials.getAuthMethod()); RestClientResponseEntity response; try { response = client.doRequest(consulAddr + V1_ACL_LOGIN, HttpMethod.POST, headers, json, String.class); From 787d9a9f4aa5c527ec97c9edc64aeb5c598456b5 Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Fri, 28 Aug 2026 12:09:20 +0400 Subject: [PATCH 10/20] chore: minor refactoring --- .../config-sources/consul-client/README.md | 4 ++-- core-rest-libraries/consul-config-provider/README.md | 2 +- .../common/KubernetesWithM2MFallbackTokenProvider.java | 3 ++- .../cloud/consul/provider/common/LoginTokenProvider.java | 2 +- .../cloud/consul/provider/common/SelfTokenReader.java | 7 +++++-- .../spring/common/config/ConsulLoginProperties.java | 1 - .../common/config/ConsulM2MConfigDataLocationResolver.java | 2 +- 7 files changed, 12 insertions(+), 9 deletions(-) diff --git a/core-quarkus-extensions/config-sources/consul-client/README.md b/core-quarkus-extensions/config-sources/consul-client/README.md index 53f852b275..c71e8ed466 100644 --- a/core-quarkus-extensions/config-sources/consul-client/README.md +++ b/core-quarkus-extensions/config-sources/consul-client/README.md @@ -41,7 +41,7 @@ quarkus.consul-source-config.m2m.enabled=false #### Login properties The Consul ACL token is exchanged through `POST /v1/acl/login`. Two ways to obtain it are supported: `kubernetes` sends -the projected service account token of the pod, `m2m` sends an M2M token. The three properties below are read at runtime +the projected service account token of the pod, `m2m` sends an M2M token. The four properties below are read at runtime when the `TokenStorage` bean is built, so the way can be switched without rebuilding the application. In `kubernetes-with-m2m-fallback` mode the `kubernetes` way is tried first, and on failure the pod falls back to the @@ -55,7 +55,7 @@ recheck rides on the scheduled relogin, so it needs `MaxTokenTTL` on the auth me expires, nothing is scheduled, and nothing is rechecked. An unknown value of `quarkus.consul-source-config.login.mode` fails the startup. With -`quarkus.consul-source-config.m2m.enabled=false` none of the three properties are read. +`quarkus.consul-source-config.m2m.enabled=false` none of the four properties are read. The default auth method name is a placeholder. Set `quarkus.consul-source-config.login.auth-method` to the name of the auth method registered in your Consul. diff --git a/core-rest-libraries/consul-config-provider/README.md b/core-rest-libraries/consul-config-provider/README.md index 873913a344..50bff28129 100644 --- a/core-rest-libraries/consul-config-provider/README.md +++ b/core-rest-libraries/consul-config-provider/README.md @@ -11,7 +11,7 @@ Two ways to obtain the token are supported: ## Login properties -The three properties below are read at startup, in the ConfigData phase and again when the `TokenStorage` bean is +The four properties below are read at startup, in the ConfigData phase and again when the `TokenStorage` bean is built. In `kubernetes-with-m2m-fallback` mode the first login picks the way, and a later failure never switches it back on its own. diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java index 5f42894d82..1164b38571 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java @@ -108,7 +108,8 @@ private Token probe() throws IOException { event.getAttemptCount(), KUBERNETES_WAY, event.getLastFailure()))) .get(kubernetesProvider::getToken); } catch (FailsafeException e) { - throw (IOException) e.getCause(); + Throwable cause = e.getCause(); + throw cause instanceof IOException ? (IOException) cause : new IOException(cause); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java index 3de9cf9d4d..9ab1f1b118 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java @@ -37,7 +37,7 @@ public Token getToken() throws IOException { ConsulClientResponse response = client.login(credentials); String responseBody = response.getBodyJson(); if (responseBody == null || responseBody.isEmpty()) { - throw new IOException("can not get consul token by m2m token: response body is empty"); + throw new IOException("can not get consul token: response body is empty"); } if (response.getCode() != 200) { String reason = response.getCode() == 403 ? "consul auth method is not ready" : "login to consul failed"; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java index 0de3cf450e..1ee464986b 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java @@ -28,7 +28,10 @@ public SelfTokenReader(ConsulClient client) { * Reads the token behind {@code currentSecretId}. A token without an expiration is valid: Consul omits the field * for an auth method without {@code MaxTokenTTL}. * - * @throws IOException on a transport failure or a non-2xx answer + * @throws IOException on a non-2xx answer; the caller may retry + * @throws RuntimeException on a transport failure, in whatever type the client throws. Unlike {@link + * ConsulClient#login(ConsulLoginCredentials)}, {@link ConsulClient#getSelfToken(String)} does not report + * it as an {@link IOException}, so the retry policies of the module do not cover it */ public Token read(String currentSecretId) throws IOException { ConsulClientResponse response = client.getSelfToken(currentSecretId); @@ -47,7 +50,7 @@ public Token read(String currentSecretId) throws IOException { log.info("Got self token from Consul, issued by the {} auth method", readAuthMethod(bodyJson)); return new Token(secretId, expirationTime); } - + private static String readAuthMethod(String bodyJson) { try { return JsonPath.read(bodyJson, "$.AuthMethod"); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java index 3d9abceb12..a5aca5e30f 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java @@ -10,7 +10,6 @@ * belong to {@code TokenStorageFactory.CreateOptions.Builder}, so that an external caller of the builder gets them * too. */ -//todo vlla lombok? @ConfigurationProperties(prefix = ConsulLoginProperties.PREFIX) public class ConsulLoginProperties { diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java index b64b1f118b..e4e246b48f 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java @@ -76,7 +76,7 @@ protected ConsulConfigProperties loadConfigProperties(ConfigDataLocationResolver consulConfigProperties.setAclToken(TokenStorageFactory.from(client, options.build()).getToken().getSecretId()); } catch (IOException e) { - log.error("can not get consul token by m2m: ", e); + log.error("can not get consul token: ", e); } registerAndPromoteBean(resolverContext, ConsulProperties.class, BootstrapRegistry.InstanceSupplier.of(properties)); return consulConfigProperties; From 99c95a44c9f62b03f0386d4c08445945ab7bdd2d Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Fri, 28 Aug 2026 17:13:14 +0400 Subject: [PATCH 11/20] fix(consul): keep the login way across phases and relogin before expiry A failed relogin repeated the previous delay, so on a short-lived token the retry landed well past the expiration. Every delay is now measured against the expiration of the token the pod holds right now. The spring stack built a second provider for the TokenStorage bean and lost the way the ConfigData phase had already picked, so one transient failure sent a migrated pod back to m2m. Reading the existing token moved onto ConsulTokenProvider, which recovers the way from its AuthMethod. --- .../consul-config-provider/README.md | 4 +- .../provider/common/ConsulTokenProvider.java | 8 ++ ...ubernetesWithM2MFallbackTokenProvider.java | 37 +++++++++- .../provider/common/LoginTokenProvider.java | 7 ++ .../provider/common/SelfTokenReader.java | 7 +- .../cloud/consul/provider/common/Token.java | 14 ++++ .../provider/common/TokenStorageFactory.java | 4 +- .../consul/provider/common/TokenUpdater.java | 35 +++++---- ...netesWithM2MFallbackTokenProviderTest.java | 74 ++++++++++++++++++- .../provider/common/SelfTokenReaderTest.java | 24 ++++++ .../provider/common/TokenUpdaterTest.java | 73 +++++++++++++++--- 11 files changed, 246 insertions(+), 41 deletions(-) diff --git a/core-rest-libraries/consul-config-provider/README.md b/core-rest-libraries/consul-config-provider/README.md index 50bff28129..a8bc131c72 100644 --- a/core-rest-libraries/consul-config-provider/README.md +++ b/core-rest-libraries/consul-config-provider/README.md @@ -29,7 +29,9 @@ mode the auth method name and the audience are not read at all. The fallback is temporary. Once `fallback-recheck-interval` has passed, the next scheduled relogin tries the `kubernetes` way again, and the first success switches the pod over for good, with a second `INFO` record. Going back -to `m2m` never happens. The recheck rides on the scheduled relogin instead of a timer of its own, so it needs +to `m2m` never happens. The choice survives the two phases of the startup: the `TokenStorage` bean reads the auth +method of the token the ConfigData phase already obtained, from `/v1/acl/token/self`, and picks up from there instead +of probing again. The recheck rides on the scheduled relogin instead of a timer of its own, so it needs `MaxTokenTTL` on the auth method: without it Consul issues a token that never expires, nothing is scheduled, and nothing is rechecked. diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java index c15fbafbd0..d2980f7c55 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java @@ -14,4 +14,12 @@ public interface ConsulTokenProvider { * @throws IOException on a transport failure or a non-2xx answer from Consul; the caller may retry */ Token getToken() throws IOException; + + /** + * Reads the token the pod already holds instead of obtaining a new one. Consul reports the auth method the token + * was issued to, so a provider that carries state across logins recovers it here rather than starting over. + * + * @throws IOException on a transport failure or a non-2xx answer from Consul; the caller may retry + */ + Token getSelfToken(String currentSecretId) throws IOException; } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java index 1164b38571..566797b25d 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java @@ -32,6 +32,7 @@ final class KubernetesWithM2MFallbackTokenProvider implements ConsulTokenProvide private final ConsulTokenProvider kubernetesProvider; private final ConsulTokenProvider m2mProvider; + private final String kubernetesAuthMethod; private final int tries; private final Duration probeDelay; private final Duration recheckInterval; @@ -41,14 +42,17 @@ final class KubernetesWithM2MFallbackTokenProvider implements ConsulTokenProvide private Instant fellBackAt; KubernetesWithM2MFallbackTokenProvider(ConsulTokenProvider kubernetesProvider, ConsulTokenProvider m2mProvider, - Duration recheckInterval) { - this(kubernetesProvider, m2mProvider, PROBE_TRIES, DEFAULT_PROBE_PAUSE, recheckInterval, Clock.systemUTC()); + String kubernetesAuthMethod, Duration recheckInterval) { + this(kubernetesProvider, m2mProvider, kubernetesAuthMethod, PROBE_TRIES, DEFAULT_PROBE_PAUSE, recheckInterval, + Clock.systemUTC()); } KubernetesWithM2MFallbackTokenProvider(ConsulTokenProvider kubernetesProvider, ConsulTokenProvider m2mProvider, - int tries, Duration probeDelay, Duration recheckInterval, Clock clock) { + String kubernetesAuthMethod, int tries, Duration probeDelay, + Duration recheckInterval, Clock clock) { this.kubernetesProvider = kubernetesProvider; this.m2mProvider = m2mProvider; + this.kubernetesAuthMethod = kubernetesAuthMethod; this.tries = tries; this.probeDelay = probeDelay; this.recheckInterval = recheckInterval; @@ -78,6 +82,33 @@ public synchronized Token getToken() throws IOException { return m2mProvider.getToken(); } + /** + * Reads the token through the kubernetes provider and adopts the state of whichever instance obtained it. The + * choice of provider is arbitrary: the read sends the token the pod already holds and never looks at the + * credentials, so both give the same answer. + * + *

Without the adoption a pod whose token was obtained in an earlier phase would start over: it would probe the + * kubernetes way again even after that way was confirmed, and one failing probe would send a migrated pod back to + * m2m for a whole recheck interval. + */ + @Override + public synchronized Token getSelfToken(String currentSecretId) throws IOException { + Token token = kubernetesProvider.getSelfToken(currentSecretId); + adopt(token.getAuthMethod()); + return token; + } + + private void adopt(String authMethod) { + if (kubernetesConfirmed || fellBackAt != null || authMethod == null) { + return; + } + if (kubernetesAuthMethod.equals(authMethod)) { + confirmKubernetes(); + } else { + fellBackAt = clock.instant(); + } + } + private boolean recheckIsDue() { return fellBackAt == null || !clock.instant().isBefore(fellBackAt.plus(recheckInterval)); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java index 9ab1f1b118..8e321f4804 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java @@ -20,10 +20,12 @@ public class LoginTokenProvider implements ConsulTokenProvider { private final ConsulClient client; private final ConsulLoginCredentials credentials; + private final SelfTokenReader selfTokenReader; public LoginTokenProvider(ConsulClient client, ConsulLoginCredentials credentials) { this.client = client; this.credentials = credentials; + this.selfTokenReader = new SelfTokenReader(client); } /** @@ -53,4 +55,9 @@ public Token getToken() throws IOException { log.debug("Got new token from Consul by login procedure"); return new Token(secretId, expirationTime); } + + @Override + public Token getSelfToken(String currentSecretId) throws IOException { + return selfTokenReader.read(currentSecretId); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java index 1ee464986b..ba92a85cb7 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java @@ -47,15 +47,16 @@ public Token read(String currentSecretId) throws IOException { } catch (PathNotFoundException ex) { // No Expiration Time. Nothing to do. } - log.info("Got self token from Consul, issued by the {} auth method", readAuthMethod(bodyJson)); - return new Token(secretId, expirationTime); + String authMethod = readAuthMethod(bodyJson); + log.info("Got self token from Consul, issued by the {} auth method", authMethod == null ? "unknown" : authMethod); + return new Token(secretId, expirationTime, authMethod); } private static String readAuthMethod(String bodyJson) { try { return JsonPath.read(bodyJson, "$.AuthMethod"); } catch (PathNotFoundException ex) { - return "unknown"; + return null; } } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/Token.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/Token.java index d054320dcd..569c657dc2 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/Token.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/Token.java @@ -5,10 +5,16 @@ public class Token { private final String secretId; private final OffsetDateTime expirationTime; + private final String authMethod; public Token(String secretId, OffsetDateTime expirationTime) { + this(secretId, expirationTime, null); + } + + public Token(String secretId, OffsetDateTime expirationTime, String authMethod) { this.secretId = secretId; this.expirationTime = expirationTime; + this.authMethod = authMethod; } public String getSecretId() { @@ -18,4 +24,12 @@ public String getSecretId() { public OffsetDateTime getExpirationTime() { return expirationTime; } + + /** + * The auth method Consul issued the token to, or {@code null} when it is not known. A login answer does not carry + * it; only {@code /v1/acl/token/self} does. + */ + public String getAuthMethod() { + return authMethod; + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java index 6c7aff3fff..02ce60abc2 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java @@ -19,7 +19,7 @@ protected TokenStorageFactory() { public TokenStorage create(CreateOptions config) { ConsulClient consulClient = createTokenExchanger(config); - TokenUpdater tokenUpdater = new TokenUpdater(from(consulClient, config), new SelfTokenReader(consulClient)); + TokenUpdater tokenUpdater = new TokenUpdater(from(consulClient, config)); TokenStorage tokenStorage = createTokenStorage(config); tokenUpdater.watch(tokenStorage::update, tokenStorage.get()); return tokenStorage; @@ -38,7 +38,7 @@ public static ConsulTokenProvider from(ConsulClient client, CreateOptions option return kubernetesProvider(client, options); default: return new KubernetesWithM2MFallbackTokenProvider(kubernetesProvider(client, options), - m2mProvider(client, options), options.fallbackRecheckInterval); + m2mProvider(client, options), options.authMethod, options.fallbackRecheckInterval); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java index f2d701e328..9165db4b3c 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java @@ -30,22 +30,19 @@ public class TokenUpdater { static final long MIN_DELAY_SECONDS = 10; private final ConsulTokenProvider tokenProvider; - private final SelfTokenReader selfTokenReader; private ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); private Clock clock = Clock.systemDefaultZone(); private final Integer tries; private final Duration retryPause; - public TokenUpdater(ConsulTokenProvider tokenProvider, SelfTokenReader selfTokenReader) { + public TokenUpdater(ConsulTokenProvider tokenProvider) { this.tokenProvider = tokenProvider; - this.selfTokenReader = selfTokenReader; this.tries = DEFAULT_TRIES; this.retryPause = DEFAULT_RETRY_PAUSE; } - TokenUpdater(ConsulTokenProvider tokenProvider, SelfTokenReader selfTokenReader, ScheduledExecutorService executor, Clock clock, int tries, Duration retryPause) { + TokenUpdater(ConsulTokenProvider tokenProvider, ScheduledExecutorService executor, Clock clock, int tries, Duration retryPause) { this.tokenProvider = tokenProvider; - this.selfTokenReader = selfTokenReader; this.executor = executor; this.clock = clock; this.tries = tries; @@ -68,21 +65,22 @@ synchronized public void watch(Consumer updater, String currentSecretId) token = withRetry(tokenProvider::getToken, tries); updater.accept(token.getSecretId()); } else { - token = withRetry(() -> selfTokenReader.read(currentSecretId), tries); + token = withRetry(() -> tokenProvider.getSelfToken(currentSecretId), tries); } if (token.getExpirationTime() != null) { - scheduleRelogin(updater, reloginDelaySeconds(token.getExpirationTime())); + scheduleRelogin(updater, token.getExpirationTime()); } } /** - * Schedules one relogin and, from its result, the next one. The period follows the expiration of the token just - * received rather than of the first one: the way of obtaining the token can change while the pod lives, and the - * two auth methods carry different {@code MaxTokenTTL}. A failed relogin keeps the previous period, and a token - * without an expiration ends the schedule. + * Schedules one relogin and, from its result, the next one. Every delay is measured against the expiration of the + * token the pod holds right now rather than of the first one: the way of obtaining the token can change while the + * pod lives, and the two auth methods carry different {@code MaxTokenTTL}. A failed relogin therefore shortens the + * next delay instead of repeating the previous one, which would put the retry well past the expiration on a + * short-lived token. A token without an expiration ends the schedule. */ - private void scheduleRelogin(Consumer updater, long delay) { + private void scheduleRelogin(Consumer updater, OffsetDateTime expirationTime) { executor.schedule(() -> { log.debug("Get new consul token with {} retry attempts", tries); try { @@ -92,18 +90,19 @@ private void scheduleRelogin(Consumer updater, long delay) { log.debug("Consul token has no expiration time, stop refreshing"); return; } - scheduleRelogin(updater, reloginDelaySeconds(newToken.getExpirationTime())); + scheduleRelogin(updater, newToken.getExpirationTime()); } catch (Exception e) { - log.error("Error occurred during getting new consul token. Will try in {} seconds.", delay, e); - scheduleRelogin(updater, delay); + log.error("Error occurred during getting new consul token. Will try in {} seconds.", + reloginDelaySeconds(expirationTime), e); + scheduleRelogin(updater, expirationTime); } - }, delay, TimeUnit.SECONDS); + }, reloginDelaySeconds(expirationTime), TimeUnit.SECONDS); } /** * Returns the delay before the next relogin as a share of the remaining lifetime. The same value serves as the - * period of the schedule, so a constant offset from the expiration would degenerate on short-lived tokens. The - * lower bound covers clocks that ran ahead of the expiration. + * period of the schedule, so a constant offset from the expiration would degenerate on short-lived tokens. Past + * the expiration the share turns negative, and the lower bound takes over as the retry cadence. */ private long reloginDelaySeconds(OffsetDateTime expirationTime) { long remaining = ChronoUnit.SECONDS.between(OffsetDateTime.now(clock), expirationTime); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java index 98cbe312d5..9105ff9898 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java @@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -40,6 +41,9 @@ class KubernetesWithM2MFallbackTokenProviderTest { + "dial tcp 10.96.0.1:443: connect: connection refused"; private static final Duration RECHECK_INTERVAL = Duration.ofHours(5); + private static final String KUBERNETES_AUTH_METHOD = "core-k8s"; + private static final String M2M_AUTH_METHOD = "test-namespace"; + private static final String CURRENT_SECRET_ID = "test-current-secret-id"; private ConsulTokenProvider kubernetes; private ConsulTokenProvider m2m; @@ -66,7 +70,8 @@ void detachAppender() { } private KubernetesWithM2MFallbackTokenProvider probing() { - return new KubernetesWithM2MFallbackTokenProvider(kubernetes, m2m, 2, Duration.ZERO, RECHECK_INTERVAL, clock); + return new KubernetesWithM2MFallbackTokenProvider(kubernetes, m2m, KUBERNETES_AUTH_METHOD, 2, Duration.ZERO, + RECHECK_INTERVAL, clock); } private List infoRecords() { @@ -117,7 +122,7 @@ void probeSpendsFewerTriesThanTokenUpdater() throws IOException { "login to consul failed: response code=500; body='" + TOKEN_REVIEW_UNREACHABLE + "'")); when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); - new KubernetesWithM2MFallbackTokenProvider(kubernetes, m2m, RECHECK_INTERVAL).getToken(); + new KubernetesWithM2MFallbackTokenProvider(kubernetes, m2m, KUBERNETES_AUTH_METHOD, RECHECK_INTERVAL).getToken(); verify(kubernetes, times(KubernetesWithM2MFallbackTokenProvider.PROBE_TRIES)).getToken(); } @@ -303,4 +308,69 @@ void onlyTheSwitchAddsASecondLogRecord() throws IOException { assertEquals(2, records.size(), records.toString()); assertTrue(records.get(1).contains("kubernetes"), records.get(1)); } + + @Test + void anExistingKubernetesTokenConfirmsTheNewWayWithoutAProbe() throws IOException { + Token token = new Token(SECRET_ID, null); + when(kubernetes.getSelfToken(CURRENT_SECRET_ID)).thenReturn(new Token(SECRET_ID, null, KUBERNETES_AUTH_METHOD)); + when(kubernetes.getToken()).thenReturn(token); + + KubernetesWithM2MFallbackTokenProvider probing = probing(); + probing.getSelfToken(CURRENT_SECRET_ID); + + assertEquals(token, probing.getToken()); + verifyNoInteractions(m2m); + assertEquals(1, infoRecords().size(), infoRecords().toString()); + } + + @Test + void anExistingKubernetesTokenSurvivesALaterFailureInsteadOfFallingBack() throws IOException { + when(kubernetes.getSelfToken(CURRENT_SECRET_ID)).thenReturn(new Token(SECRET_ID, null, KUBERNETES_AUTH_METHOD)); + when(kubernetes.getToken()).thenThrow(new IOException( + "login to consul failed: response code=500; body='" + TOKEN_REVIEW_UNREACHABLE + "'")); + + KubernetesWithM2MFallbackTokenProvider probing = probing(); + probing.getSelfToken(CURRENT_SECRET_ID); + + assertThrows(IOException.class, probing::getToken); + verifyNoInteractions(m2m); + } + + @Test + void anExistingM2MTokenPostponesTheProbeByTheRecheckInterval() throws IOException { + when(kubernetes.getSelfToken(CURRENT_SECRET_ID)).thenReturn(new Token(SECRET_ID, null, M2M_AUTH_METHOD)); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); + + KubernetesWithM2MFallbackTokenProvider probing = probing(); + probing.getSelfToken(CURRENT_SECRET_ID); + probing.getToken(); + + verify(kubernetes, never()).getToken(); + + clock.advance(RECHECK_INTERVAL); + when(kubernetes.getToken()).thenReturn(new Token("kubernetes-secret-id", null)); + assertEquals("kubernetes-secret-id", probing.getToken().getSecretId()); + } + + @Test + void anUnreportedAuthMethodLeavesTheProbeToTheFirstRelogin() throws IOException { + when(kubernetes.getSelfToken(CURRENT_SECRET_ID)).thenReturn(new Token(SECRET_ID, null, null)); + when(kubernetes.getToken()).thenReturn(new Token(SECRET_ID, null)); + + KubernetesWithM2MFallbackTokenProvider probing = probing(); + probing.getSelfToken(CURRENT_SECRET_ID); + + assertEquals(SECRET_ID, probing.getToken().getSecretId()); + verify(kubernetes, times(1)).getToken(); + verifyNoInteractions(m2m); + } + + @Test + void theSelfTokenIsReadThroughTheKubernetesProviderAndReturnedUntouched() throws IOException { + Token selfToken = new Token(SECRET_ID, null, KUBERNETES_AUTH_METHOD); + when(kubernetes.getSelfToken(CURRENT_SECRET_ID)).thenReturn(selfToken); + + assertEquals(selfToken, probing().getSelfToken(CURRENT_SECRET_ID)); + verifyNoInteractions(m2m); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/SelfTokenReaderTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/SelfTokenReaderTest.java index 663f97ea3d..03ad555489 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/SelfTokenReaderTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/SelfTokenReaderTest.java @@ -29,6 +29,11 @@ class SelfTokenReaderTest { "\"Local\":true,\"AuthMethod\":\"k8s-poc\",\"CreateTime\":\"2026-08-26T07:21:18.613036445Z\"," + "\"Hash\":\"test-hash\",\"CreateIndex\":52,\"ModifyIndex\":52}"; + private static final String SELF_RESPONSE_WITHOUT_AUTH_METHOD = + "{\"AccessorID\":\"test-accessor-id\",\"SecretID\":\"test-secret-id\",\"Description\":\"static token\"," + + "\"Local\":true,\"CreateTime\":\"2026-08-26T07:21:18.613036445Z\"," + + "\"Hash\":\"test-hash\",\"CreateIndex\":52,\"ModifyIndex\":52}"; + private ConsulClient consulClient; private SelfTokenReader selfTokenReader; @@ -61,6 +66,25 @@ void readSurvivesMissingExpirationTime() throws IOException { assertNull(token.getExpirationTime()); } + @Test + void readReportsTheAuthMethodThatIssuedTheToken() throws IOException { + when(consulClient.getSelfToken(anyString())) + .thenReturn(new ConsulClientResponse(SELF_RESPONSE_WITH_EXPIRATION, 200)); + + assertEquals("k8s-poc-ttl", selfTokenReader.read("test-current-secret-id").getAuthMethod()); + } + + @Test + void readSurvivesMissingAuthMethod() throws IOException { + when(consulClient.getSelfToken(anyString())) + .thenReturn(new ConsulClientResponse(SELF_RESPONSE_WITHOUT_AUTH_METHOD, 200)); + + Token token = selfTokenReader.read("test-current-secret-id"); + + assertEquals("test-secret-id", token.getSecretId()); + assertNull(token.getAuthMethod()); + } + @Test void readFailsWithIOExceptionOnNonSuccessCode() { when(consulClient.getSelfToken(anyString())) diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java index 82c02dec4c..012a6a08a3 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java @@ -26,7 +26,6 @@ class TokenUpdaterTest { private TokenUpdater tokenUpdater; private ConsulTokenProvider tokenProvider; - private SelfTokenReader selfTokenReader; private ScheduledExecutorService scheduledExecutorService; private final Instant currentTime = Instant.now(); @@ -34,9 +33,8 @@ class TokenUpdaterTest { public void init() { tokenProvider = Mockito.mock(ConsulTokenProvider.class); - selfTokenReader = Mockito.mock(SelfTokenReader.class); scheduledExecutorService = Mockito.mock(ScheduledExecutorService.class); - tokenUpdater = new TokenUpdater(tokenProvider, selfTokenReader, scheduledExecutorService, Clock.fixed(currentTime, ZoneId.of("UTC")), 2, Duration.ZERO); + tokenUpdater = new TokenUpdater(tokenProvider, scheduledExecutorService, Clock.fixed(currentTime, ZoneId.of("UTC")), 2, Duration.ZERO); } @Test @@ -60,7 +58,7 @@ void mustGetNewTokenScheduleUpdates() throws IOException { void mustUseSelfTokenIfProvidedScheduleUpdates() throws IOException { String secretId = "test-self-token"; OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); - when(selfTokenReader.read(secretId)).thenReturn(new Token(secretId, secretExpirationTime)); + when(tokenProvider.getSelfToken(secretId)).thenReturn(new Token(secretId, secretExpirationTime)); AtomicReference updater = new AtomicReference<>(""); tokenUpdater.watch(updater::set, secretId); @@ -124,7 +122,7 @@ void scheduledTaskLoginsThroughTheSameConsulLogin() throws IOException { assertEquals(rotatedSecretId, updater.get()); verify(tokenProvider, times(2)).getToken(); - verifyNoInteractions(selfTokenReader); + verify(tokenProvider, never()).getSelfToken(any()); } private long scheduledDelay(Instant now, OffsetDateTime expirationTime) throws IOException { @@ -132,8 +130,7 @@ private long scheduledDelay(Instant now, OffsetDateTime expirationTime) throws I ConsulTokenProvider login = Mockito.mock(ConsulTokenProvider.class); when(login.getToken()).thenReturn(new Token("test-token", expirationTime)); - new TokenUpdater(login, Mockito.mock(SelfTokenReader.class), executor, - Clock.fixed(now, ZoneId.of("UTC")), 2, Duration.ZERO).watch(unused -> { + new TokenUpdater(login, executor, Clock.fixed(now, ZoneId.of("UTC")), 2, Duration.ZERO).watch(unused -> { }, ""); ArgumentCaptor delay = ArgumentCaptor.forClass(Long.class); @@ -167,7 +164,7 @@ void retryWaitsBackoffDelayBetweenAttempts() throws IOException { .thenThrow(new IOException()) .thenReturn(new Token("test-token", secretExpirationTime)); - TokenUpdater updater = new TokenUpdater(tokenProvider, selfTokenReader, scheduledExecutorService, + TokenUpdater updater = new TokenUpdater(tokenProvider, scheduledExecutorService, Clock.fixed(currentTime, ZoneId.of("UTC")), 2, retryPause); long startedAt = System.nanoTime(); @@ -182,10 +179,17 @@ void retryWaitsBackoffDelayBetweenAttempts() throws IOException { } private void runScheduledTaskOnce() { + runScheduledTaskOnce(null); + } + + private void runScheduledTaskOnce(TestClock clock) { AtomicInteger runs = new AtomicInteger(); when(scheduledExecutorService.schedule(any(Runnable.class), anyLong(), eq(TimeUnit.SECONDS))) .thenAnswer(invocationOnMock -> { if (runs.getAndIncrement() == 0) { + if (clock != null) { + clock.advance(Duration.ofSeconds(invocationOnMock.getArgument(1))); + } Runnable task = invocationOnMock.getArgument(0); task.run(); } @@ -193,6 +197,34 @@ private void runScheduledTaskOnce() { }); } + private static final class TestClock extends Clock { + + private Instant now; + + private TestClock(Instant now) { + this.now = now; + } + + private void advance(Duration duration) { + now = now.plus(duration); + } + + @Override + public ZoneId getZone() { + return ZoneId.of("UTC"); + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now; + } + } + @Test void nextReloginIsScheduledByTheExpirationOfTheNewToken() throws IOException { OffsetDateTime firstExpiration = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); @@ -212,20 +244,37 @@ void nextReloginIsScheduledByTheExpirationOfTheNewToken() throws IOException { } @Test - void aFailedReloginKeepsTheSchedule() throws IOException { + void aFailedReloginRetriesBeforeTheCurrentTokenExpires() throws IOException { + TestClock clock = new TestClock(currentTime); OffsetDateTime expiration = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); when(tokenProvider.getToken()) .thenReturn(new Token("test-token", expiration)) .thenThrow(new IOException()) .thenThrow(new IOException()); + TokenUpdater updater = new TokenUpdater(tokenProvider, scheduledExecutorService, clock, 2, Duration.ZERO); - runScheduledTaskOnce(); - tokenUpdater.watch(unused -> { + runScheduledTaskOnce(clock); + updater.watch(unused -> { }, ""); ArgumentCaptor delays = ArgumentCaptor.forClass(Long.class); verify(scheduledExecutorService, times(2)).schedule(any(Runnable.class), delays.capture(), eq(TimeUnit.SECONDS)); - assertEquals(1440L, delays.getAllValues().get(1)); + assertEquals(1440L, delays.getAllValues().get(0)); + assertEquals(288L, delays.getAllValues().get(1)); + Assertions.assertTrue(delays.getAllValues().get(1) < 1800L - delays.getAllValues().get(0), + "the retry must land before the current token expires, got " + delays.getAllValues()); + } + + @Test + void anExistingSecretIdIsReadThroughTheProviderInsteadOfALogin() throws IOException { + String secretId = "test-token"; + when(tokenProvider.getSelfToken(secretId)).thenReturn(new Token(secretId, null, "core-k8s")); + + tokenUpdater.watch(unused -> { + }, secretId); + + verify(tokenProvider).getSelfToken(secretId); + verify(tokenProvider, never()).getToken(); } @Test From 30fcc9329b0fe831c4680763a81807a5432f1326 Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Mon, 31 Aug 2026 10:36:39 +0400 Subject: [PATCH 12/20] chore: minor TODOs @cf_ignore --- ...ubernetesWithM2MFallbackTokenProvider.java | 22 +++++++++---------- .../consul/provider/common/TokenUpdater.java | 8 ++++--- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java index 566797b25d..573019e38b 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java @@ -20,16 +20,14 @@ * taking two interchangeable providers. */ final class KubernetesWithM2MFallbackTokenProvider implements ConsulTokenProvider { + private static final Logger log = LoggerFactory.getLogger(KubernetesWithM2MFallbackTokenProvider.class); static final int PROBE_TRIES = 3; - static final int ERROR_MESSAGE_LIMIT = 512; - + private static final int ERROR_MESSAGE_LIMIT = 512; private static final Duration DEFAULT_PROBE_PAUSE = Duration.ofSeconds(1); private static final String KUBERNETES_WAY = "kubernetes"; private static final String M2M_WAY = "m2m"; - private static final Logger log = LoggerFactory.getLogger(KubernetesWithM2MFallbackTokenProvider.class); - private final ConsulTokenProvider kubernetesProvider; private final ConsulTokenProvider m2mProvider; private final String kubernetesAuthMethod; @@ -38,7 +36,7 @@ final class KubernetesWithM2MFallbackTokenProvider implements ConsulTokenProvide private final Duration recheckInterval; private final Clock clock; - private volatile boolean kubernetesConfirmed; + private volatile boolean isKubernetesConfirmed; private Instant fellBackAt; KubernetesWithM2MFallbackTokenProvider(ConsulTokenProvider kubernetesProvider, ConsulTokenProvider m2mProvider, @@ -67,13 +65,13 @@ final class KubernetesWithM2MFallbackTokenProvider implements ConsulTokenProvide */ @Override public synchronized Token getToken() throws IOException { - if (kubernetesConfirmed) { + if (isKubernetesConfirmed) { return kubernetesProvider.getToken(); } if (recheckIsDue()) { try { Token token = probe(); - confirmKubernetes(); + confirmKubernetesWay(); return token; } catch (Exception e) { fallBack(e); @@ -99,11 +97,11 @@ public synchronized Token getSelfToken(String currentSecretId) throws IOExceptio } private void adopt(String authMethod) { - if (kubernetesConfirmed || fellBackAt != null || authMethod == null) { + if (isKubernetesConfirmed || fellBackAt != null || authMethod == null) { return; } if (kubernetesAuthMethod.equals(authMethod)) { - confirmKubernetes(); + confirmKubernetesWay(); } else { fellBackAt = clock.instant(); } @@ -113,9 +111,10 @@ private boolean recheckIsDue() { return fellBackAt == null || !clock.instant().isBefore(fellBackAt.plus(recheckInterval)); } - private void confirmKubernetes() { + private void confirmKubernetesWay() { + isKubernetesConfirmed = true; + boolean afterFallback = fellBackAt != null; - kubernetesConfirmed = true; if (afterFallback) { log.info("Consul ACL token is obtained by the {} auth method from now on, the fallback to the {} one is over", KUBERNETES_WAY, M2M_WAY); @@ -132,6 +131,7 @@ private void fallBack(Exception e) { fellBackAt = clock.instant(); } + //todo vlla может назвать метод как-то более говорящим образом? И зочем обязательно выбрасывать IOException, если единственный вызывающий метод все равно ловит Exception? private Token probe() throws IOException { try { return Failsafe.with(LoginRetryPolicies.onTransportFailure(tries, probeDelay) diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java index 9165db4b3c..0db90c0ee6 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java @@ -4,10 +4,10 @@ import net.jodah.failsafe.FailsafeException; import net.jodah.failsafe.RetryPolicy; import net.jodah.failsafe.function.CheckedSupplier; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; import java.time.Clock; import java.time.Duration; import java.time.OffsetDateTime; @@ -22,8 +22,8 @@ * nothing about how the token is obtained. */ public class TokenUpdater { - private static final Logger log = LoggerFactory.getLogger(TokenUpdater.class); + private static final int DEFAULT_TRIES = 10; private static final Duration DEFAULT_RETRY_PAUSE = Duration.ofSeconds(1); private static final double DELAY_MULTIPLIER = 0.8; @@ -61,7 +61,7 @@ public TokenUpdater(ConsulTokenProvider tokenProvider) { synchronized public void watch(Consumer updater, String currentSecretId) { log.debug("Start token refreshing process for consul"); Token token; - if (currentSecretId == null || currentSecretId.isEmpty()) { + if (StringUtils.isEmpty(currentSecretId)) { token = withRetry(tokenProvider::getToken, tries); updater.accept(token.getSecretId()); } else { @@ -73,6 +73,8 @@ synchronized public void watch(Consumer updater, String currentSecretId) } } + //todo vlla правильно ли я понимаю, что прошлое решение создавало шедулер навсегда, новое же решение шедулит только одну следующую проверку? + // нет ли риска, что во время очерендой попытки произойдет ошибка и следующая проверка не зашедулится? Что случиться тогда с системой? /** * Schedules one relogin and, from its result, the next one. Every delay is measured against the expiration of the * token the pod holds right now rather than of the first one: the way of obtaining the token can change while the From 5bad07e0c5f4acb7d1dec7a9344475dd157fd642 Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Mon, 31 Aug 2026 10:51:07 +0400 Subject: [PATCH 13/20] fix(consul): keep the relogin schedule alive when the task throws an Error The scheduled task caught Exception, so an Error walked out of the lambda, landed in a Future nobody reads, and the task never ran again. The pod then kept a token that eventually expired and every Consul read started failing, with nothing in the log to say why. The SPI behind the kubernetes bearer token throws exactly such an Error when no TokenSource provider is on the classpath. The task now catches Throwable, logs it and rearms. That is a deliberate exception to the rule of the module that lets an Error through: the rule earns its keep where a caller can see the failure, and on a pool thread nobody can. Also from review: probe becomes probeKubernetesWay and says why it unwraps the failsafe failure back into an IOException; the SelfTokenReader and Token.getAuthMethod comments catch up with the code, the latter having claimed that a login answer carries no auth method when it does; and both READMEs say the m2m mode does not use the auth method and the audience rather than not reading them, which it does through the builder defaults. --- .../config-sources/consul-client/README.md | 2 +- .../consul-config-provider/README.md | 2 +- .../KubernetesWithM2MFallbackTokenProvider.java | 12 +++++++++--- .../consul/provider/common/SelfTokenReader.java | 5 +++-- .../cloud/consul/provider/common/Token.java | 5 +++-- .../consul/provider/common/TokenUpdater.java | 8 +++++--- .../consul/provider/common/TokenUpdaterTest.java | 15 +++++++++++++++ 7 files changed, 37 insertions(+), 12 deletions(-) diff --git a/core-quarkus-extensions/config-sources/consul-client/README.md b/core-quarkus-extensions/config-sources/consul-client/README.md index c71e8ed466..241441c830 100644 --- a/core-quarkus-extensions/config-sources/consul-client/README.md +++ b/core-quarkus-extensions/config-sources/consul-client/README.md @@ -46,7 +46,7 @@ when the `TokenStorage` bean is built, so the way can be switched without rebuil In `kubernetes-with-m2m-fallback` mode the `kubernetes` way is tried first, and on failure the pod falls back to the `m2m` way and logs one `INFO` record with the reason, the Consul response code, and a truncated response body. In -`kubernetes` mode there is no probe and no fallback. In `m2m` mode the auth method name and the audience are not read +`kubernetes` mode there is no probe and no fallback. In `m2m` mode the auth method name and the audience are not used at all. The fallback is temporary. Once `fallback-recheck-interval` has passed, the next scheduled relogin tries the diff --git a/core-rest-libraries/consul-config-provider/README.md b/core-rest-libraries/consul-config-provider/README.md index a8bc131c72..51d0c0c2d0 100644 --- a/core-rest-libraries/consul-config-provider/README.md +++ b/core-rest-libraries/consul-config-provider/README.md @@ -25,7 +25,7 @@ back on its own. In `kubernetes-with-m2m-fallback` mode the `kubernetes` way is tried first. If it fails, the pod falls back to the `m2m` way and logs one `INFO` record with the reason, the Consul response code, and a truncated response body. In `kubernetes` mode there is no probe and no fallback, and neither `M2MManager` nor the namespace is needed. In `m2m` -mode the auth method name and the audience are not read at all. +mode the auth method name and the audience are not used at all. The fallback is temporary. Once `fallback-recheck-interval` has passed, the next scheduled relogin tries the `kubernetes` way again, and the first success switches the pod over for good, with a second `INFO` record. Going back diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java index 573019e38b..8f07bbb0f0 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java @@ -70,7 +70,7 @@ public synchronized Token getToken() throws IOException { } if (recheckIsDue()) { try { - Token token = probe(); + Token token = probeKubernetesWay(); confirmKubernetesWay(); return token; } catch (Exception e) { @@ -131,8 +131,14 @@ private void fallBack(Exception e) { fellBackAt = clock.instant(); } - //todo vlla может назвать метод как-то более говорящим образом? И зочем обязательно выбрасывать IOException, если единственный вызывающий метод все равно ловит Exception? - private Token probe() throws IOException { + /** + * Runs the kubernetes login with the probe budget. + * + * @throws IOException the failure failsafe wrapped, unwrapped back. The caller catches {@link Exception} either + * way, so this is for the log: {@link #describe(Exception)} would otherwise name the record after + * {@code FailsafeException} instead of the response code and body that explain the fallback + */ + private Token probeKubernetesWay() throws IOException { try { return Failsafe.with(LoginRetryPolicies.onTransportFailure(tries, probeDelay) .onFailedAttempt(event -> log.debug("Failed probe attempt {} of the {} auth method", diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java index ba92a85cb7..09466f8a5d 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java @@ -11,8 +11,9 @@ import java.time.OffsetDateTime; /** - * Reads the expiration of an ACL token the pod already holds. The operation does not depend on how the token was - * obtained, which is why it lives apart from {@link ConsulTokenProvider}. + * Reads an ACL token the pod already holds: its expiration and the auth method Consul issued it to. The read sends the + * token itself and never looks at credentials, so it does not depend on how the token was obtained — one instance + * serves every {@link ConsulTokenProvider}. */ public class SelfTokenReader { diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/Token.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/Token.java index 569c657dc2..646050ee86 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/Token.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/Token.java @@ -26,8 +26,9 @@ public OffsetDateTime getExpirationTime() { } /** - * The auth method Consul issued the token to, or {@code null} when it is not known. A login answer does not carry - * it; only {@code /v1/acl/token/self} does. + * The auth method Consul issued the token to, or {@code null} when it is not known. Both a login answer and + * {@code /v1/acl/token/self} carry the field, but only the self read parses it: a login already knows the way it + * took. */ public String getAuthMethod() { return authMethod; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java index 0db90c0ee6..daf14f845e 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java @@ -73,14 +73,16 @@ synchronized public void watch(Consumer updater, String currentSecretId) } } - //todo vlla правильно ли я понимаю, что прошлое решение создавало шедулер навсегда, новое же решение шедулит только одну следующую проверку? - // нет ли риска, что во время очерендой попытки произойдет ошибка и следующая проверка не зашедулится? Что случиться тогда с системой? /** * Schedules one relogin and, from its result, the next one. Every delay is measured against the expiration of the * token the pod holds right now rather than of the first one: the way of obtaining the token can change while the * pod lives, and the two auth methods carry different {@code MaxTokenTTL}. A failed relogin therefore shortens the * next delay instead of repeating the previous one, which would put the retry well past the expiration on a * short-lived token. A token without an expiration ends the schedule. + * + *

The task catches {@link Throwable} rather than {@link Exception}, against the rule of the module that lets an + * {@link Error} through. Here nobody would see it: the executor keeps it in a {@link java.util.concurrent.Future} + * no one reads, the task never runs again, and the pod silently keeps a token that eventually expires. */ private void scheduleRelogin(Consumer updater, OffsetDateTime expirationTime) { executor.schedule(() -> { @@ -93,7 +95,7 @@ private void scheduleRelogin(Consumer updater, OffsetDateTime expiration return; } scheduleRelogin(updater, newToken.getExpirationTime()); - } catch (Exception e) { + } catch (Throwable e) { log.error("Error occurred during getting new consul token. Will try in {} seconds.", reloginDelaySeconds(expirationTime), e); scheduleRelogin(updater, expirationTime); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java index 012a6a08a3..f490ca10ee 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenUpdaterTest.java @@ -290,4 +290,19 @@ void aTokenWithoutExpirationStopsTheSchedule() throws IOException { verify(scheduledExecutorService, times(1)).schedule(any(Runnable.class), anyLong(), eq(TimeUnit.SECONDS)); } + + @Test + void anErrorInTheScheduledTaskDoesNotKillTheSchedule() throws IOException { + OffsetDateTime expiration = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); + when(tokenProvider.getToken()) + .thenReturn(new Token("test-token", expiration)) + .thenThrow(new Error("Unable to locate implementation for TokenSource")) + .thenThrow(new Error("Unable to locate implementation for TokenSource")); + + runScheduledTaskOnce(); + tokenUpdater.watch(unused -> { + }, ""); + + verify(scheduledExecutorService, times(2)).schedule(any(Runnable.class), anyLong(), eq(TimeUnit.SECONDS)); + } } From 3bb59be781bb0de33b0d5d71499382135b547294 Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Mon, 31 Aug 2026 12:46:07 +0400 Subject: [PATCH 14/20] feat(consul): default to the applications-k8s-m2m auth method Infrastructure named the kubernetes auth method, so the default stops being a placeholder and a service no longer has to set login.auth-method to migrate. --- .../config-sources/consul-client/README.md | 6 +++--- core-rest-libraries/consul-config-provider/README.md | 6 +++--- .../cloud/consul/provider/common/TokenStorageFactory.java | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/core-quarkus-extensions/config-sources/consul-client/README.md b/core-quarkus-extensions/config-sources/consul-client/README.md index 241441c830..78ca6b8fe6 100644 --- a/core-quarkus-extensions/config-sources/consul-client/README.md +++ b/core-quarkus-extensions/config-sources/consul-client/README.md @@ -57,8 +57,8 @@ expires, nothing is scheduled, and nothing is rechecked. An unknown value of `quarkus.consul-source-config.login.mode` fails the startup. With `quarkus.consul-source-config.m2m.enabled=false` none of the four properties are read. -The default auth method name is a placeholder. Set `quarkus.consul-source-config.login.auth-method` to the name of the -auth method registered in your Consul. +The default is the auth method the platform registers. Set `quarkus.consul-source-config.login.auth-method` only if +your Consul names it differently. The `kubernetes` way reads the token from `/var/run/secrets/tokens//token`. To run it outside a cluster, point the token directory elsewhere with `com.netcracker.cloud.security.kubernetes.tokens.dir`. @@ -72,6 +72,6 @@ the token directory elsewhere with `com.netcracker.cloud.security.kubernetes.tok | quarkus.consul-source-config.wait-time | Maximum Value for Consul blocking queries wait time (seconds) | 570 | | quarkus.consul-source-config.m2m.enabled | Enable the Consul ACL token exchange (bool, build time) | true | | quarkus.consul-source-config.login.mode | Way to obtain the ACL token: kubernetes-with-m2m-fallback, kubernetes or m2m | kubernetes-with-m2m-fallback | -| quarkus.consul-source-config.login.auth-method | Consul auth method name, used by the kubernetes way | kubernetes-auth-method-placeholder | +| quarkus.consul-source-config.login.auth-method | Consul auth method name, used by the kubernetes way | applications-k8s-m2m | | quarkus.consul-source-config.login.audience | Projected token audience, used by the kubernetes way | netcracker | | quarkus.consul-source-config.login.fallback-recheck-interval | How often the fallback retries the kubernetes way | PT5H | diff --git a/core-rest-libraries/consul-config-provider/README.md b/core-rest-libraries/consul-config-provider/README.md index 51d0c0c2d0..2f5dc73e3e 100644 --- a/core-rest-libraries/consul-config-provider/README.md +++ b/core-rest-libraries/consul-config-provider/README.md @@ -18,7 +18,7 @@ back on its own. | Property name | Type | Default | Read when | |-----------------------------------------------------|-------------------------------|--------------------------------------|--------------------------------------------------------------| | `spring.cloud.consul.config.login.mode` | `kubernetes-with-m2m-fallback`, `kubernetes` or `m2m` | `kubernetes-with-m2m-fallback` | ConfigData phase and `TokenStorage` bean creation | -| `spring.cloud.consul.config.login.auth-method` | string | `kubernetes-auth-method-placeholder` | same, and only when the `kubernetes` way is used | +| `spring.cloud.consul.config.login.auth-method` | string | `applications-k8s-m2m` | same, and only when the `kubernetes` way is used | | `spring.cloud.consul.config.login.audience` | string | `netcracker` | same, and only when the `kubernetes` way is used | | `spring.cloud.consul.config.login.fallback-recheck-interval` | duration | `5h` | same, and only in the fallback mode | @@ -37,8 +37,8 @@ nothing is rechecked. An unknown value of `spring.cloud.consul.config.login.mode` fails the startup. -The default auth method name is a placeholder. Set -`spring.cloud.consul.config.login.auth-method` to the name of the auth method registered in your Consul. +The default is the auth method the platform registers. Set `spring.cloud.consul.config.login.auth-method` only if +your Consul names it differently. To turn the ACL token exchange off altogether — for local runs and tests — set `spring.cloud.consul.config.m2m.enabled=false`. None of the login properties are read then. diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java index 02ce60abc2..ed6e6b8513 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java @@ -56,7 +56,7 @@ private static ConsulTokenProvider kubernetesProvider(ConsulClient client, Creat public static class CreateOptions { - public static final String DEFAULT_AUTH_METHOD = "kubernetes-auth-method-placeholder"; + public static final String DEFAULT_AUTH_METHOD = "applications-k8s-m2m"; public static final Duration DEFAULT_FALLBACK_RECHECK_INTERVAL = Duration.ofHours(5); String consulUrl; From 26586cd6fa6aedde5202ccf62454f08e9b6250e3 Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Mon, 31 Aug 2026 17:12:56 +0400 Subject: [PATCH 15/20] fix(consul): declare failsafe instead of taking it transitively The login retries compile against net.jodah:failsafe, which reached the module only through k8s-utils, and TokenUpdater reached commons-lang3 the same way for a single StringUtils.isEmpty call. Both would break on the next k8s-utils upgrade, and the repository is already split between net.jodah and dev.failsafe. failsafe is now declared, pinned to the 2.4.4 the other modules use, and the emptiness check goes back to the plain comparison it replaced, so commons-lang3 is not needed at all. mvn dependency:analyze no longer reports either as used-undeclared. --- .../consul-config-provider-common/pom.xml | 5 +++++ .../consul/provider/common/TokenUpdater.java | 15 +++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/pom.xml b/core-rest-libraries/consul-config-provider/consul-config-provider-common/pom.xml index 9caa49e078..30efab2d3d 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/pom.xml +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/pom.xml @@ -31,6 +31,11 @@ com.squareup.okhttp3 okhttp + + net.jodah + failsafe + 2.4.4 + org.junit.jupiter junit-jupiter-engine diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java index daf14f845e..d335c7a15b 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenUpdater.java @@ -4,7 +4,6 @@ import net.jodah.failsafe.FailsafeException; import net.jodah.failsafe.RetryPolicy; import net.jodah.failsafe.function.CheckedSupplier; -import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -61,7 +60,7 @@ public TokenUpdater(ConsulTokenProvider tokenProvider) { synchronized public void watch(Consumer updater, String currentSecretId) { log.debug("Start token refreshing process for consul"); Token token; - if (StringUtils.isEmpty(currentSecretId)) { + if (currentSecretId == null || currentSecretId.isEmpty()) { token = withRetry(tokenProvider::getToken, tries); updater.accept(token.getSecretId()); } else { @@ -85,6 +84,10 @@ synchronized public void watch(Consumer updater, String currentSecretId) * no one reads, the task never runs again, and the pod silently keeps a token that eventually expires. */ private void scheduleRelogin(Consumer updater, OffsetDateTime expirationTime) { + scheduleReloginIn(updater, expirationTime, reloginDelaySeconds(expirationTime)); + } + + private void scheduleReloginIn(Consumer updater, OffsetDateTime expirationTime, long delaySeconds) { executor.schedule(() -> { log.debug("Get new consul token with {} retry attempts", tries); try { @@ -96,11 +99,11 @@ private void scheduleRelogin(Consumer updater, OffsetDateTime expiration } scheduleRelogin(updater, newToken.getExpirationTime()); } catch (Throwable e) { - log.error("Error occurred during getting new consul token. Will try in {} seconds.", - reloginDelaySeconds(expirationTime), e); - scheduleRelogin(updater, expirationTime); + long retryDelaySeconds = reloginDelaySeconds(expirationTime); + log.error("Error occurred during getting new consul token. Will try in {} seconds.", retryDelaySeconds, e); + scheduleReloginIn(updater, expirationTime, retryDelaySeconds); } - }, reloginDelaySeconds(expirationTime), TimeUnit.SECONDS); + }, delaySeconds, TimeUnit.SECONDS); } /** From 650c745a7f0cd7702c8f1370828410338f5d2bde Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Mon, 31 Aug 2026 17:13:17 +0400 Subject: [PATCH 16/20] fix(consul): end the ConfigData phase the same way whatever the login hit The phase caught IOException only, so the failure type decided the outcome: a 403 from Consul was logged and the application started without an ACL token, while an answer without a SecretID threw PathNotFoundException past the catch and ended the startup. Same cause, two behaviors, neither of them chosen. The catch now covers Exception, so every login failure ends the phase the same way in every mode, and the TokenStorage bean is left to obtain the token. SelfTokenReader gains the empty-body check LoginTokenProvider already had; a null body used to reach JsonPath and come out as a NullPointerException instead of the IOException the callers retry on. --- .../provider/common/SelfTokenReader.java | 9 +++++--- .../provider/common/SelfTokenReaderTest.java | 11 ++++++++++ .../ConsulM2MConfigDataLocationResolver.java | 21 +++++++++---------- ...nsulM2MConfigDataLocationResolverTest.java | 21 +++++++++++++++++++ 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java index 09466f8a5d..e0e42c8251 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/SelfTokenReader.java @@ -12,8 +12,8 @@ /** * Reads an ACL token the pod already holds: its expiration and the auth method Consul issued it to. The read sends the - * token itself and never looks at credentials, so it does not depend on how the token was obtained — one instance - * serves every {@link ConsulTokenProvider}. + * token itself and never looks at credentials, so it does not depend on how the token was obtained — every {@link + * ConsulTokenProvider} gets the same answer from a reader over the same client. */ public class SelfTokenReader { @@ -29,7 +29,7 @@ public SelfTokenReader(ConsulClient client) { * Reads the token behind {@code currentSecretId}. A token without an expiration is valid: Consul omits the field * for an auth method without {@code MaxTokenTTL}. * - * @throws IOException on a non-2xx answer; the caller may retry + * @throws IOException on a non-2xx answer or an empty body; the caller may retry * @throws RuntimeException on a transport failure, in whatever type the client throws. Unlike {@link * ConsulClient#login(ConsulLoginCredentials)}, {@link ConsulClient#getSelfToken(String)} does not report * it as an {@link IOException}, so the retry policies of the module do not cover it @@ -40,6 +40,9 @@ public Token read(String currentSecretId) throws IOException { if (response.getCode() != 200) { throw new IOException(String.format("can not get self token from consul; response code=%s; body='%s'", response.getCode(), bodyJson)); } + if (bodyJson == null || bodyJson.isEmpty()) { + throw new IOException("can not get self token from consul: response body is empty"); + } String secretId = JsonPath.read(bodyJson, "$.SecretID"); OffsetDateTime expirationTime = null; diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/SelfTokenReaderTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/SelfTokenReaderTest.java index 03ad555489..c97436ed2d 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/SelfTokenReaderTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/SelfTokenReaderTest.java @@ -92,4 +92,15 @@ void readFailsWithIOExceptionOnNonSuccessCode() { assertThrows(IOException.class, () -> selfTokenReader.read("test-current-secret-id")); } + + @Test + void readFailsWithIOExceptionOnEmptyBody() { + when(consulClient.getSelfToken(anyString())).thenReturn(new ConsulClientResponse(null, 200)); + + assertThrows(IOException.class, () -> selfTokenReader.read("test-current-secret-id")); + + when(consulClient.getSelfToken(anyString())).thenReturn(new ConsulClientResponse("", 200)); + + assertThrows(IOException.class, () -> selfTokenReader.read("test-current-secret-id")); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java index e4e246b48f..432c41e5a4 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolver.java @@ -44,8 +44,11 @@ protected ConsulM2MConfigDataLocationResolver(DeferredLogFactory log) { /** * Logs in once and writes the {@code SecretID} into {@link ConsulConfigProperties}, so that Consul is readable * before the application context exists. The phase runs without a context, so the mode is bound through {@link - * Binder} rather than injected, and a failed login is logged rather than thrown: the application still starts, - * just without an ACL token. + * Binder} rather than injected. + * + *

A failed login is logged rather than thrown, in every mode: the application still starts, just without an ACL + * token, and the {@code TokenStorage} bean tries again. The catch covers {@link Exception}, not only {@link + * IOException}, so that a non-2xx answer from Consul and a malformed one leave the phase the same way. */ @Override protected ConsulConfigProperties loadConfigProperties(ConfigDataLocationResolverContext resolverContext) { @@ -61,21 +64,17 @@ protected ConsulConfigProperties loadConfigProperties(ConfigDataLocationResolver .orElseGet(ConsulLoginProperties::new); Supplier m2mTokenSupplier = () -> resolverContext.getBootstrapContext().get(M2MManager.class).getToken().getTokenValue(); - ConsulRestClient client = createConsulRestClient(Utils.formatConsulAddress(properties), m2mTokenSupplier); - - TokenStorageFactory.CreateOptions.Builder options = new TokenStorageFactory.CreateOptions.Builder() - .consulUrl(Utils.formatConsulAddress(properties)) - .mode(login.getMode()) - .authMethod(login.getAuthMethod()) - .audience(login.getAudience()) - .fallbackRecheckInterval(login.getFallbackRecheckInterval()); + String consulAddress = Utils.formatConsulAddress(properties); + ConsulRestClient client = createConsulRestClient(consulAddress, m2mTokenSupplier); + + TokenStorageFactory.CreateOptions.Builder options = login.toOptionsBuilder().consulUrl(consulAddress); if (login.getMode() != ConsulLoginMode.KUBERNETES) { options.namespace(getPropsOrEnvsMust(args(PROP_CLOUD_NAMESPACE), args(ENV_NAMESPACE, ENV_CLOUD_NAMESPACE))) .m2mSupplier(m2mTokenSupplier); } consulConfigProperties.setAclToken(TokenStorageFactory.from(client, options.build()).getToken().getSecretId()); - } catch (IOException e) { + } catch (Exception e) { log.error("can not get consul token: ", e); } registerAndPromoteBean(resolverContext, ConsulProperties.class, BootstrapRegistry.InstanceSupplier.of(properties)); diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolverTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolverTest.java index 200037a440..270e92a097 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolverTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulM2MConfigDataLocationResolverTest.java @@ -204,6 +204,27 @@ void m2mModeSkipsTheNewWayEntirely() { Assertions.assertEquals(1, m2mLookups.get()); } + @Test + void aFailedLoginLeavesTheStartupAliveWithoutATokenInEveryMode() throws IOException { + Mockito.when(consulRestClient.login(Mockito.any(ConsulLoginCredentials.class))) + .thenThrow(new IOException("consul auth method is not ready: response code=403; body='ACL not found'")); + + for (String mode : new String[]{"kubernetes", "kubernetes-with-m2m-fallback", "m2m"}) { + properties.put(PROP_LOGIN_MODE, mode); + + Assertions.assertNull(resolve().getAclToken(), mode); + } + } + + @Test + void aMalformedAnswerEndsTheSameWayAsARejectedLogin() throws IOException { + properties.put(PROP_LOGIN_MODE, "kubernetes"); + Mockito.when(consulRestClient.login(Mockito.any(ConsulLoginCredentials.class))) + .thenReturn(new ConsulClientResponse("{\"NoSecretHere\":true}", 200)); + + Assertions.assertNull(resolve().getAclToken()); + } + @Test void unknownModeBreaksTheBinding() { properties.put(PROP_LOGIN_MODE, "cloud-foundry"); From 9a0da1f326f40c733a3068d439488aed67f695a4 Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Mon, 31 Aug 2026 17:13:44 +0400 Subject: [PATCH 17/20] refactor(consul): remove the login code that existed twice Three duplications, all of them mechanical: - both consul clients built the login request twice, once for the deprecated login(String) and once for login(credentials). The building moves into a private login(authMethod, bearerToken); the two public methods keep the exception handling that genuinely differs between them - the four login inputs were mapped onto CreateOptions in three places. They move into ConsulLoginProperties.toOptionsBuilder, which the ConfigData phase and both autoconfigurations now start from - the two autoconfiguration tests were the same file twice. The mapping is covered once in ConsulLoginPropertiesTest, and each module keeps only the wiring it owns Also from the review: - TokenStorageFactory.from lists KUBERNETES_WITH_M2M_FALLBACK explicitly and throws in the default branch, so a new mode without a branch fails loudly instead of silently becoming the fallback - isKubernetesConfirmed drops volatile: both fields are read and written under the instance lock only, and one of them claiming otherwise misled the reader - the relogin delay is computed once per schedule rather than twice, so the logged value is the one the executor gets - unknownModeIsNotCreated asserted Enum.valueOf and tested the JDK. It becomes everyModeBuildsAProvider, which walks ConsulLoginMode.values() and fails when a new constant has no branch - the quarkus side gains the binding test for fallback-recheck-interval, which only mode had - TestClock moves out of the middle of the test methods --- .../client/ConsulLoginModeConfigTest.java | 11 +++ ...ubernetesWithM2MFallbackTokenProvider.java | 13 ++-- .../provider/common/TokenStorageFactory.java | 4 +- .../common/client/ConsulOkHttpClient.java | 26 ++----- .../common/client/ConsulRestClient.java | 37 ++++------ ...netesWithM2MFallbackTokenProviderTest.java | 56 +++++++------- .../common/TokenStorageFactoryTest.java | 13 +++- .../common/config/ConsulLoginProperties.java | 14 ++++ .../config/ConsulLoginPropertiesTest.java | 73 +++++++++++++++++++ ...onsulM2MRestTemplateAutoConfiguration.java | 10 +-- ...lM2MRestTemplateAutoConfigurationTest.java | 58 +++------------ .../ConsulM2MWebClientAutoConfiguration.java | 10 +-- ...nsulM2MWebClientAutoConfigurationTest.java | 58 +++------------ 13 files changed, 197 insertions(+), 186 deletions(-) create mode 100644 core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginPropertiesTest.java diff --git a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulLoginModeConfigTest.java b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulLoginModeConfigTest.java index 4e89fa2811..5d59b7be07 100644 --- a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulLoginModeConfigTest.java +++ b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/ConsulLoginModeConfigTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.time.Duration; import java.util.Map; class ConsulLoginModeConfigTest { @@ -34,4 +35,14 @@ void unknownModeBreaksTheStart() { Assertions.assertThrows(IllegalArgumentException.class, () -> config.getValue(ConsulClientConfiguration.PROP_LOGIN_MODE, ConsulLoginMode.class)); } + + @Test + void theFallbackRecheckIntervalIsReadFromItsPropertyValue() { + SmallRyeConfig config = new SmallRyeConfigBuilder() + .withDefaultValues(Map.of(ConsulClientConfiguration.PROP_LOGIN_FALLBACK_RECHECK_INTERVAL, "PT30M")) + .build(); + + Assertions.assertEquals(Duration.ofMinutes(30), + config.getValue(ConsulClientConfiguration.PROP_LOGIN_FALLBACK_RECHECK_INTERVAL, Duration.class)); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java index 8f07bbb0f0..894f65efbd 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProvider.java @@ -13,11 +13,14 @@ /** * Probes the kubernetes way and falls back to m2m if the probe fails. The fallback is temporary: every so often the * pod tries the kubernetes way again, and the first success switches it over for good. Going back to m2m never - * happens, so the state is a ratchet, and a later failure of the kubernetes way is a plain failure. + * happens, so a later failure of the kubernetes way is a plain failure. * *

The recheck rides on the scheduled relogin rather than on a timer of its own, so it happens only while someone * asks for tokens. The whole class goes away with the m2m way, which is why it names the pair it serves instead of * taking two interchangeable providers. + * + *

Both entry points are synchronized, and the instance lock guards the choice of the way: a probe must not run + * next to the relogin that could confirm it. */ final class KubernetesWithM2MFallbackTokenProvider implements ConsulTokenProvider { private static final Logger log = LoggerFactory.getLogger(KubernetesWithM2MFallbackTokenProvider.class); @@ -36,7 +39,7 @@ final class KubernetesWithM2MFallbackTokenProvider implements ConsulTokenProvide private final Duration recheckInterval; private final Clock clock; - private volatile boolean isKubernetesConfirmed; + private boolean isKubernetesConfirmed; private Instant fellBackAt; KubernetesWithM2MFallbackTokenProvider(ConsulTokenProvider kubernetesProvider, ConsulTokenProvider m2mProvider, @@ -58,9 +61,9 @@ final class KubernetesWithM2MFallbackTokenProvider implements ConsulTokenProvide } /** - * Returns a token from the kubernetes way once it is confirmed, and otherwise probes it whenever the recheck - * interval has passed since the last failure. The probe spends fewer attempts than the scheduler so that an - * unmigrated pod pays little for it. Any {@link Exception} out of the kubernetes way means the m2m way for now; + * Returns a token from the kubernetes way once it is confirmed. Otherwise probes that way — on the first call, and + * after that whenever the recheck interval has passed since the last failure — and serves the m2m way in between. + * The probe spends fewer attempts than the scheduler so that an unmigrated pod pays little for it. Any {@link Exception} out of the kubernetes way means the m2m way for now; * an {@link Error} passes through untouched. */ @Override diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java index ed6e6b8513..9fc054b2ea 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactory.java @@ -36,9 +36,11 @@ public static ConsulTokenProvider from(ConsulClient client, CreateOptions option return m2mProvider(client, options); case KUBERNETES: return kubernetesProvider(client, options); - default: + case KUBERNETES_WITH_M2M_FALLBACK: return new KubernetesWithM2MFallbackTokenProvider(kubernetesProvider(client, options), m2mProvider(client, options), options.authMethod, options.fallbackRecheckInterval); + default: + throw new IllegalStateException("no provider is built for the " + options.mode + " consul login mode"); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java index eaa2af532f..e56de3a7a8 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulOkHttpClient.java @@ -49,25 +49,11 @@ public ConsulClientResponse getSelfToken(String currentSecretId) { @Override public ConsulClientResponse login(String authMethod) { - Map payload = new HashMap<>(); - payload.put(AUTH_METHOD_FIELD, authMethod); - payload.put(BEARER_TOKEN_FIELD, m2mTokenSupplier.get()); - String json = new Gson().toJson(payload); - log.info("Perform login to {} with {} auth method", consulAddr, authMethod); - Response response; - String responseBody = ""; try { - response = client.newCall(new Request.Builder() - .post(RequestBody.create(MediaType.parse(APPLICATION_JSON), json)) - .url(consulAddr + V1_ACL_LOGIN) - .addHeader(CONTENT_TYPE, APPLICATION_JSON) - .build() - ).execute(); - responseBody = response.body().string(); + return login(authMethod, m2mTokenSupplier.get()); } catch (IOException e) { throw new RuntimeException(e); } - return new ConsulClientResponse(responseBody, response.code()); } /** @@ -76,11 +62,15 @@ public ConsulClientResponse login(String authMethod) { */ @Override public ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOException { + return login(credentials.getAuthMethod(), credentials.getBearerToken()); + } + + private ConsulClientResponse login(String authMethod, String bearerToken) throws IOException { Map payload = new HashMap<>(); - payload.put(AUTH_METHOD_FIELD, credentials.getAuthMethod()); - payload.put(BEARER_TOKEN_FIELD, credentials.getBearerToken()); + payload.put(AUTH_METHOD_FIELD, authMethod); + payload.put(BEARER_TOKEN_FIELD, bearerToken); String json = new Gson().toJson(payload); - log.info("Perform login to {} with {} auth method", consulAddr, credentials.getAuthMethod()); + log.info("Perform login to {} with {} auth method", consulAddr, authMethod); Response response = client.newCall(new Request.Builder() .post(RequestBody.create(MediaType.parse(APPLICATION_JSON), json)) .url(consulAddr + V1_ACL_LOGIN) diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java index c36776f780..233333dff6 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/client/ConsulRestClient.java @@ -49,38 +49,31 @@ public ConsulClientResponse getSelfToken(String currentSecretId) { @Override public ConsulClientResponse login(String authMethod) { - Map payload = new HashMap<>(); - payload.put(AUTH_METHOD_FIELD, authMethod); - payload.put(BEARER_TOKEN_FIELD, m2mTokenSupplier.get()); - String json = new Gson().toJson(payload); - - Map> headers = new HashMap<>(); - headers.put(CONTENT_TYPE, Collections.singletonList(APPLICATION_JSON)); - - log.info("Perform login to {} with {} auth method", consulAddr, authMethod); - RestClientResponseEntity response = client.doRequest(consulAddr + V1_ACL_LOGIN, HttpMethod.POST, headers, json, String.class); - return new ConsulClientResponse(response.getResponseBody(), response.getHttpStatus()); + return login(authMethod, m2mTokenSupplier.get()); } @Override public ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOException { - Map payload = new HashMap<>(); - payload.put(AUTH_METHOD_FIELD, credentials.getAuthMethod()); - payload.put(BEARER_TOKEN_FIELD, credentials.getBearerToken()); - String json = new Gson().toJson(payload); - - Map> headers = new HashMap<>(); - headers.put(CONTENT_TYPE, Collections.singletonList(APPLICATION_JSON)); - - log.info("Perform login to {} with {} auth method", consulAddr, credentials.getAuthMethod()); - RestClientResponseEntity response; try { - response = client.doRequest(consulAddr + V1_ACL_LOGIN, HttpMethod.POST, headers, json, String.class); + return login(credentials.getAuthMethod(), credentials.getBearerToken()); } catch (MicroserviceRestClientException e) { // Retries and their backoff act on IOException only, so a transport failure must reach the caller as one: // otherwise a single dropped connection sticks the pod to the m2m auth method until it restarts. throw new IOException("can not perform login to consul: " + e.getMessage(), e); } + } + + private ConsulClientResponse login(String authMethod, String bearerToken) { + Map payload = new HashMap<>(); + payload.put(AUTH_METHOD_FIELD, authMethod); + payload.put(BEARER_TOKEN_FIELD, bearerToken); + String json = new Gson().toJson(payload); + + Map> headers = new HashMap<>(); + headers.put(CONTENT_TYPE, Collections.singletonList(APPLICATION_JSON)); + + log.info("Perform login to {} with {} auth method", consulAddr, authMethod); + RestClientResponseEntity response = client.doRequest(consulAddr + V1_ACL_LOGIN, HttpMethod.POST, headers, json, String.class); return new ConsulClientResponse(response.getResponseBody(), response.getHttpStatus()); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java index 9105ff9898..78223789e0 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java @@ -198,34 +198,6 @@ void secretsNeverReachTheLog() throws IOException { assertFalse(logged.contains(SECRET_ID)); } - private static final class TestClock extends Clock { - - private Instant now; - - private TestClock(Instant now) { - this.now = now; - } - - private void advance(Duration duration) { - now = now.plus(duration); - } - - @Override - public ZoneId getZone() { - return ZoneOffset.UTC; - } - - @Override - public Clock withZone(ZoneId zone) { - return this; - } - - @Override - public Instant instant() { - return now; - } - } - @Test void fallbackIsNotRecheckedBeforeTheIntervalPasses() throws IOException { when(kubernetes.getToken()).thenThrow(new IllegalArgumentException("Unknown token audience: netcracker")); @@ -373,4 +345,32 @@ void theSelfTokenIsReadThroughTheKubernetesProviderAndReturnedUntouched() throws assertEquals(selfToken, probing().getSelfToken(CURRENT_SECRET_ID)); verifyNoInteractions(m2m); } + + private static final class TestClock extends Clock { + + private Instant now; + + private TestClock(Instant now) { + this.now = now; + } + + private void advance(Duration duration) { + now = now.plus(duration); + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now; + } + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java index dd4e79e711..e0c95f8fcc 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenStorageFactoryTest.java @@ -122,8 +122,17 @@ void authMethodAndAudienceAreTakenFromTheCallerWhenGiven() { } @Test - void unknownModeIsNotCreated() { - Assertions.assertThrows(IllegalArgumentException.class, () -> ConsulLoginMode.valueOf("cloud-foundry")); + void everyModeBuildsAProvider() { + for (ConsulLoginMode mode : ConsulLoginMode.values()) { + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .namespace(NAMESPACE) + .m2mSupplier(() -> "token") + .mode(mode) + .build(); + + Assertions.assertNotNull(TokenStorageFactory.from(mock(ConsulClient.class), opts), mode.name()); + } } private static ConsulLoginCredentials credentialsUsedBy(ConsulTokenProvider login, ConsulClient client) throws IOException { diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java index a5aca5e30f..87593b9ad7 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/main/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginProperties.java @@ -1,6 +1,7 @@ package com.netcracker.cloud.consul.provider.spring.common.config; import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; +import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; import java.time.Duration; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -51,4 +52,17 @@ public Duration getFallbackRecheckInterval() { public void setFallbackRecheckInterval(Duration fallbackRecheckInterval) { this.fallbackRecheckInterval = fallbackRecheckInterval; } + + /** + * Starts an options builder from the four login inputs, leaving the absent ones {@code null} for the builder to + * default. The Consul URL, the namespace and the M2M token supplier stay with the caller: every entry point + * resolves them its own way, and the ConfigData phase leaves out the last two in the {@code kubernetes} mode. + */ + public TokenStorageFactory.CreateOptions.Builder toOptionsBuilder() { + return new TokenStorageFactory.CreateOptions.Builder() + .mode(mode) + .authMethod(authMethod) + .audience(audience) + .fallbackRecheckInterval(fallbackRecheckInterval); + } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginPropertiesTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginPropertiesTest.java new file mode 100644 index 0000000000..27b371a18f --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-common/src/test/java/com/netcracker/cloud/consul/provider/spring/common/config/ConsulLoginPropertiesTest.java @@ -0,0 +1,73 @@ +package com.netcracker.cloud.consul.provider.spring.common.config; + +import com.netcracker.cloud.consul.provider.common.ConsulLoginMode; +import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; +import com.netcracker.cloud.security.core.utils.k8s.AudienceName; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +class ConsulLoginPropertiesTest { + + private static final String CONSUL_URL = "http://consul:8500"; + + private final ConsulLoginProperties loginProperties = new ConsulLoginProperties(); + + private TokenStorageFactory.CreateOptions options() { + return loginProperties.toOptionsBuilder() + .consulUrl(CONSUL_URL) + .namespace("ns") + .m2mSupplier(() -> "m2m-token") + .build(); + } + + @Test + void modeIsKubernetesWithM2MFallbackWhenNothingIsConfigured() { + Assertions.assertEquals(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK, options().getMode()); + } + + @Test + void everyModeReachesTheOptions() { + for (ConsulLoginMode mode : ConsulLoginMode.values()) { + loginProperties.setMode(mode); + + Assertions.assertEquals(mode, options().getMode()); + } + } + + @Test + void authMethodAndAudienceDefaultsComeFromTheBuilder() { + loginProperties.setMode(ConsulLoginMode.KUBERNETES); + + TokenStorageFactory.CreateOptions opts = options(); + + Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_AUTH_METHOD, opts.getAuthMethod()); + Assertions.assertEquals(AudienceName.NETCRACKER, opts.getAudience()); + } + + @Test + void authMethodAndAudienceAreReadFromTheConfiguration() { + loginProperties.setMode(ConsulLoginMode.KUBERNETES); + loginProperties.setAuthMethod("core-k8s"); + loginProperties.setAudience(AudienceName.DBAAS); + + TokenStorageFactory.CreateOptions opts = options(); + + Assertions.assertEquals("core-k8s", opts.getAuthMethod()); + Assertions.assertEquals(AudienceName.DBAAS, opts.getAudience()); + } + + @Test + void fallbackRecheckIntervalDefaultComesFromTheBuilder() { + Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_FALLBACK_RECHECK_INTERVAL, + options().getFallbackRecheckInterval()); + } + + @Test + void fallbackRecheckIntervalIsReadFromTheConfiguration() { + loginProperties.setFallbackRecheckInterval(Duration.ofMinutes(30)); + + Assertions.assertEquals(Duration.ofMinutes(30), options().getFallbackRecheckInterval()); + } +} diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java index 0c3b9509e2..8240c2716c 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/main/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfiguration.java @@ -38,22 +38,14 @@ public TokenStorage consulTokenStorageViaM2MRestTemplate(ConsulConfigProperties return factory.create(createOptions(loginProperties, consulProperties, m2MManager, System.getenv("NAMESPACE"))); } - /** - * Collects the raw inputs of the login. Values absent from the configuration stay {@code null} here, so that the - * builder applies the defaults. - */ static TokenStorageFactory.CreateOptions createOptions(ConsulLoginProperties loginProperties, ConsulProperties consulProperties, M2MManager m2MManager, String namespace) { - return new TokenStorageFactory.CreateOptions.Builder() + return loginProperties.toOptionsBuilder() .consulUrl(Utils.formatConsulAddress(consulProperties)) .namespace(namespace) .m2mSupplier(() -> m2MManager.getToken().getTokenValue()) - .mode(loginProperties.getMode()) - .authMethod(loginProperties.getAuthMethod()) - .audience(loginProperties.getAudience()) - .fallbackRecheckInterval(loginProperties.getFallbackRecheckInterval()) .build(); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java index 7a8dc3748b..de3e38fdea 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-resttemplate/src/test/java/com/netcracker/cloud/consul/provider/spring/resttemplate/config/ConsulM2MRestTemplateAutoConfigurationTest.java @@ -4,15 +4,16 @@ import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; import com.netcracker.cloud.consul.provider.spring.common.config.ConsulLoginProperties; import com.netcracker.cloud.security.core.auth.M2MManager; -import com.netcracker.cloud.security.core.utils.k8s.AudienceName; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.cloud.consul.ConsulProperties; -import java.time.Duration; - +/** + * Covers the wiring this module owns. How the login properties themselves turn into options is checked once, in + * {@code ConsulLoginPropertiesTest}. + */ class ConsulM2MRestTemplateAutoConfigurationTest { private final ConsulLoginProperties loginProperties = new ConsulLoginProperties(); @@ -33,59 +34,24 @@ private TokenStorageFactory.CreateOptions options() { } @Test - void modeIsKubernetesWithM2MFallbackWhenNothingIsConfigured() { - Assertions.assertEquals(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK, options().getMode()); - } - - @Test - void kubernetesModeReachesCreateOptionsAndNeedsNoM2MManager() { - loginProperties.setMode(ConsulLoginMode.KUBERNETES); - - TokenStorageFactory.CreateOptions opts = options(); - - Assertions.assertEquals(ConsulLoginMode.KUBERNETES, opts.getMode()); - Mockito.verifyNoInteractions(m2MManager); - } - - @Test - void m2mModeReachesCreateOptions() { - loginProperties.setMode(ConsulLoginMode.M2M); - - Assertions.assertEquals(ConsulLoginMode.M2M, options().getMode()); - } - - @Test - void authMethodAndAudienceDefaultsComeFromTheBuilder() { - loginProperties.setMode(ConsulLoginMode.KUBERNETES); - - TokenStorageFactory.CreateOptions opts = options(); - - Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_AUTH_METHOD, opts.getAuthMethod()); - Assertions.assertEquals(AudienceName.NETCRACKER, opts.getAudience()); - } - - @Test - void authMethodAndAudienceAreReadFromTheConfiguration() { + void loginPropertiesReachTheOptions() { loginProperties.setMode(ConsulLoginMode.KUBERNETES); loginProperties.setAuthMethod("core-k8s"); - loginProperties.setAudience(AudienceName.DBAAS); TokenStorageFactory.CreateOptions opts = options(); + Assertions.assertEquals(ConsulLoginMode.KUBERNETES, opts.getMode()); Assertions.assertEquals("core-k8s", opts.getAuthMethod()); - Assertions.assertEquals(AudienceName.DBAAS, opts.getAudience()); } @Test - void fallbackRecheckIntervalDefaultComesFromTheBuilder() { - Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_FALLBACK_RECHECK_INTERVAL, - options().getFallbackRecheckInterval()); - } + void buildingTheOptionsAsksNoM2MTokenInAnyMode() { + for (ConsulLoginMode mode : ConsulLoginMode.values()) { + loginProperties.setMode(mode); - @Test - void fallbackRecheckIntervalIsReadFromTheConfiguration() { - loginProperties.setFallbackRecheckInterval(Duration.ofMinutes(30)); + Assertions.assertEquals(mode, options().getMode()); + } - Assertions.assertEquals(Duration.ofMinutes(30), options().getFallbackRecheckInterval()); + Mockito.verifyNoInteractions(m2MManager); } } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java index d2262c99f9..ccffdbc3da 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/main/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfiguration.java @@ -34,22 +34,14 @@ public TokenStorage consulTokenStorageViaM2MWebClient(ConsulConfigProperties con return factory.create(createOptions(loginProperties, consulProperties, m2MManager, System.getenv("NAMESPACE"))); } - /** - * Collects the raw inputs of the login. Values absent from the configuration stay {@code null} here, so that the - * builder applies the defaults. - */ static TokenStorageFactory.CreateOptions createOptions(ConsulLoginProperties loginProperties, ConsulProperties consulProperties, M2MManager m2MManager, String namespace) { - return new TokenStorageFactory.CreateOptions.Builder() + return loginProperties.toOptionsBuilder() .consulUrl(Utils.formatConsulAddress(consulProperties)) .namespace(namespace) .m2mSupplier(() -> m2MManager.getToken().block().getTokenValue()) - .mode(loginProperties.getMode()) - .authMethod(loginProperties.getAuthMethod()) - .audience(loginProperties.getAudience()) - .fallbackRecheckInterval(loginProperties.getFallbackRecheckInterval()) .build(); } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java index d35cf687a5..60a512242e 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-spring-webclient/src/test/java/com/netcracker/cloud/consul/provider/spring/webclient/config/ConsulM2MWebClientAutoConfigurationTest.java @@ -4,15 +4,16 @@ import com.netcracker.cloud.consul.provider.common.TokenStorageFactory; import com.netcracker.cloud.consul.provider.spring.common.config.ConsulLoginProperties; import com.netcracker.cloud.security.common.reactive.M2MManager; -import com.netcracker.cloud.security.core.utils.k8s.AudienceName; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.cloud.consul.ConsulProperties; -import java.time.Duration; - +/** + * Covers the wiring this module owns. How the login properties themselves turn into options is checked once, in + * {@code ConsulLoginPropertiesTest}. + */ class ConsulM2MWebClientAutoConfigurationTest { private final ConsulLoginProperties loginProperties = new ConsulLoginProperties(); @@ -33,59 +34,24 @@ private TokenStorageFactory.CreateOptions options() { } @Test - void modeIsKubernetesWithM2MFallbackWhenNothingIsConfigured() { - Assertions.assertEquals(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK, options().getMode()); - } - - @Test - void kubernetesModeReachesCreateOptionsAndNeedsNoM2MManager() { - loginProperties.setMode(ConsulLoginMode.KUBERNETES); - - TokenStorageFactory.CreateOptions opts = options(); - - Assertions.assertEquals(ConsulLoginMode.KUBERNETES, opts.getMode()); - Mockito.verifyNoInteractions(m2MManager); - } - - @Test - void m2mModeReachesCreateOptions() { - loginProperties.setMode(ConsulLoginMode.M2M); - - Assertions.assertEquals(ConsulLoginMode.M2M, options().getMode()); - } - - @Test - void authMethodAndAudienceDefaultsComeFromTheBuilder() { - loginProperties.setMode(ConsulLoginMode.KUBERNETES); - - TokenStorageFactory.CreateOptions opts = options(); - - Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_AUTH_METHOD, opts.getAuthMethod()); - Assertions.assertEquals(AudienceName.NETCRACKER, opts.getAudience()); - } - - @Test - void authMethodAndAudienceAreReadFromTheConfiguration() { + void loginPropertiesReachTheOptions() { loginProperties.setMode(ConsulLoginMode.KUBERNETES); loginProperties.setAuthMethod("core-k8s"); - loginProperties.setAudience(AudienceName.DBAAS); TokenStorageFactory.CreateOptions opts = options(); + Assertions.assertEquals(ConsulLoginMode.KUBERNETES, opts.getMode()); Assertions.assertEquals("core-k8s", opts.getAuthMethod()); - Assertions.assertEquals(AudienceName.DBAAS, opts.getAudience()); } @Test - void fallbackRecheckIntervalDefaultComesFromTheBuilder() { - Assertions.assertEquals(TokenStorageFactory.CreateOptions.DEFAULT_FALLBACK_RECHECK_INTERVAL, - options().getFallbackRecheckInterval()); - } + void buildingTheOptionsAsksNoM2MTokenInAnyMode() { + for (ConsulLoginMode mode : ConsulLoginMode.values()) { + loginProperties.setMode(mode); - @Test - void fallbackRecheckIntervalIsReadFromTheConfiguration() { - loginProperties.setFallbackRecheckInterval(Duration.ofMinutes(30)); + Assertions.assertEquals(mode, options().getMode()); + } - Assertions.assertEquals(Duration.ofMinutes(30), options().getFallbackRecheckInterval()); + Mockito.verifyNoInteractions(m2MManager); } } From bf807ddda2ef1e7860ffe445d46558356faae107 Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Mon, 31 Aug 2026 17:14:04 +0400 Subject: [PATCH 18/20] docs(consul): correct the login documentation The recheck landed after most of this text was written, and the text still described the behavior it replaced: - ConsulLoginMode claimed a pod keeps one way for its whole life, and the fallback constant claimed it tries the kubernetes way once and stays on m2m for the rest of that life. Both stopped being true when the recheck arrived - the m2m constant said the auth method and the audience are not read. They are: the builder defaults them. They are not used - ConsulTokenProvider promised IOException on a transport failure of the self read, while SelfTokenReader documents the opposite, because the client does not wrap that one - getToken described the probe as happening after a recheck interval, leaving out the first call - LoginRetryPolicies said anything other than IOException means the input is wrong. A missing projected token is the environment, not the input The READMEs gain what an operator has to know and could not read anywhere: - fallback-recheck-interval is the lower bound, not the period. The recheck waits for the next relogin, which runs at 80% of MaxTokenTTL, so a fleet migrates on the MaxTokenTTL clock - a failed login ends differently on the two stacks: the spring ConfigData phase survives it and the TokenStorage bean does not, and on quarkus the bean cannot be produced at all - the fallback record is one per decision, but every login attempt still logs one of its own - cloud.microservice.namespace stays required on quarkus in the kubernetes mode, unlike the namespace on the spring side Also drops the ratchet metaphor, which named the mechanism after a machine part instead of describing it. --- .../config-sources/consul-client/README.md | 20 +++++++++++---- .../consul-config-provider/README.md | 25 +++++++++++++------ .../provider/common/ConsulLoginMode.java | 9 ++++--- .../provider/common/ConsulTokenProvider.java | 3 ++- .../provider/common/LoginRetryPolicies.java | 4 +-- 5 files changed, 41 insertions(+), 20 deletions(-) diff --git a/core-quarkus-extensions/config-sources/consul-client/README.md b/core-quarkus-extensions/config-sources/consul-client/README.md index 78ca6b8fe6..1d801b15fa 100644 --- a/core-quarkus-extensions/config-sources/consul-client/README.md +++ b/core-quarkus-extensions/config-sources/consul-client/README.md @@ -44,17 +44,27 @@ The Consul ACL token is exchanged through `POST /v1/acl/login`. Two ways to obta the projected service account token of the pod, `m2m` sends an M2M token. The four properties below are read at runtime when the `TokenStorage` bean is built, so the way can be switched without rebuilding the application. -In `kubernetes-with-m2m-fallback` mode the `kubernetes` way is tried first, and on failure the pod falls back to the -`m2m` way and logs one `INFO` record with the reason, the Consul response code, and a truncated response body. In -`kubernetes` mode there is no probe and no fallback. In `m2m` mode the auth method name and the audience are not used -at all. +In `kubernetes-with-m2m-fallback` mode the `kubernetes` way is tried first. If it fails, the pod falls back to the +`m2m` way and logs the reason, the Consul response code, and a truncated response body in a single `INFO` record. That +record marks the decision, so it appears once rather than on every retry; each login attempt logs an `INFO` record of +its own, naming the auth method it went to. In `kubernetes` mode there is no probe and no fallback. In `m2m` mode the +auth method name and the audience are not used at all. + +`cloud.microservice.namespace` stays required in every mode, including `kubernetes`, where the namespace never reaches +Consul: the producer reads it whatever the mode says. The fallback is temporary. Once `fallback-recheck-interval` has passed, the next scheduled relogin tries the `kubernetes` way again, and the first success switches the pod over for good. Going back to `m2m` never happens. The recheck rides on the scheduled relogin, so it needs `MaxTokenTTL` on the auth method: without it the token never expires, nothing is scheduled, and nothing is rechecked. -An unknown value of `quarkus.consul-source-config.login.mode` fails the startup. With +`fallback-recheck-interval` therefore sets the lower bound on how often the pod retries, not the actual period. The +relogin runs at 80% of `MaxTokenTTL`, and the recheck waits for the first relogin past the interval, so with a +`MaxTokenTTL` of 24 hours a pod retries about every 19 hours whatever the interval says. Plan the migration of a fleet +against `MaxTokenTTL`, and lower it on the auth method if the pods have to move over sooner. + +An unknown value of `quarkus.consul-source-config.login.mode` fails the startup, and so does a login failure the +retries do not fix: the `TokenStorage` bean cannot be produced without a token. With `quarkus.consul-source-config.m2m.enabled=false` none of the four properties are read. The default is the auth method the platform registers. Set `quarkus.consul-source-config.login.auth-method` only if diff --git a/core-rest-libraries/consul-config-provider/README.md b/core-rest-libraries/consul-config-provider/README.md index 2f5dc73e3e..f9e50445e1 100644 --- a/core-rest-libraries/consul-config-provider/README.md +++ b/core-rest-libraries/consul-config-provider/README.md @@ -12,20 +12,21 @@ Two ways to obtain the token are supported: ## Login properties The four properties below are read at startup, in the ConfigData phase and again when the `TokenStorage` bean is -built. In `kubernetes-with-m2m-fallback` mode the first login picks the way, and a later failure never switches it -back on its own. +built. In `kubernetes-with-m2m-fallback` mode the first login picks the way the pod starts on, a recheck can move it +to the `kubernetes` way later, and nothing ever moves it back. | Property name | Type | Default | Read when | |-----------------------------------------------------|-------------------------------|--------------------------------------|--------------------------------------------------------------| | `spring.cloud.consul.config.login.mode` | `kubernetes-with-m2m-fallback`, `kubernetes` or `m2m` | `kubernetes-with-m2m-fallback` | ConfigData phase and `TokenStorage` bean creation | -| `spring.cloud.consul.config.login.auth-method` | string | `applications-k8s-m2m` | same, and only when the `kubernetes` way is used | -| `spring.cloud.consul.config.login.audience` | string | `netcracker` | same, and only when the `kubernetes` way is used | +| `spring.cloud.consul.config.login.auth-method` | string | `applications-k8s-m2m` | same; not used in `m2m` mode | +| `spring.cloud.consul.config.login.audience` | string | `netcracker` | same; read only when the `kubernetes` way logs in | | `spring.cloud.consul.config.login.fallback-recheck-interval` | duration | `5h` | same, and only in the fallback mode | In `kubernetes-with-m2m-fallback` mode the `kubernetes` way is tried first. If it fails, the pod falls back to the -`m2m` way and logs one `INFO` record with the reason, the Consul response code, and a truncated response body. In -`kubernetes` mode there is no probe and no fallback, and neither `M2MManager` nor the namespace is needed. In `m2m` -mode the auth method name and the audience are not used at all. +`m2m` way and logs the reason, the Consul response code, and a truncated response body in a single `INFO` record. That +record marks the decision, so it appears once rather than on every retry; each login attempt logs an `INFO` record of +its own, naming the auth method it went to. In `kubernetes` mode there is no probe and no fallback, and neither +`M2MManager` nor the namespace is needed. In `m2m` mode the auth method name and the audience are not used at all. The fallback is temporary. Once `fallback-recheck-interval` has passed, the next scheduled relogin tries the `kubernetes` way again, and the first success switches the pod over for good, with a second `INFO` record. Going back @@ -35,7 +36,15 @@ of probing again. The recheck rides on the scheduled relogin instead of a timer `MaxTokenTTL` on the auth method: without it Consul issues a token that never expires, nothing is scheduled, and nothing is rechecked. -An unknown value of `spring.cloud.consul.config.login.mode` fails the startup. +`fallback-recheck-interval` therefore sets the lower bound on how often the pod retries, not the actual period. The +relogin runs at 80% of `MaxTokenTTL`, and the recheck waits for the first relogin past the interval, so with a +`MaxTokenTTL` of 24 hours a pod retries about every 19 hours whatever the interval says. Plan the migration of a fleet +against `MaxTokenTTL`, and lower it on the auth method if the pods have to move over sooner. + +An unknown value of `spring.cloud.consul.config.login.mode` fails the startup. A failed login in the ConfigData phase +does not, in any mode: the phase logs one `ERROR` record and the application starts without an ACL token, leaving the +`TokenStorage` bean to obtain one, and Consul reads fail until it does. The bean is stricter — a login failure its +retries do not fix ends the startup. The default is the auth method the platform registers. Set `spring.cloud.consul.config.login.auth-method` only if your Consul names it differently. diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java index b6dc8ca68b..56ac77fd67 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulLoginMode.java @@ -1,13 +1,14 @@ package com.netcracker.cloud.consul.provider.common; /** - * Which way of obtaining a Consul ACL token a pod uses. A pod keeps one way for its whole life. + * Which way of obtaining a Consul ACL token a pod uses. The mode is read at startup and does not change, but in the + * fallback mode the way the pod actually takes can change while it runs. */ public enum ConsulLoginMode { - /** Tries the kubernetes way once, and falls back to m2m for the rest of the pod's life if that fails. */ + /** Tries the kubernetes way, serves m2m while that fails, and retries it on the scheduled relogin. */ KUBERNETES_WITH_M2M_FALLBACK, - /** Only the kubernetes way, with no probe and no fallback. A failed login is a failure. */ + /** Only the kubernetes way, with no probe and no fallback: a failed login is not retried by another way. */ KUBERNETES, - /** Only the m2m way. The auth method name and the audience are not read. */ + /** Only the m2m way. The auth method name and the audience are not used. */ M2M } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java index d2980f7c55..692c91bedb 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/ConsulTokenProvider.java @@ -19,7 +19,8 @@ public interface ConsulTokenProvider { * Reads the token the pod already holds instead of obtaining a new one. Consul reports the auth method the token * was issued to, so a provider that carries state across logins recovers it here rather than starting over. * - * @throws IOException on a transport failure or a non-2xx answer from Consul; the caller may retry + * @throws IOException on a non-2xx answer or an empty body; the caller may retry. Unlike {@link #getToken()}, this + * read reports a transport failure unchecked, in whatever type the client throws */ Token getSelfToken(String currentSecretId) throws IOException; } diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java index 15852b4f0b..ec8d3d50ee 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginRetryPolicies.java @@ -8,8 +8,8 @@ /** * Retry policies shared by the login callers. Only {@link IOException} is retried: by the exception contract of the - * module it means a transport failure or a non-2xx answer, where another attempt may help, while anything else means - * the input itself is wrong. + * module it means a transport failure or a non-2xx answer, where another attempt may help. Anything else — a missing + * projected token, an answer without a {@code SecretID} — comes out the same however many times it is tried. */ final class LoginRetryPolicies { From b04499b74bcce6f2e40d834a0be456dc36f68b9c Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Tue, 1 Sep 2026 14:49:47 +0400 Subject: [PATCH 19/20] chore: minor refactoring --- ...lClientTest.java => RetryableConsulClientTest.java} | 4 ++-- .../consul/provider/common/LoginTokenProvider.java | 10 +++++----- .../consul/provider/common/LoginTokenProviderTest.java | 10 ++++++++++ 3 files changed, 17 insertions(+), 7 deletions(-) rename core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/{RetrableConsulClientTest.java => RetryableConsulClientTest.java} (97%) diff --git a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/RetrableConsulClientTest.java b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/RetryableConsulClientTest.java similarity index 97% rename from core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/RetrableConsulClientTest.java rename to core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/RetryableConsulClientTest.java index f1e975e1ee..17b67a8efd 100644 --- a/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/RetrableConsulClientTest.java +++ b/core-quarkus-extensions/config-sources/consul-client/runtime/src/test/java/com/netcracker/cloud/quarkus/consul/client/RetryableConsulClientTest.java @@ -13,7 +13,7 @@ import java.util.List; import java.util.Optional; -class RetrableConsulClientTest { +class RetryableConsulClientTest { ConsulSourceConfig consulDefaultSourceConfig; @@ -28,7 +28,7 @@ public boolean enabled() { @Override public AgentConfig agent() { - return () -> Optional.empty(); + return Optional::empty; } @Override diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java index 8e321f4804..2a0388ebe4 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java @@ -29,8 +29,8 @@ public LoginTokenProvider(ConsulClient client, ConsulLoginCredentials credential } /** - * @throws IOException on an empty body or a non-2xx answer; the message carries the response code, and a - * {@code 403} is reported as a Consul configuration that is not ready yet + * @throws IOException on a non-2xx answer or a successful answer with an empty body; a non-2xx message carries + * the response code, and a {@code 403} is reported as a Consul configuration that is not ready yet * @throws com.jayway.jsonpath.PathNotFoundException when the answer carries no {@code SecretID}; retrying that * does not help */ @@ -38,13 +38,13 @@ public LoginTokenProvider(ConsulClient client, ConsulLoginCredentials credential public Token getToken() throws IOException { ConsulClientResponse response = client.login(credentials); String responseBody = response.getBodyJson(); - if (responseBody == null || responseBody.isEmpty()) { - throw new IOException("can not get consul token: response body is empty"); - } if (response.getCode() != 200) { String reason = response.getCode() == 403 ? "consul auth method is not ready" : "login to consul failed"; throw new IOException(String.format("%s: response code=%s; body='%s'", reason, response.getCode(), responseBody)); } + if (responseBody == null || responseBody.isEmpty()) { + throw new IOException("can not get consul token: response body is empty"); + } String secretId = JsonPath.read(responseBody, "$.SecretID"); OffsetDateTime expirationTime = null; try { diff --git a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/LoginTokenProviderTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/LoginTokenProviderTest.java index a5e07f0bf2..bc89eb5828 100644 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/LoginTokenProviderTest.java +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/LoginTokenProviderTest.java @@ -82,6 +82,16 @@ void performFailsWithIOExceptionOnNonSuccessCode() throws IOException { assertThrows(IOException.class, () -> tokenProvider.getToken()); } + @Test + void nonSuccessCodeIsReportedWhenResponseBodyIsEmpty() throws IOException { + when(consulClient.login(any(ConsulLoginCredentials.class))) + .thenReturn(new ConsulClientResponse("", 403)); + + IOException exception = assertThrows(IOException.class, () -> tokenProvider.getToken()); + + assertEquals("consul auth method is not ready: response code=403; body=''", exception.getMessage()); + } + @Test void performFailsWithPathNotFoundExceptionWhenSecretIdIsMissing() throws IOException { when(consulClient.login(any(ConsulLoginCredentials.class))) From 5c904c39f1424652cf6d9234c8c074f13ee5919c Mon Sep 17 00:00:00 2001 From: Vladislav Larkin Date: Wed, 2 Sep 2026 17:30:28 +0400 Subject: [PATCH 20/20] docs(consul): keep the kubernetes way independent of the auth method type Infrastructure is considering an auth method of type jwt instead of type kubernetes. The login request carries the same two fields either way, so the README should not pin the way to one type of auth method on the Consul side. --- core-rest-libraries/consul-config-provider/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core-rest-libraries/consul-config-provider/README.md b/core-rest-libraries/consul-config-provider/README.md index f9e50445e1..fc05995dd3 100644 --- a/core-rest-libraries/consul-config-provider/README.md +++ b/core-rest-libraries/consul-config-provider/README.md @@ -5,8 +5,9 @@ Obtains a Consul ACL token for a microservice and keeps it fresh. The token is e Two ways to obtain the token are supported: -- `kubernetes` — the projected Kubernetes service account token of the pod is sent to a Consul auth method of type - `kubernetes`. +- `kubernetes` — the projected Kubernetes service account token of the pod is sent to the auth method the platform + registers for it. Whether Consul reviews that token at the API server or validates its signature on its own is a + property of the auth method, and the login request is the same either way. - `m2m` — an M2M token issued by `M2MManager` is sent to a Consul auth method named after the namespace. ## Login properties