diff --git a/core-quarkus-extensions/config-sources/consul-client/README.md b/core-quarkus-extensions/config-sources/consul-client/README.md index 40d18e151b..1d801b15fa 100644 --- a/core-quarkus-extensions/config-sources/consul-client/README.md +++ b/core-quarkus-extensions/config-sources/consul-client/README.md @@ -38,6 +38,41 @@ 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 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. 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. + +`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 +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`. + #### Configuration properties | Property name | Description | Default value | |----------------------------------------------|---------------------------------------------------------------|-----------------------------------------------------------| @@ -45,3 +80,8 @@ 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: 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 | 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-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..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 @@ -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; @@ -17,11 +18,18 @@ import java.net.MalformedURLException; import java.net.URL; +import java.time.Duration; import java.util.Optional; @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"; + 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); @Produces @@ -68,11 +76,20 @@ 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, + @ConfigProperty(name = PROP_LOGIN_FALLBACK_RECHECK_INTERVAL) + Optional fallbackRecheckInterval) { 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)) + .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 new file mode 100644 index 0000000000..433817cfd8 --- /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,63 @@ +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.time.Duration; +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", + ConsulClientConfiguration.PROP_LOGIN_FALLBACK_RECHECK_INTERVAL, "PT30M" + ); + } + } + + @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()); + Assertions.assertEquals(Duration.ofMinutes(30), options.getValue().getFallbackRecheckInterval()); + } +} 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..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 @@ -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.KUBERNETES_WITH_M2M_FALLBACK, 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..5d59b7be07 --- /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,48 @@ +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.time.Duration; +import java.util.Map; + +class ConsulLoginModeConfigTest { + + private static SmallRyeConfig configWithMode(String mode) { + return new SmallRyeConfigBuilder() + .withDefaultValues(Map.of(ConsulClientConfiguration.PROP_LOGIN_MODE, mode)) + .build(); + } + + private static ConsulLoginMode read(String mode) { + return configWithMode(mode).getValue(ConsulClientConfiguration.PROP_LOGIN_MODE, ConsulLoginMode.class); + } + + @Test + 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 + void unknownModeBreaksTheStart() { + SmallRyeConfig config = configWithMode("cloud-foundry"); + + 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-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/README.md b/core-rest-libraries/consul-config-provider/README.md new file mode 100644 index 0000000000..fc05995dd3 --- /dev/null +++ b/core-rest-libraries/consul-config-provider/README.md @@ -0,0 +1,63 @@ +# 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 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 + +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 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; 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 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 +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. + +`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. + +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/pom.xml b/core-rest-libraries/consul-config-provider/consul-config-provider-common/pom.xml index 71d3c2e568..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 @@ -22,10 +22,20 @@ com.netcracker.cloud microservice-restclient-api + + com.netcracker.cloud.security.core.utils + k8s-utils + ${cloud-core.core-utils.version} + com.squareup.okhttp3 okhttp + + net.jodah + failsafe + 2.4.4 + org.junit.jupiter junit-jupiter-engine @@ -36,6 +46,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/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..12394df821 --- /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,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 new file mode 100644 index 0000000000..56ac77fd67 --- /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,14 @@ +package com.netcracker.cloud.consul.provider.common; + +/** + * 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, 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 not retried by another way. */ + KUBERNETES, + /** 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 new file mode 100644 index 0000000000..692c91bedb --- /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,26 @@ +package com.netcracker.cloud.consul.provider.common; + +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; + + /** + * 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 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/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..a0e4fc4fa9 --- /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,28 @@ +package com.netcracker.cloud.consul.provider.common; + +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; + private final String audience; + + KubernetesLoginCredentials(String authMethod, String audience) { + this.authMethod = authMethod; + this.audience = audience; + } + + @Override + public String getAuthMethod() { + return authMethod; + } + + @Override + 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/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..894f65efbd --- /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,163 @@ +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.Clock; +import java.time.Duration; +import java.time.Instant; + +/** + * 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 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); + + static final int PROBE_TRIES = 3; + 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 final ConsulTokenProvider kubernetesProvider; + private final ConsulTokenProvider m2mProvider; + private final String kubernetesAuthMethod; + private final int tries; + private final Duration probeDelay; + private final Duration recheckInterval; + private final Clock clock; + + private boolean isKubernetesConfirmed; + private Instant fellBackAt; + + KubernetesWithM2MFallbackTokenProvider(ConsulTokenProvider kubernetesProvider, ConsulTokenProvider m2mProvider, + String kubernetesAuthMethod, Duration recheckInterval) { + this(kubernetesProvider, m2mProvider, kubernetesAuthMethod, PROBE_TRIES, DEFAULT_PROBE_PAUSE, recheckInterval, + Clock.systemUTC()); + } + + KubernetesWithM2MFallbackTokenProvider(ConsulTokenProvider kubernetesProvider, ConsulTokenProvider m2mProvider, + 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; + this.clock = clock; + } + + /** + * 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 + public synchronized Token getToken() throws IOException { + if (isKubernetesConfirmed) { + return kubernetesProvider.getToken(); + } + if (recheckIsDue()) { + try { + Token token = probeKubernetesWay(); + confirmKubernetesWay(); + return token; + } catch (Exception e) { + fallBack(e); + } + } + 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 (isKubernetesConfirmed || fellBackAt != null || authMethod == null) { + return; + } + if (kubernetesAuthMethod.equals(authMethod)) { + confirmKubernetesWay(); + } else { + fellBackAt = clock.instant(); + } + } + + private boolean recheckIsDue() { + return fellBackAt == null || !clock.instant().isBefore(fellBackAt.plus(recheckInterval)); + } + + private void confirmKubernetesWay() { + isKubernetesConfirmed = true; + + boolean afterFallback = fellBackAt != null; + 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); + } + } + + 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(); + } + + /** + * 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", + event.getAttemptCount(), KUBERNETES_WAY, event.getLastFailure()))) + .get(kubernetesProvider::getToken); + } catch (FailsafeException e) { + Throwable cause = e.getCause(); + throw cause instanceof IOException ? (IOException) cause : new IOException(cause); + } + } + + 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/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..ec8d3d50ee --- /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,38 @@ +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; + +/** + * 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. 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 { + + static final double JITTER = 0.25; + private static final int MAX_BACKOFF_FACTOR = 8; + + 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) + .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/LoginTokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java new file mode 100644 index 0000000000..2a0388ebe4 --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/LoginTokenProvider.java @@ -0,0 +1,63 @@ +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; + +/** + * 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); + + 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); + } + + /** + * @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 + */ + @Override + public Token getToken() throws IOException { + ConsulClientResponse response = client.login(credentials); + String responseBody = response.getBodyJson(); + 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 { + expirationTime = OffsetDateTime.parse(JsonPath.read(responseBody, "$.ExpirationTime")); + } catch (PathNotFoundException ex) { + // No Expiration Time. Nothing to do. + } + 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/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..c423b3f2f4 --- /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,29 @@ +package com.netcracker.cloud.consul.provider.common; + +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; + private final Supplier m2mSupplier; + + M2MLoginCredentials(String namespace, Supplier m2mSupplier) { + this.namespace = namespace; + this.m2mSupplier = m2mSupplier; + } + + @Override + public String getAuthMethod() { + return namespace; + } + + @Override + 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/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..e0e42c8251 --- /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,66 @@ +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; + +/** + * 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 — every {@link + * ConsulTokenProvider} gets the same answer from a reader over the same client. + */ +public class SelfTokenReader { + + private static final Logger log = LoggerFactory.getLogger(SelfTokenReader.class); + + private final ConsulClient client; + + 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 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 + */ + 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)); + } + 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; + try { + expirationTime = OffsetDateTime.parse(JsonPath.read(bodyJson, "$.ExpirationTime")); + } catch (PathNotFoundException ex) { + // No Expiration Time. Nothing to do. + } + 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 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..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 @@ -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,13 @@ public String getSecretId() { public OffsetDateTime getExpirationTime() { return expirationTime; } + + /** + * 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/TokenProvider.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenProvider.java deleted file mode 100644 index 438a59a728..0000000000 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/main/java/com/netcracker/cloud/consul/provider/common/TokenProvider.java +++ /dev/null @@ -1,57 +0,0 @@ -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 TokenProvider { - - private static final Logger log = LoggerFactory.getLogger(TokenProvider.class); - - private final ConsulClient client; - private final String authMethod; - - public TokenProvider(ConsulClient client, String authMethod) { - this.client = client; - this.authMethod = authMethod; - } - - 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); - 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 secretId = JsonPath.read(responseBody, "$.SecretID"); - OffsetDateTime expirationTime = null; - try { - expirationTime = OffsetDateTime.parse(JsonPath.read(responseBody, "$.ExpirationTime")); - } catch (PathNotFoundException ex) { - // No Expiration Time. Nothing to do. - } - log.debug("Got new token from Consul by login procedure"); - 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/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..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 @@ -1,8 +1,10 @@ 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.time.Duration; +import java.util.Locale; import java.util.function.Supplier; /** @@ -17,20 +19,71 @@ 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)); TokenStorage tokenStorage = createTokenStorage(config); tokenUpdater.watch(tokenStorage::update, tokenStorage.get()); 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: + return m2mProvider(client, options); + case KUBERNETES: + return kubernetesProvider(client, options); + 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"); + } + } + + private static ConsulTokenProvider m2mProvider(ConsulClient client, CreateOptions options) { + return new LoginTokenProvider(client, new M2MLoginCredentials(options.namespace, options.m2mSupplier)); + } + + private static ConsulTokenProvider kubernetesProvider(ConsulClient client, CreateOptions options) { + return new LoginTokenProvider(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 = "applications-k8s-m2m"; + public static final Duration DEFAULT_FALLBACK_RECHECK_INTERVAL = Duration.ofHours(5); + String consulUrl; String namespace; Supplier m2mSupplier; + ConsulLoginMode mode; + String authMethod; + String audience; + Duration fallbackRecheckInterval; + + public ConsulLoginMode getMode() { + return mode; + } + + public String getAuthMethod() { + return authMethod; + } + + public String getAudience() { + return audience; + } + + public Duration getFallbackRecheckInterval() { + return fallbackRecheckInterval; + } public static class Builder { CreateOptions options = new CreateOptions(); @@ -53,14 +106,61 @@ 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 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. + * + * @throws IllegalArgumentException naming the missing input and the mode that requires it + */ public CreateOptions build() { - Objects.requireNonNull(options.consulUrl); - Objects.requireNonNull(options.namespace); - Objects.requireNonNull(options.m2mSupplier); + if (options.mode == null) { + options.mode = ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK; + } + if (options.authMethod == null) { + options.authMethod = DEFAULT_AUTH_METHOD; + } + 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); + 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).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 6c15662a91..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 @@ -1,11 +1,14 @@ package com.netcracker.cloud.consul.provider.common; -import com.netcracker.cloud.consul.provider.common.client.ConsulClient; +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; -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; @@ -13,68 +16,117 @@ 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); + private static final int DEFAULT_TRIES = 10; - private final TokenProvider tokenProvider; + private static final Duration DEFAULT_RETRY_PAUSE = Duration.ofSeconds(1); + private static final double DELAY_MULTIPLIER = 0.8; + static final long MIN_DELAY_SECONDS = 10; + + private final ConsulTokenProvider tokenProvider; 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(ConsulTokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; this.tries = DEFAULT_TRIES; + this.retryPause = DEFAULT_RETRY_PAUSE; } - TokenUpdater(TokenProvider tokenProvider, ScheduledExecutorService executor, Clock clock, int tries) { + TokenUpdater(ConsulTokenProvider tokenProvider, ScheduledExecutorService executor, Clock clock, int tries, Duration retryPause) { this.tokenProvider = tokenProvider; this.executor = executor; this.clock = clock; this.tries = tries; + 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; if (currentSecretId == null || currentSecretId.isEmpty()) { - token = withRetry(unused -> tokenProvider.getNewConsulToken(), tries); + token = withRetry(tokenProvider::getToken, tries); updater.accept(token.getSecretId()); } else { - token = withRetry(unused -> tokenProvider.getSelf(currentSecretId), tries); + token = withRetry(() -> tokenProvider.getSelfToken(currentSecretId), tries); } if (token.getExpirationTime() != null) { - long delay = ChronoUnit.SECONDS.between(OffsetDateTime.now(clock), token.getExpirationTime().minusMinutes(5)); - Runnable task = () -> { - log.debug("Get new consul token with {} retry attempts", tries); - try { - Token newToken = withRetry(unused -> tokenProvider.getNewConsulToken(), 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, token.getExpirationTime()); } } - private Token withRetry(CheckedFunction c, int tries) { - int count = 0; - while (true) { + /** + * 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) { + 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 { - return c.apply(null); - } catch (IOException e) { - if (++count >= tries) { - throw new RuntimeException("can not update consul token: ", e); + 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; } - log.debug("Failed {} retry attempt, exception: {}", count, e); + scheduleRelogin(updater, newToken.getExpirationTime()); + } catch (Throwable e) { + long retryDelaySeconds = reloginDelaySeconds(expirationTime); + log.error("Error occurred during getting new consul token. Will try in {} seconds.", retryDelaySeconds, e); + scheduleReloginIn(updater, expirationTime, retryDelaySeconds); } + }, delaySeconds, 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. 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); + return Math.max((long) (remaining * DELAY_MULTIPLIER), MIN_DELAY_SECONDS); + } + + private Token withRetry(CheckedSupplier c, int tries) { + try { + return Failsafe.with(getRetryPolicy(tries)).get(c); + } catch (FailsafeException e) { + throw new RuntimeException("can not update consul token: ", e.getCause()); } } - @FunctionalInterface - public interface CheckedFunction { - R apply(T t) throws IOException; + 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())); } } 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..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 @@ -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,16 @@ public interface ConsulClient { String CONTENT_TYPE = "Content-Type"; ConsulClientResponse getSelfToken(String currentSecretId); + + @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 477d75f635..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 @@ -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; @@ -48,25 +49,35 @@ 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()); } -} + /** + * 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 { + return login(credentials.getAuthMethod(), credentials.getBearerToken()); + } + + private ConsulClientResponse login(String authMethod, String bearerToken) throws IOException { + Map payload = new HashMap<>(); + 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, 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..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 @@ -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; @@ -46,15 +49,30 @@ public ConsulClientResponse getSelfToken(String currentSecretId) { @Override public ConsulClientResponse login(String authMethod) { + return login(authMethod, m2mTokenSupplier.get()); + } + + @Override + public ConsulClientResponse login(ConsulLoginCredentials credentials) throws IOException { + try { + 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, m2mTokenSupplier.get()); + 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.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()); } 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..be3ef66b4c --- /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.getAuthMethod()); + } + + @Test + void bearerTokenIsReadOnEveryCallAndNotCached() throws IOException { + writeToken(AudienceName.NETCRACKER, "first-projected-token"); + KubernetesLoginCredentials credentials = new KubernetesLoginCredentials("core-k8s", AudienceName.NETCRACKER); + + assertEquals("first-projected-token", credentials.getBearerToken()); + + writeToken(AudienceName.NETCRACKER, "rotated-projected-token"); + assertEquals("rotated-projected-token", credentials.getBearerToken()); + } + + @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::getBearerToken); + assertInstanceOf(IOException.class, thrown.getCause()); + } + + @Test + void bearerTokenFailsWithIllegalArgumentExceptionForUnknownAudience() { + KubernetesLoginCredentials credentials = new KubernetesLoginCredentials("core-k8s", "no-such-audience"); + + 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/KubernetesWithM2MFallbackTokenProviderTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java new file mode 100644 index 0000000000..78223789e0 --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/KubernetesWithM2MFallbackTokenProviderTest.java @@ -0,0 +1,376 @@ +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.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; + +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.never; +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 KubernetesWithM2MFallbackTokenProviderTest { + + 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 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; + private TestClock clock; + private ListAppender appender; + private ch.qos.logback.classic.Logger logger; + + @BeforeEach + 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<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.INFO); + } + + @AfterEach + void detachAppender() { + logger.detachAppender(appender); + } + + private KubernetesWithM2MFallbackTokenProvider probing() { + return new KubernetesWithM2MFallbackTokenProvider(kubernetes, m2m, KUBERNETES_AUTH_METHOD, 2, Duration.ZERO, + RECHECK_INTERVAL, clock); + } + + 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.getToken()).thenReturn(token); + + KubernetesWithM2MFallbackTokenProvider probing = probing(); + assertEquals(token, probing.getToken()); + assertEquals(token, probing.getToken()); + + verify(kubernetes, times(2)).getToken(); + verifyNoInteractions(m2m); + } + + @Test + void anyExceptionFromTheNewWayLeadsToTheOldOneAndSticksIt() throws IOException { + Token token = new Token(SECRET_ID, null); + when(kubernetes.getToken()).thenThrow(new IllegalArgumentException("Unknown token audience: netcracker")); + when(m2m.getToken()).thenReturn(token); + + KubernetesWithM2MFallbackTokenProvider probing = probing(); + assertEquals(token, probing.getToken()); + assertEquals(token, probing.getToken()); + + verify(kubernetes, times(1)).getToken(); + verify(m2m, times(2)).getToken(); + } + + @Test + void errorPassesThrough() throws IOException { + when(kubernetes.getToken()).thenThrow(new Error("Unable to locate implementation for TokenSource")); + + assertThrows(Error.class, () -> probing().getToken()); + verifyNoInteractions(m2m); + } + + @Test + void probeSpendsFewerTriesThanTokenUpdater() throws IOException { + when(kubernetes.getToken()).thenThrow(new 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, KUBERNETES_AUTH_METHOD, RECHECK_INTERVAL).getToken(); + + verify(kubernetes, times(KubernetesWithM2MFallbackTokenProvider.PROBE_TRIES)).getToken(); + } + + @Test + void chosenWayIsLoggedOnceOnSuccess() throws IOException { + when(kubernetes.getToken()).thenReturn(new Token(SECRET_ID, null)); + + KubernetesWithM2MFallbackTokenProvider probing = probing(); + probing.getToken(); + probing.getToken(); + + List records = infoRecords(); + assertEquals(1, records.size()); + assertTrue(records.get(0).contains("kubernetes")); + } + + @Test + void fallbackIsLoggedOnceWithReasonCodeAndBody() throws IOException { + when(kubernetes.getToken()).thenThrow(new IOException( + "login to consul failed: response code=500; body='" + TOKEN_REVIEW_UNREACHABLE + "'")); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); + + KubernetesWithM2MFallbackTokenProvider probing = probing(); + probing.getToken(); + probing.getToken(); + + 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.getToken()).thenThrow(new IOException( + "consul auth method is not ready: response code=403; body='" + AUTH_METHOD_NOT_FOUND + "'")); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); + + probing().getToken(); + + 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.getToken()).thenThrow(new IOException( + "login to consul failed: response code=500; body='" + longBody + "'")); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); + + probing().getToken(); + + String record = infoRecords().get(0); + assertTrue(record.length() < longBody.length()); + } + + @Test + void secretsNeverReachTheLog() throws IOException { + when(kubernetes.getToken()).thenThrow(new IOException( + "login to consul failed: response code=500; body='" + TOKEN_REVIEW_UNREACHABLE + "'")); + when(m2m.getToken()).thenReturn(new Token(SECRET_ID, null)); + + probing().getToken(); + + String logged = String.join("\n", infoRecords()); + assertFalse(logged.contains(BEARER_TOKEN)); + assertFalse(logged.contains(SECRET_ID)); + } + + @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)); + } + + @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); + } + + 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/LoginTokenProviderTest.java b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/LoginTokenProviderTest.java new file mode 100644 index 0000000000..bc89eb5828 --- /dev/null +++ b/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/LoginTokenProviderTest.java @@ -0,0 +1,103 @@ +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; +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.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 LoginTokenProviderTest { + + 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 LoginTokenProvider tokenProvider; + + @BeforeEach + public void init() { + consulClient = mock(ConsulClient.class); + credentials = new M2MLoginCredentials("test", () -> "test-m2m-token"); + tokenProvider = new LoginTokenProvider(consulClient, credentials); + } + + @Test + void performReadsSecretIdAndExpirationTime() throws IOException { + when(consulClient.login(any(ConsulLoginCredentials.class))) + .thenReturn(new ConsulClientResponse(LOGIN_RESPONSE_WITH_EXPIRATION, 200)); + + Token token = tokenProvider.getToken(); + + 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.getToken(); + + assertEquals("test-secret-id", token.getSecretId()); + assertNull(token.getExpirationTime()); + } + + @Test + void performFailsWithIOExceptionOnEmptyBody() throws IOException { + when(consulClient.login(any(ConsulLoginCredentials.class))) + .thenReturn(new ConsulClientResponse("", 200)); + + assertThrows(IOException.class, () -> tokenProvider.getToken()); + } + + @Test + void performFailsWithIOExceptionOnNonSuccessCode() throws IOException { + when(consulClient.login(any(ConsulLoginCredentials.class))) + .thenReturn(new ConsulClientResponse("{\"Error\":\"Permission denied\"}", 403)); + + 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))) + .thenReturn(new ConsulClientResponse("{\"AccessorID\":\"test-accessor-id\"}", 200)); + + 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/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..c15764595e --- /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.getAuthMethod()); + } + + @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.getBearerToken()); + assertEquals("test-m2m-token-2", credentials.getBearerToken()); + 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/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..c97436ed2d --- /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,106 @@ +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 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; + + @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 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())) + .thenReturn(new ConsulClientResponse("token does not exist: ACL not found", 403)); + + 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-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 deleted file mode 100644 index 0479c9d67a..0000000000 --- a/core-rest-libraries/consul-config-provider/consul-config-provider-common/src/test/java/com/netcracker/cloud/consul/provider/common/TokenProviderTest.java +++ /dev/null @@ -1,57 +0,0 @@ -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.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 TokenProviderTest { - - private ConsulClient consulClient; - private TokenProvider tokenProvider; - - @BeforeEach - public void init() { - consulClient = mock(ConsulClient.class); - tokenProvider = new TokenProvider(consulClient, "test"); - } - - @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)); - } - - @Test - void getNewConsulToken() throws IOException { - String secretId = "test-secret-id"; - OffsetDateTime expirationTime = OffsetDateTime.now().plusMinutes(30); - - when(consulClient.login(anyString())) - .thenReturn(new ConsulClientResponse("{\"SecretID\": \"" + secretId + "\", \"ExpirationTime\": \"" + expirationTime + "\"}", 200)); - - Token token = tokenProvider.getNewConsulToken(); - verify(consulClient).login("test"); - assertEquals(secretId, token.getSecretId()); - assertEquals(expirationTime.toEpochSecond(), token.getExpirationTime().toEpochSecond()); - } -} 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..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 @@ -1,7 +1,21 @@ 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.time.Duration; +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 +37,216 @@ 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 modeIsKubernetesWithM2MFallbackWhenNotGiven() { + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .namespace(NAMESPACE) + .m2mSupplier(() -> "token") + .build(); + + Assertions.assertEquals(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK, opts.mode); + } + + @Test + void fallbackModeNamesTheMissingInputAndTheMode() { + IllegalArgumentException thrown = Assertions.assertThrows(IllegalArgumentException.class, + () -> new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .mode(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK) + .build()); + + Assertions.assertTrue(thrown.getMessage().contains("namespace"), thrown.getMessage()); + Assertions.assertTrue(thrown.getMessage().contains("kubernetes-with-m2m-fallback"), 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 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 { + when(client.login(any(ConsulLoginCredentials.class))) + .thenReturn(new ConsulClientResponse("{\"SecretID\":\"test-secret-id\"}", 200)); + + login.getToken(); + + 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(); + + ConsulTokenProvider login = TokenStorageFactory.from(client, opts); + + Assertions.assertInstanceOf(LoginTokenProvider.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(); + + ConsulTokenProvider login = TokenStorageFactory.from(client, opts); + + Assertions.assertInstanceOf(LoginTokenProvider.class, login); + ConsulLoginCredentials used = credentialsUsedBy(login, client); + Assertions.assertInstanceOf(KubernetesLoginCredentials.class, used); + Assertions.assertEquals("core-k8s", used.getAuthMethod()); + Assertions.assertEquals(0, m2mCalls.get()); + } + + @Test + void fallbackModeBuildsProbingLoginOverBothWays() throws IOException { + ConsulClient client = mock(ConsulClient.class); + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .mode(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK) + .namespace(NAMESPACE) + .authMethod("core-k8s") + .m2mSupplier(() -> "token") + .build(); + + ConsulTokenProvider login = TokenStorageFactory.from(client, opts); + + Assertions.assertInstanceOf(KubernetesWithM2MFallbackTokenProvider.class, login); + Assertions.assertInstanceOf(KubernetesLoginCredentials.class, credentialsUsedBy(login, client)); + } + + @Test + void fallbackModeFallsBackToM2MCredentials() throws IOException { + ConsulClient client = mock(ConsulClient.class); + TokenStorageFactory.CreateOptions opts = new TokenStorageFactory.CreateOptions.Builder() + .consulUrl(CONSUL_URL) + .mode(ConsulLoginMode.KUBERNETES_WITH_M2M_FALLBACK) + .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).getToken(); + + 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)); + } + + @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 3d9faa6c3d..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 @@ -3,51 +3,53 @@ 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.AtomicInteger; 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 ConsulTokenProvider tokenProvider; private ScheduledExecutorService scheduledExecutorService; private final Instant currentTime = Instant.now(); @BeforeEach public void init() { - tokenProvider = Mockito.mock(TokenProvider.class); + tokenProvider = Mockito.mock(ConsulTokenProvider.class); scheduledExecutorService = Mockito.mock(ScheduledExecutorService.class); - tokenUpdater = new TokenUpdater(tokenProvider, scheduledExecutorService, Clock.fixed(currentTime, ZoneId.of("UTC")), 2); + tokenUpdater = new TokenUpdater(tokenProvider, 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(tokenProvider.getToken()).thenReturn(new Token(secretId, secretExpirationTime)); AtomicReference updater = new AtomicReference<>(""); tokenUpdater.watch(updater::set, ""); assertEquals(secretId, updater.get()); - verify(scheduledExecutorService).scheduleWithFixedDelay( - any(), - eq(ChronoUnit.SECONDS.between(currentTime, secretExpirationTime.minusMinutes(5))), - eq(ChronoUnit.SECONDS.between(currentTime, secretExpirationTime.minusMinutes(5))), + verify(scheduledExecutorService).schedule( + any(Runnable.class), + eq(1440L), eq(TimeUnit.SECONDS) ); } @@ -56,15 +58,14 @@ 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(tokenProvider.getSelfToken(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))), + verify(scheduledExecutorService).schedule( + any(Runnable.class), + eq(1440L), eq(TimeUnit.SECONDS) ); } @@ -73,21 +74,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(tokenProvider.getToken()) .thenThrow(new IOException()) .thenReturn(new Token(secretId, secretExpirationTime)); AtomicReference updater = new AtomicReference<>(""); tokenUpdater.watch(updater::set, ""); - verify(tokenProvider, times(2)).getNewConsulToken(); + 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(tokenProvider.getNewConsulToken()) + when(tokenProvider.getToken()) .thenReturn(new Token(secretId, secretExpirationTime)) .thenThrow(new IOException()) .thenThrow(new IOException()) @@ -95,17 +96,213 @@ 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(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()); } + + @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(tokenProvider.getToken()) + .thenReturn(new Token(secretId, secretExpirationTime)) + .thenReturn(new Token(rotatedSecretId, secretExpirationTime)); + + AtomicReference updater = new AtomicReference<>(""); + runScheduledTaskOnce(); + + tokenUpdater.watch(updater::set, ""); + + assertEquals(rotatedSecretId, updater.get()); + verify(tokenProvider, times(2)).getToken(); + verify(tokenProvider, never()).getSelfToken(any()); + } + + private long scheduledDelay(Instant now, OffsetDateTime expirationTime) throws IOException { + ScheduledExecutorService executor = Mockito.mock(ScheduledExecutorService.class); + ConsulTokenProvider login = Mockito.mock(ConsulTokenProvider.class); + when(login.getToken()).thenReturn(new Token("test-token", expirationTime)); + + new TokenUpdater(login, executor, Clock.fixed(now, ZoneId.of("UTC")), 2, Duration.ZERO).watch(unused -> { + }, ""); + + ArgumentCaptor delay = ArgumentCaptor.forClass(Long.class); + verify(executor).schedule(any(Runnable.class), delay.capture(), 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 retryWaitsBackoffDelayBetweenAttempts() throws IOException { + Duration retryPause = Duration.ofMillis(200); + OffsetDateTime secretExpirationTime = OffsetDateTime.ofInstant(currentTime, ZoneId.of("UTC")).plusMinutes(30); + when(tokenProvider.getToken()) + .thenThrow(new IOException()) + .thenReturn(new Token("test-token", secretExpirationTime)); + + TokenUpdater updater = new TokenUpdater(tokenProvider, scheduledExecutorService, + Clock.fixed(currentTime, ZoneId.of("UTC")), 2, retryPause); + + long startedAt = System.nanoTime(); + updater.watch(unused -> { + }, ""); + long elapsed = System.nanoTime() - startedAt; + + long lowestJitteredDelay = (long) (retryPause.toNanos() * (1 - LoginRetryPolicies.JITTER)); + verify(tokenProvider, times(2)).getToken(); + Assertions.assertTrue(elapsed >= lowestJitteredDelay, + "expected a backoff delay of at least " + lowestJitteredDelay + " ns between retries, got " + elapsed); + } + + 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(); + } + return null; + }); + } + + 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); + 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 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(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(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 + 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)); + } + + @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)); + } } 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..f7815fe17b --- /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 getAuthMethod() { + return authMethod; + } + + @Override + public String getBearerToken() { + 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..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 @@ -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 getAuthMethod() { + return authMethod; + } + + @Override + public String getBearerToken() { + 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..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 @@ -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 getAuthMethod() { + return authMethod; + } + + @Override + public String getBearerToken() { + 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/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..87593b9ad7 --- /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,68 @@ +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; + +/** + * 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; + private Duration fallbackRecheckInterval; + + 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; + } + + public Duration getFallbackRecheckInterval() { + return fallbackRecheckInterval; + } + + 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/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..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 @@ -1,7 +1,8 @@ package com.netcracker.cloud.consul.provider.spring.common.config; import org.apache.commons.logging.Log; -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; @@ -9,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; @@ -39,21 +41,41 @@ 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. + * + *

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) { 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); - 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()); - } catch (IOException e) { - log.error("can not get consul token by m2m: ", e); + ConsulLoginProperties login = binder.bind(ConsulLoginProperties.PREFIX, ConsulLoginProperties.class) + .orElseGet(ConsulLoginProperties::new); + Supplier m2mTokenSupplier = () -> + resolverContext.getBootstrapContext().get(M2MManager.class).getToken().getTokenValue(); + 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 (Exception e) { + log.error("can not get consul token: ", e); } registerAndPromoteBean(resolverContext, ConsulProperties.class, BootstrapRegistry.InstanceSupplier.of(properties)); return consulConfigProperties; 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-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..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 @@ -4,6 +4,31 @@ 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.BindException; +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.args; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -38,4 +63,174 @@ 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 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(); + + 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.getAuthMethod())) { + throw new IOException("consul auth method is not ready: response code=403; body='ACL not found'"); + } + if (clientReadsBearerToken) { + credentials.getBearerToken(); + } + 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 fallbackModeLeavesTheRegistryAloneWhileTheNewWayWorks() { + properties.put(PROP_LOGIN_MODE, "kubernetes-with-m2m-fallback"); + properties.put(PROP_LOGIN_AUTH_METHOD, "core-k8s"); + + ConsulConfigProperties resolved = resolve(); + + Assertions.assertEquals(SECRET_ID, resolved.getAclToken()); + Assertions.assertEquals(0, m2mLookups.get()); + } + + @Test + void fallbackModeAsksTheRegistryOnlyWhenBearerTokenIsNeeded() { + properties.put(PROP_LOGIN_MODE, "kubernetes-with-m2m-fallback"); + 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)); + } + + @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 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"); + + 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 1404674acc..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 @@ -3,6 +3,7 @@ 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.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; @@ -15,10 +16,12 @@ 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; @Configuration +@EnableConfigurationProperties(ConsulLoginProperties.class) @ConditionalOnConsulEnabled //@EnableM2MManager // TODO why it is commented out? @ConditionalOnProperty(value = "spring.cloud.consul.config.m2m.enabled", havingValue = "true", matchIfMissing = true) @@ -28,14 +31,22 @@ public class ConsulM2MRestTemplateAutoConfiguration { @Bean public TokenStorage consulTokenStorageViaM2MRestTemplate(ConsulConfigProperties consulConfigProperties, ConsulProperties consulProperties, - M2MManager m2MManager) { + M2MManager m2MManager, + ConsulLoginProperties loginProperties) { TokenStorageFactory factory = new SpringTokenStorageFactory(consulConfigProperties, new MicroserviceRestTemplate()); - return factory.create(new TokenStorageFactory.CreateOptions.Builder() + return factory.create(createOptions(loginProperties, consulProperties, m2MManager, System.getenv("NAMESPACE"))); + } + + static TokenStorageFactory.CreateOptions createOptions(ConsulLoginProperties loginProperties, + ConsulProperties consulProperties, + M2MManager m2MManager, + String namespace) { + return loginProperties.toOptionsBuilder() .consulUrl(Utils.formatConsulAddress(consulProperties)) - .namespace(System.getenv("NAMESPACE")) + .namespace(namespace) .m2mSupplier(() -> m2MManager.getToken().getTokenValue()) - .build()); + .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..de3e38fdea --- /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,57 @@ +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.consul.provider.spring.common.config.ConsulLoginProperties; +import com.netcracker.cloud.security.core.auth.M2MManager; +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; + +/** + * 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(); + private ConsulProperties consulProperties; + private M2MManager m2MManager; + + @BeforeEach + void init() { + consulProperties = new ConsulProperties(); + consulProperties.setHost("consul"); + consulProperties.setPort(8500); + + m2MManager = Mockito.mock(M2MManager.class); + } + + private TokenStorageFactory.CreateOptions options() { + return ConsulM2MRestTemplateAutoConfiguration.createOptions(loginProperties, consulProperties, m2MManager, "ns"); + } + + @Test + void loginPropertiesReachTheOptions() { + loginProperties.setMode(ConsulLoginMode.KUBERNETES); + loginProperties.setAuthMethod("core-k8s"); + + TokenStorageFactory.CreateOptions opts = options(); + + Assertions.assertEquals(ConsulLoginMode.KUBERNETES, opts.getMode()); + Assertions.assertEquals("core-k8s", opts.getAuthMethod()); + } + + @Test + void buildingTheOptionsAsksNoM2MTokenInAnyMode() { + for (ConsulLoginMode mode : ConsulLoginMode.values()) { + loginProperties.setMode(mode); + + Assertions.assertEquals(mode, options().getMode()); + } + + Mockito.verifyNoInteractions(m2MManager); + } +} 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..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 @@ -3,6 +3,7 @@ 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.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; @@ -13,9 +14,11 @@ 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; @Configuration +@EnableConfigurationProperties(ConsulLoginProperties.class) @ConditionalOnConsulEnabled //@EnableReactiveM2MManager @ConditionalOnProperty(value = "spring.cloud.consul.config.m2m.enabled", havingValue = "true", matchIfMissing = true) @@ -24,14 +27,22 @@ public class ConsulM2MWebClientAutoConfiguration { @Bean public TokenStorage consulTokenStorageViaM2MWebClient(ConsulConfigProperties consulConfigProperties, ConsulProperties consulProperties, - M2MManager m2MManager) { + M2MManager m2MManager, + ConsulLoginProperties loginProperties) { TokenStorageFactory factory = new SpringTokenStorageFactory(consulConfigProperties, new MicroserviceWebClient()); - return factory.create(new TokenStorageFactory.CreateOptions.Builder() + return factory.create(createOptions(loginProperties, consulProperties, m2MManager, System.getenv("NAMESPACE"))); + } + + static TokenStorageFactory.CreateOptions createOptions(ConsulLoginProperties loginProperties, + ConsulProperties consulProperties, + M2MManager m2MManager, + String namespace) { + return loginProperties.toOptionsBuilder() .consulUrl(Utils.formatConsulAddress(consulProperties)) - .namespace(System.getenv("NAMESPACE")) + .namespace(namespace) .m2mSupplier(() -> m2MManager.getToken().block().getTokenValue()) - .build()); + .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..60a512242e --- /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,57 @@ +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.consul.provider.spring.common.config.ConsulLoginProperties; +import com.netcracker.cloud.security.common.reactive.M2MManager; +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; + +/** + * 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(); + private ConsulProperties consulProperties; + private M2MManager m2MManager; + + @BeforeEach + void init() { + consulProperties = new ConsulProperties(); + consulProperties.setHost("consul"); + consulProperties.setPort(8500); + + m2MManager = Mockito.mock(M2MManager.class); + } + + private TokenStorageFactory.CreateOptions options() { + return ConsulM2MWebClientAutoConfiguration.createOptions(loginProperties, consulProperties, m2MManager, "ns"); + } + + @Test + void loginPropertiesReachTheOptions() { + loginProperties.setMode(ConsulLoginMode.KUBERNETES); + loginProperties.setAuthMethod("core-k8s"); + + TokenStorageFactory.CreateOptions opts = options(); + + Assertions.assertEquals(ConsulLoginMode.KUBERNETES, opts.getMode()); + Assertions.assertEquals("core-k8s", opts.getAuthMethod()); + } + + @Test + void buildingTheOptionsAsksNoM2MTokenInAnyMode() { + for (ConsulLoginMode mode : ConsulLoginMode.values()) { + loginProperties.setMode(mode); + + Assertions.assertEquals(mode, options().getMode()); + } + + Mockito.verifyNoInteractions(m2MManager); + } +}