diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultSysTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultSysTemplate.java index bafb28fd..839a33b8 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultSysTemplate.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/ReactiveVaultSysTemplate.java @@ -22,13 +22,16 @@ import org.springframework.http.HttpEntity; import org.springframework.util.Assert; +import org.springframework.vault.client.VaultClientResponseException; import org.springframework.vault.client.VaultHttpHeaders; +import org.springframework.vault.support.JacksonCompat; import org.springframework.vault.support.VaultHealth; /** * Default implementation of {@link ReactiveVaultSysOperations}. * * @author Mark Paluch + * @author Henk Hofs */ public class ReactiveVaultSysTemplate implements ReactiveVaultSysOperations { @@ -70,8 +73,24 @@ public Mono health() { .retrieve() .toEntity(VaultSysTemplate.VaultHealthImpl.class) .filter(HttpEntity::hasBody) - .map(HttpEntity::getBody); + .map(HttpEntity::getBody) + .onErrorResume(VaultClientResponseException.class, + ReactiveVaultSysTemplate::deserializeHealthResponse) + .cast(VaultHealth.class); }); } + private static Mono deserializeHealthResponse( + VaultClientResponseException responseError) { + + try { + return Mono.just(JacksonCompat.instance() + .getObjectMapperAccessor() + .deserialize(responseError.getResponseBodyAsString(), VaultSysTemplate.VaultHealthImpl.class)); + } + catch (Exception jsonError) { + return Mono.error(responseError); + } + } + } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysTemplate.java index 58083c56..7c961369 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysTemplate.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysTemplate.java @@ -47,6 +47,7 @@ * Default implementation of {@link VaultSysOperations}. * * @author Mark Paluch + * @author Henk Hofs */ public class VaultSysTemplate implements VaultSysOperations { @@ -454,6 +455,8 @@ static class VaultHealthImpl implements VaultHealth { private final boolean replicationRecoverySecondary; + private final @Nullable Boolean haConnectionHealthy; + private final int serverTimeUtc; private final @Nullable String version; @@ -463,6 +466,7 @@ static class VaultHealthImpl implements VaultHealth { @JsonProperty("standby") boolean standby, @JsonProperty("performance_standby") boolean performanceStandby, @Nullable @JsonProperty("replication_dr_mode") String replicationRecoverySecondary, + @Nullable @JsonProperty("ha_connection_healthy") Boolean haConnectionHealthy, @JsonProperty("server_time_utc") int serverTimeUtc, @Nullable @JsonProperty("version") String version) { this.initialized = initialized; @@ -471,6 +475,7 @@ static class VaultHealthImpl implements VaultHealth { this.performanceStandby = performanceStandby; this.replicationRecoverySecondary = replicationRecoverySecondary != null && !"disabled".equalsIgnoreCase(replicationRecoverySecondary); + this.haConnectionHealthy = haConnectionHealthy; this.serverTimeUtc = serverTimeUtc; this.version = version; } @@ -496,6 +501,11 @@ public boolean isRecoveryReplicationSecondary() { return this.replicationRecoverySecondary; } + @Override + public @Nullable Boolean getHaConnectionHealthy() { + return this.haConnectionHealthy; + } + public int getServerTimeUtc() { return this.serverTimeUtc; } @@ -513,13 +523,15 @@ public boolean equals(Object o) { return this.initialized == that.initialized && this.sealed == that.sealed && this.standby == that.standby && this.performanceStandby == that.performanceStandby && this.replicationRecoverySecondary == that.replicationRecoverySecondary - && this.serverTimeUtc == that.serverTimeUtc && Objects.equals(this.version, that.version); + && this.serverTimeUtc == that.serverTimeUtc + && Objects.equals(this.haConnectionHealthy, that.haConnectionHealthy) + && Objects.equals(this.version, that.version); } @Override public int hashCode() { return Objects.hash(this.initialized, this.sealed, this.standby, this.performanceStandby, - this.replicationRecoverySecondary, this.serverTimeUtc, this.version); + this.replicationRecoverySecondary, this.haConnectionHealthy, this.serverTimeUtc, this.version); } } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/VaultHealth.java b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultHealth.java index 82258791..b977cc2d 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/support/VaultHealth.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultHealth.java @@ -24,6 +24,7 @@ * @author Stuart Ingram * @author Bill Koch * @author Mark Paluch + * @author Henk Hofs */ public interface VaultHealth { @@ -59,6 +60,17 @@ public interface VaultHealth { */ boolean isRecoveryReplicationSecondary(); + /** + * @return {@literal true} if the standby node is connected to the active node, + * {@literal false} if not, or {@literal null} if Vault did not report the HA + * connection state. + * @since 4.2 + */ + @Nullable + default Boolean getHaConnectionHealthy() { + return null; + } + /** * @return the server time in seconds, UTC. */ diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultSysTemplateUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultSysTemplateUnitTests.java new file mode 100644 index 00000000..24476fb2 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/ReactiveVaultSysTemplateUnitTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.vault.core; + +import java.io.IOException; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.vault.client.ReactiveVaultClient; +import org.springframework.vault.client.VaultEndpoint; + +import static org.assertj.core.api.Assertions.*; + +/** + * Unit tests for {@link ReactiveVaultSysTemplate}. + * + * @author Henk Hofs + */ +class ReactiveVaultSysTemplateUnitTests { + + private static final MockWebServer mockWebServer = new MockWebServer(); + + static VaultEndpoint localhost = new VaultEndpoint(); + + @BeforeAll + static void beforeAll() throws IOException { + mockWebServer.start(); + localhost.setHost("localhost"); + localhost.setPort(mockWebServer.getPort()); + localhost.setScheme("http"); + } + + @AfterAll + static void afterAll() throws IOException { + mockWebServer.shutdown(); + } + + @Test + void shouldDeserializeHealthErrorResponse() throws Exception { + + mockWebServer.enqueue(new MockResponse().setResponseCode(429) + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .setBody(""" + { + "initialized": true, + "sealed": false, + "standby": true, + "performance_standby": false, + "ha_connection_healthy": true, + "server_time_utc": 123, + "version": "1.20.0" + } + """)); + + ReactiveVaultSysOperations sysOperations = new ReactiveVaultSysTemplate( + new ReactiveVaultTemplate(ReactiveVaultClient.create(localhost))); + + sysOperations.health().as(StepVerifier::create).assertNext(health -> { + assertThat(health.isInitialized()).isTrue(); + assertThat(health.isSealed()).isFalse(); + assertThat(health.isStandby()).isTrue(); + assertThat(health.isPerformanceStandby()).isFalse(); + assertThat(health.getHaConnectionHealthy()).isTrue(); + assertThat(health.getServerTimeUtc()).isEqualTo(123); + assertThat(health.getVersion()).isEqualTo("1.20.0"); + }).verifyComplete(); + + RecordedRequest recordedRequest = mockWebServer.takeRequest(); + assertThat(recordedRequest.getMethod()).isEqualTo(HttpMethod.GET.name()); + assertThat(recordedRequest.getPath()).isEqualTo("/v1/sys/health"); + } + +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/VaultSysTemplateUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultSysTemplateUnitTests.java index 7c30d6a1..946b870f 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/core/VaultSysTemplateUnitTests.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultSysTemplateUnitTests.java @@ -31,14 +31,23 @@ class VaultSysTemplateUnitTests { void shouldReportRecoveryReplicationMode() { VaultSysTemplate.VaultHealthImpl disabled = new VaultSysTemplate.VaultHealthImpl(true, true, true, true, - "disabled", 0, null); + "disabled", null, 0, null); assertThat(disabled.isRecoveryReplicationSecondary()).isFalse(); VaultSysTemplate.VaultHealthImpl enabled = new VaultSysTemplate.VaultHealthImpl(true, true, true, true, - "enabled", 0, null); + "enabled", null, 0, null); assertThat(enabled.isRecoveryReplicationSecondary()).isTrue(); } + @Test + void shouldReportHaConnectionHealth() { + + VaultSysTemplate.VaultHealthImpl health = new VaultSysTemplate.VaultHealthImpl(true, false, true, false, + "disabled", true, 0, null); + + assertThat(health.getHaConnectionHealthy()).isTrue(); + } + }