summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorHÃ¥kon Hallingstad <hakon@oath.com>2018-04-17 08:45:55 +0200
committerGitHub <noreply@github.com>2018-04-17 08:45:55 +0200
commita2d5b8283794c8872376c754ef0a1a9fc0a978de (patch)
tree9326f991e2b1564b731d65239a45fbf357dbbc48
parente05a40ea22ac94d63eeb054dbc20b47cbc3fce9c (diff)
parent791b893af409eec918521ce72c56d8a22a1be6be (diff)
Merge pull request #5582 from vespa-engine/hakonhall/add-statusv1health-client
Add /status/v1/health client
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/component/ConfigServerInfo.java30
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApi.java11
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApiImpl.java84
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerClients.java10
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/RealConfigServerClients.java57
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConfigServerApiImpl.java114
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConnectionSocketFactoryCreator.java45
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConnectionSocketFactoryUpdater.java117
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/certificate/ConfigServerKeyStoreRefresherFactory.java17
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/HealthCode.java32
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/State.java12
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/StateImpl.java41
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/bindings/HealthResponse.java36
-rw-r--r--node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApiImplTest.java2
-rw-r--r--node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConnectionSocketFactoryUpdaterTest.java58
-rw-r--r--node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/noderepository/RealNodeRepositoryTest.java8
-rw-r--r--node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/state/HealthResponseTest.java54
-rw-r--r--node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/state/StateImplTest.java37
18 files changed, 613 insertions, 152 deletions
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/component/ConfigServerInfo.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/component/ConfigServerInfo.java
index 479a46a38fe..218b2947a21 100644
--- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/component/ConfigServerInfo.java
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/component/ConfigServerInfo.java
@@ -9,9 +9,13 @@ import com.yahoo.vespa.hosted.node.admin.util.KeyStoreOptions;
import java.net.URI;
import java.nio.file.Paths;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
-import java.util.stream.Collectors;
+import java.util.function.Function;
+
+import static java.util.stream.Collectors.toMap;
/**
* Information necessary to e.g. establish communication with the config servers
@@ -20,7 +24,7 @@ import java.util.stream.Collectors;
*/
public class ConfigServerInfo {
private final List<String> configServerHostNames;
- private final List<URI> configServerURIs;
+ private final Map<String, URI> configServerURIs;
private final Optional<KeyStoreOptions> keyStoreOptions;
private final Optional<KeyStoreOptions> trustStoreOptions;
private final Optional<AthenzIdentity> athenzIdentity;
@@ -49,7 +53,16 @@ public class ConfigServerInfo {
}
public List<URI> getConfigServerUris() {
- return configServerURIs;
+ return new ArrayList<>(configServerURIs.values());
+ }
+
+ public URI getConfigServerUri(String hostname) {
+ URI uri = configServerURIs.get(hostname);
+ if (uri == null) {
+ throw new IllegalArgumentException("There is no config server '" + hostname + "'");
+ }
+
+ return uri;
}
public Optional<KeyStoreOptions> getKeyStoreOptions() {
@@ -64,10 +77,13 @@ public class ConfigServerInfo {
return athenzIdentity;
}
- private static List<URI> createConfigServerUris(String scheme, List<String> configServerHosts, int port) {
- return configServerHosts.stream()
- .map(hostname -> URI.create(scheme + "://" + hostname + ":" + port))
- .collect(Collectors.toList());
+ private static Map<String, URI> createConfigServerUris(
+ String scheme,
+ List<String> configServerHosts,
+ int port) {
+ return configServerHosts.stream().collect(toMap(
+ Function.identity(),
+ hostname -> URI.create(scheme + "://" + hostname + ":" + port)));
}
private static Optional<KeyStoreOptions> createKeyStoreOptions(String pathToKeyStore, char[] password, String type) {
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApi.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApi.java
index 4d4a6c0328d..9f6684ba84a 100644
--- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApi.java
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApi.java
@@ -1,10 +1,12 @@
// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.hosted.node.admin.configserver;
+import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+
import java.util.Optional;
/**
- * Interface to execute basic HTTP request against config server(s)
+ * Interface to execute basic HTTP/HTTPS request against config server(s)
*
* @author freva
*/
@@ -20,9 +22,10 @@ public interface ConfigServerApi extends AutoCloseable {
<T> T delete(String path, Class<T> wantedReturnType);
- /**
- * Close the underlying HTTP client and any threads this class might have started.
- */
+ /** Set or update the socket factory */
+ void setSSLConnectionSocketFactory(SSLConnectionSocketFactory sslSocketFactory);
+
+ /** Close the underlying HTTP client and any threads this class might have started. */
@Override
void close();
}
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApiImpl.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApiImpl.java
index c5592e91973..fd34fede291 100644
--- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApiImpl.java
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApiImpl.java
@@ -3,6 +3,8 @@ package com.yahoo.vespa.hosted.node.admin.configserver;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yahoo.config.provision.HostName;
+import com.yahoo.vespa.hosted.node.admin.component.ConfigServerInfo;
import com.yahoo.vespa.hosted.node.admin.util.PrefixLogger;
import org.apache.http.HttpHeaders;
import org.apache.http.client.methods.CloseableHttpResponse;
@@ -38,6 +40,10 @@ public class ConfigServerApiImpl implements ConfigServerApi {
private final List<URI> configServerHosts;
+ // If this instance is associated with asynchronous updating, this field is set
+ // to unregister from the updater at close()
+ private Optional<SslConnectionSocketFactoryUpdater> socketFactoryUpdater = Optional.empty();
+
/**
* The 'client' will be periodically re-created by clientRefresherScheduler if we provide keyStoreOptions
* or trustStoreOptions. This is needed because the key/trust stores are updated outside of node-admin,
@@ -48,15 +54,66 @@ public class ConfigServerApiImpl implements ConfigServerApi {
*/
private volatile SelfCloseableHttpClient client;
- public ConfigServerApiImpl(Collection<URI> configServerUris) {
- this(configServerUris, SSLConnectionSocketFactory.getSocketFactory());
+ /**
+ * Creates an api for talking to the config servers.
+ *
+ * <p>Registers with a SslConnectionSocketFactoryUpdater to keep the socket factory
+ * up to date as e.g. any client certificate expires (and unregisters at {@link #close()})
+ */
+ public static ConfigServerApiImpl create(ConfigServerInfo configServerInfo,
+ SslConnectionSocketFactoryUpdater updater) {
+ return createFor(updater, configServerInfo.getConfigServerUris());
+ }
+
+ /**
+ * Creates an api for talking to a single config server.
+ *
+ * <p>Registers with a SslConnectionSocketFactoryUpdater to keep the socket factory
+ * up to date as e.g. any client certificate expires (and unregisters at {@link #close()})
+ */
+ public static ConfigServerApiImpl createFor(ConfigServerInfo configServerInfo,
+ SslConnectionSocketFactoryUpdater updater,
+ HostName configServer) {
+ URI uri = configServerInfo.getConfigServerUri(configServer.value());
+ return createFor(updater, Collections.singletonList(uri));
+ }
+
+ /**
+ * Creates an api for talking to the config servers with a fixed socket factory.
+ *
+ * <p>This may be used to avoid requiring background certificate signing requests (CSR)
+ * against the config server when client validation is enabled in the config server.
+ */
+ public static ConfigServerApiImpl createWithSocketFactory(
+ List<URI> configServerHosts,
+ SSLConnectionSocketFactory socketFactory) {
+ return new ConfigServerApiImpl(configServerHosts, socketFactory);
+ }
+
+ static ConfigServerApiImpl createForTestingWithClient(List<URI> configServerHosts,
+ SelfCloseableHttpClient client) {
+ return new ConfigServerApiImpl(configServerHosts, client);
+ }
+
+ private static ConfigServerApiImpl createFor(SslConnectionSocketFactoryUpdater updater,
+ List<URI> configServers) {
+ ConfigServerApiImpl api = new ConfigServerApiImpl(
+ configServers,
+ // Passing null here (only) works because startSocketFactoryUpdating will
+ // set the client. This avoids an unnecessary allocation of a client.
+ (SelfCloseableHttpClient) null);
+ api.startSocketFactoryUpdating(updater);
+ assert api.client != null;
+ return api;
}
- ConfigServerApiImpl(Collection<URI> configServerUris, SSLConnectionSocketFactory sslConnectionSocketFactory) {
- this(randomizeConfigServerUris(configServerUris), new SelfCloseableHttpClient(sslConnectionSocketFactory));
+ private ConfigServerApiImpl(Collection<URI> configServerUris,
+ SSLConnectionSocketFactory sslConnectionSocketFactory) {
+ this(randomizeConfigServerUris(configServerUris),
+ new SelfCloseableHttpClient(sslConnectionSocketFactory));
}
- ConfigServerApiImpl(List<URI> configServerHosts, SelfCloseableHttpClient client) {
+ private ConfigServerApiImpl(List<URI> configServerHosts, SelfCloseableHttpClient client) {
this.configServerHosts = configServerHosts;
this.client = client;
}
@@ -110,6 +167,7 @@ public class ConfigServerApiImpl implements ConfigServerApi {
+ configServerHosts + ") failed, last as follows:", lastException);
}
+ @Override
public <T> T put(String path, Optional<Object> bodyJsonPojo, Class<T> wantedReturnType) {
return tryAllConfigServers(configServer -> {
HttpPut put = new HttpPut(configServer.resolve(path));
@@ -121,6 +179,7 @@ public class ConfigServerApiImpl implements ConfigServerApi {
}, wantedReturnType);
}
+ @Override
public <T> T patch(String path, Object bodyJsonPojo, Class<T> wantedReturnType) {
return tryAllConfigServers(configServer -> {
HttpPatch patch = new HttpPatch(configServer.resolve(path));
@@ -130,16 +189,19 @@ public class ConfigServerApiImpl implements ConfigServerApi {
}, wantedReturnType);
}
+ @Override
public <T> T delete(String path, Class<T> wantedReturnType) {
return tryAllConfigServers(configServer ->
new HttpDelete(configServer.resolve(path)), wantedReturnType);
}
+ @Override
public <T> T get(String path, Class<T> wantedReturnType) {
return tryAllConfigServers(configServer ->
new HttpGet(configServer.resolve(path)), wantedReturnType);
}
+ @Override
public <T> T post(String path, Object bodyJsonPojo, Class<T> wantedReturnType) {
return tryAllConfigServers(configServer -> {
HttpPost post = new HttpPost(configServer.resolve(path));
@@ -153,6 +215,7 @@ public class ConfigServerApiImpl implements ConfigServerApi {
request.setHeader(HttpHeaders.CONTENT_TYPE, "application/json");
}
+ @Override
public void setSSLConnectionSocketFactory(SSLConnectionSocketFactory sslSocketFactory) {
this.client = new SelfCloseableHttpClient(sslSocketFactory);
}
@@ -164,8 +227,19 @@ public class ConfigServerApiImpl implements ConfigServerApi {
return shuffledConfigServerHosts;
}
+ private void startSocketFactoryUpdating(SslConnectionSocketFactoryUpdater updater) {
+ updater.registerConfigServerApi(this);
+ this.socketFactoryUpdater = Optional.of(updater);
+ }
+
+ private void stopSocketFactoryUpdating() {
+ this.socketFactoryUpdater.ifPresent(updater -> updater.unregisterConfigServerApi(this));
+ this.socketFactoryUpdater = Optional.empty();
+ }
+
@Override
public void close() {
+ stopSocketFactoryUpdating();
client.close();
}
}
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerClients.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerClients.java
index f52487c306f..7c15f94852b 100644
--- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerClients.java
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerClients.java
@@ -1,15 +1,25 @@
// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.hosted.node.admin.configserver;
+import com.yahoo.config.provision.HostName;
import com.yahoo.vespa.hosted.node.admin.configserver.noderepository.NodeRepository;
import com.yahoo.vespa.hosted.node.admin.configserver.orchestrator.Orchestrator;
+import com.yahoo.vespa.hosted.node.admin.configserver.state.State;
/**
+ * The available (and implemented) APIs of the config server
+ *
* @author freva
*/
public interface ConfigServerClients {
+ /** Get handle to /nodes/v2/ REST API */
NodeRepository nodeRepository();
+
+ /** Get handle to /orchestrator/v1/ REST API */
Orchestrator orchestrator();
+ /** Get handle to the /state/v1 REST API of the specified config server */
+ default State state(HostName hostname) { throw new java.lang.UnsupportedOperationException(); }
+
void stop();
}
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/RealConfigServerClients.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/RealConfigServerClients.java
index 34b081689e2..1405a518625 100644
--- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/RealConfigServerClients.java
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/RealConfigServerClients.java
@@ -1,45 +1,52 @@
// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.hosted.node.admin.configserver;
+import com.yahoo.config.provision.HostName;
import com.yahoo.vespa.hosted.node.admin.component.ConfigServerInfo;
import com.yahoo.vespa.hosted.node.admin.component.Environment;
import com.yahoo.vespa.hosted.node.admin.configserver.noderepository.NodeRepository;
import com.yahoo.vespa.hosted.node.admin.configserver.noderepository.RealNodeRepository;
import com.yahoo.vespa.hosted.node.admin.configserver.orchestrator.Orchestrator;
import com.yahoo.vespa.hosted.node.admin.configserver.orchestrator.OrchestratorImpl;
+import com.yahoo.vespa.hosted.node.admin.configserver.state.State;
+import com.yahoo.vespa.hosted.node.admin.configserver.state.StateImpl;
-import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
/**
* @author freva
*/
public class RealConfigServerClients implements ConfigServerClients {
- private final Optional<ConfigServerApi> configServerApi;
+ private final SslConnectionSocketFactoryUpdater updater;
+
+ // ConfigServerApi that talks to all config servers
+ private final ConfigServerApi configServerApi;
+
private final NodeRepository nodeRepository;
private final Orchestrator orchestrator;
+ private final ConcurrentHashMap<HostName, State> states = new ConcurrentHashMap<>();
+ private final ConfigServerInfo configServerInfo;
public RealConfigServerClients(Environment environment) {
- this(new SslConfigServerApiImpl(environment));
+ this(environment.getConfigServerInfo(), environment.getParentHostHostname());
}
+ /**
+ * Create config server clients against a real (remote) config server.
+ *
+ * If a client certificate is required, one will be requested from the config server
+ * and kept up to date. On failure, this constructor will throw an exception and
+ * the caller may retry later.
+ */
public RealConfigServerClients(ConfigServerInfo info, String hostname) {
- this(new SslConfigServerApiImpl(info, hostname));
- }
+ this.configServerInfo = info;
+ updater = SslConnectionSocketFactoryUpdater.createAndRefreshKeyStoreIfNeeded(info, hostname);
- public RealConfigServerClients(NodeRepository nodeRepository, Orchestrator orchestrator) {
- this(nodeRepository, orchestrator, Optional.empty());
- }
-
- private RealConfigServerClients(ConfigServerApi configServerApi) {
- this(new RealNodeRepository(configServerApi), new OrchestratorImpl(configServerApi), Optional.of(configServerApi));
- }
+ configServerApi = ConfigServerApiImpl.create(info, updater);
- private RealConfigServerClients(NodeRepository nodeRepository, Orchestrator orchestrator,
- Optional<ConfigServerApi> configServerApi) {
- this.nodeRepository = nodeRepository;
- this.orchestrator = orchestrator;
- this.configServerApi = configServerApi;
+ nodeRepository = new RealNodeRepository(configServerApi);
+ orchestrator = new OrchestratorImpl(configServerApi);
}
@Override
@@ -53,7 +60,21 @@ public class RealConfigServerClients implements ConfigServerClients {
}
@Override
+ public State state(HostName hostname) {
+ return states.computeIfAbsent(hostname, this::createState);
+ }
+
+ @Override
public void stop() {
- configServerApi.ifPresent(ConfigServerApi::close);
+ updater.unregisterConfigServerApi(configServerApi);
+ configServerApi.close();
+ updater.close();
+ }
+
+ private State createState(HostName hostname) {
+ ConfigServerApi configServerApi = ConfigServerApiImpl.createFor(
+ configServerInfo, updater, hostname);
+
+ return new StateImpl(configServerApi);
}
}
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConfigServerApiImpl.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConfigServerApiImpl.java
deleted file mode 100644
index ee38541c11c..00000000000
--- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConfigServerApiImpl.java
+++ /dev/null
@@ -1,114 +0,0 @@
-// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.node.admin.configserver;
-
-import com.yahoo.vespa.athenz.tls.AthenzIdentityVerifier;
-import com.yahoo.vespa.athenz.tls.AthenzSslContextBuilder;
-import com.yahoo.vespa.hosted.node.admin.component.ConfigServerInfo;
-import com.yahoo.vespa.hosted.node.admin.component.Environment;
-import com.yahoo.vespa.hosted.node.admin.configserver.certificate.ConfigServerKeyStoreRefresher;
-import com.yahoo.vespa.hosted.node.admin.util.KeyStoreOptions;
-import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
-
-import javax.net.ssl.HostnameVerifier;
-import javax.net.ssl.SSLContext;
-import java.util.Collections;
-import java.util.Optional;
-
-/**
- * ConfigServerApi with proper keystore, truststore and hostname verifier to communicate with the
- * config server(s). The keystore is refreshed automatically.
- *
- * @author freva
- */
-public class SslConfigServerApiImpl implements ConfigServerApi {
-
- private final ConfigServerApiImpl configServerApi;
- private final Optional<ConfigServerKeyStoreRefresher> keyStoreRefresher;
- private final ConfigServerInfo configServerInfo;
-
- public SslConfigServerApiImpl(ConfigServerInfo configServerInfo, String hostname) {
- this.configServerInfo = configServerInfo;
-
- // At this point we don't know the state of the keystore, it may not exist at all, or the keystore
- // maybe exists, but the certificate in it is expired. Create the ConfigServerApi without a keystore
- // (but with truststore and hostname verifier).
- this.configServerApi = new ConfigServerApiImpl(
- configServerInfo.getConfigServerUris(), makeSslConnectionSocketFactory(Optional.empty()));
-
- // If we have keystore options, we should make sure we use the keystore with the latest certificate,
- // start the keystore refresher.
- this.keyStoreRefresher = configServerInfo.getKeyStoreOptions().map(keyStoreOptions -> {
- // Any callback from KeyStoreRefresher should result in using the latest keystore on disk
- Runnable connectionFactoryRefresher = () -> configServerApi.setSSLConnectionSocketFactory(
- makeSslConnectionSocketFactory(Optional.of(keyStoreOptions)));
-
- ConfigServerKeyStoreRefresher keyStoreRefresher = new ConfigServerKeyStoreRefresher(
- keyStoreOptions, connectionFactoryRefresher, configServerApi, hostname);
-
- // Run the refresh once manually to make sure that we have a valid certificate, otherwise fail.
- try {
- keyStoreRefresher.refreshKeyStoreIfNeeded();
- connectionFactoryRefresher.run(); // Update connectionFactory with the keystore on disk
- } catch (Exception e) {
- throw new RuntimeException("Failed to acquire certificate to config server", e);
- }
-
- keyStoreRefresher.start();
- return keyStoreRefresher;
- });
- }
-
- public SslConfigServerApiImpl(Environment environment) {
- this(environment.getConfigServerInfo(), environment.getParentHostHostname());
-
- }
-
- @Override
- public <T> T get(String path, Class<T> wantedReturnType) {
- return configServerApi.get(path, wantedReturnType);
- }
-
- @Override
- public <T> T post(String path, Object bodyJsonPojo, Class<T> wantedReturnType) {
- return configServerApi.post(path, bodyJsonPojo, wantedReturnType);
- }
-
- @Override
- public <T> T put(String path, Optional<Object> bodyJsonPojo, Class<T> wantedReturnType) {
- return configServerApi.put(path, bodyJsonPojo, wantedReturnType);
- }
-
- @Override
- public <T> T patch(String path, Object bodyJsonPojo, Class<T> wantedReturnType) {
- return configServerApi.patch(path, bodyJsonPojo, wantedReturnType);
- }
-
- @Override
- public <T> T delete(String path, Class<T> wantedReturnType) {
- return configServerApi.delete(path, wantedReturnType);
- }
-
- @Override
- public void close() {
- keyStoreRefresher.ifPresent(ConfigServerKeyStoreRefresher::stop);
- configServerApi.close();
- }
-
- private SSLConnectionSocketFactory makeSslConnectionSocketFactory(Optional<KeyStoreOptions> keyStoreOptions) {
- return new SSLConnectionSocketFactory(makeSslContext(keyStoreOptions), makeHostnameVerifier());
- }
-
- private SSLContext makeSslContext(Optional<KeyStoreOptions> keyStoreOptions) {
- AthenzSslContextBuilder sslContextBuilder = new AthenzSslContextBuilder();
- configServerInfo.getTrustStoreOptions().map(KeyStoreOptions::loadKeyStore).ifPresent(sslContextBuilder::withTrustStore);
- keyStoreOptions.ifPresent(options -> sslContextBuilder.withKeyStore(options.loadKeyStore(), options.password));
-
- return sslContextBuilder.build();
- }
-
- private HostnameVerifier makeHostnameVerifier() {
- return configServerInfo.getAthenzIdentity()
- .map(identity -> (HostnameVerifier) new AthenzIdentityVerifier(Collections.singleton(identity)))
- .orElseGet(SSLConnectionSocketFactory::getDefaultHostnameVerifier);
- }
-}
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConnectionSocketFactoryCreator.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConnectionSocketFactoryCreator.java
new file mode 100644
index 00000000000..6f6bf34ae30
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConnectionSocketFactoryCreator.java
@@ -0,0 +1,45 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.node.admin.configserver;
+
+import com.yahoo.vespa.athenz.tls.AthenzIdentityVerifier;
+import com.yahoo.vespa.athenz.tls.AthenzSslContextBuilder;
+import com.yahoo.vespa.hosted.node.admin.component.ConfigServerInfo;
+import com.yahoo.vespa.hosted.node.admin.util.KeyStoreOptions;
+import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+import java.util.Collections;
+import java.util.Optional;
+
+/**
+ * @author hakon
+ */
+class SslConnectionSocketFactoryCreator {
+ SSLConnectionSocketFactory createSocketFactory(
+ ConfigServerInfo configServerInfo,
+ Optional<KeyStoreOptions> keyStoreOptions) {
+ SSLContext context = makeSslContext(configServerInfo, keyStoreOptions);
+ return new SSLConnectionSocketFactory(context, makeHostnameVerifier(configServerInfo));
+ }
+
+ private static SSLContext makeSslContext(
+ ConfigServerInfo configServerInfo,
+ Optional<KeyStoreOptions> keyStoreOptions) {
+ AthenzSslContextBuilder sslContextBuilder = new AthenzSslContextBuilder();
+ configServerInfo.getTrustStoreOptions()
+ .map(KeyStoreOptions::loadKeyStore)
+ .ifPresent(sslContextBuilder::withTrustStore);
+ keyStoreOptions.ifPresent(options ->
+ sslContextBuilder.withKeyStore(options.loadKeyStore(), options.password));
+
+ return sslContextBuilder.build();
+ }
+
+ private static HostnameVerifier makeHostnameVerifier(ConfigServerInfo configServerInfo) {
+ return configServerInfo.getAthenzIdentity()
+ .map(identity -> (HostnameVerifier) new AthenzIdentityVerifier(Collections.singleton(identity)))
+ .orElseGet(SSLConnectionSocketFactory::getDefaultHostnameVerifier);
+ }
+
+}
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConnectionSocketFactoryUpdater.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConnectionSocketFactoryUpdater.java
new file mode 100644
index 00000000000..57fa5526d73
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConnectionSocketFactoryUpdater.java
@@ -0,0 +1,117 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.node.admin.configserver;
+
+import com.yahoo.vespa.hosted.node.admin.component.ConfigServerInfo;
+import com.yahoo.vespa.hosted.node.admin.configserver.certificate.ConfigServerKeyStoreRefresher;
+import com.yahoo.vespa.hosted.node.admin.configserver.certificate.ConfigServerKeyStoreRefresherFactory;
+import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Responsible for updating SSLConnectionSocketFactory on ConfigServerApiImpl asynchronously
+ * and as required by embedded certificate expiry
+ *
+ * @author hakon
+ */
+public class SslConnectionSocketFactoryUpdater implements AutoCloseable {
+ private final ConfigServerInfo configServerInfo;
+ private final SslConnectionSocketFactoryCreator socketFactoryCreator;
+ // Internal ConfigServerApi used to refresh the key store
+ private final ConfigServerApiImpl configServerApi;
+ private final Optional<ConfigServerKeyStoreRefresher> keyStoreRefresher;
+
+ private final Object monitor = new Object();
+ private SSLConnectionSocketFactory socketFactory = null;
+ private final Set<ConfigServerApi> configServerApis = new HashSet<>();
+
+ /**
+ * Creates an updater with valid initial {@link SSLConnectionSocketFactory}
+ *
+ * @param hostname the hostname of localhost
+ * @throws RuntimeException if e.g. key store options have been specified, but was unable
+ * create a create a key store with a valid certificate
+ */
+ public static SslConnectionSocketFactoryUpdater createAndRefreshKeyStoreIfNeeded(
+ ConfigServerInfo configServerInfo, String hostname) {
+ return new SslConnectionSocketFactoryUpdater(
+ configServerInfo,
+ hostname,
+ ConfigServerKeyStoreRefresher::new,
+ new SslConnectionSocketFactoryCreator());
+ }
+
+ /** Non-private for testing only */
+ SslConnectionSocketFactoryUpdater(
+ ConfigServerInfo configServerInfo,
+ String hostname,
+ ConfigServerKeyStoreRefresherFactory refresherFactory,
+ SslConnectionSocketFactoryCreator socketFactoryCreator) {
+ this.configServerInfo = configServerInfo;
+ this.socketFactoryCreator = socketFactoryCreator;
+
+ // ConfigServerApi used to refresh the key store. Does not itself rely on a socket
+ // factory with key store, of course.
+ SSLConnectionSocketFactory socketFactoryWithoutKeyStore =
+ socketFactoryCreator.createSocketFactory(configServerInfo, Optional.empty());
+ configServerApi = ConfigServerApiImpl.createWithSocketFactory(
+ configServerInfo.getConfigServerUris(), socketFactoryWithoutKeyStore);
+
+ // If we have keystore options, we should make sure we use the keystore with the latest certificate,
+ // start the keystore refresher.
+ keyStoreRefresher = configServerInfo.getKeyStoreOptions().map(keyStoreOptions -> {
+ ConfigServerKeyStoreRefresher keyStoreRefresher = refresherFactory.create(
+ keyStoreOptions,
+ this::updateSslConnectionSocketFactory,
+ configServerApi,
+ hostname);
+
+ // Run the refresh once manually to make sure that we have a valid certificate, otherwise fail.
+ try {
+ keyStoreRefresher.refreshKeyStoreIfNeeded();
+ updateSslConnectionSocketFactory();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to acquire certificate to config server", e);
+ }
+
+ keyStoreRefresher.start();
+ return keyStoreRefresher;
+ });
+ }
+
+ public SSLConnectionSocketFactory getCurrentSocketFactory() {
+ return socketFactory;
+ }
+
+ /** Register a {@link ConfigServerApi} whose SSLConnectionSocketFactory will be kept up to date */
+ public void registerConfigServerApi(ConfigServerApi configServerApi) {
+ synchronized (monitor) {
+ configServerApi.setSSLConnectionSocketFactory(socketFactory);
+ configServerApis.add(configServerApi);
+ }
+ }
+
+ public void unregisterConfigServerApi(ConfigServerApi configServerApi) {
+ synchronized (monitor) {
+ configServerApis.remove(configServerApi);
+ }
+ }
+
+ @Override
+ public void close() {
+ keyStoreRefresher.ifPresent(ConfigServerKeyStoreRefresher::stop);
+ configServerApi.close();
+ }
+
+ private void updateSslConnectionSocketFactory() {
+ synchronized (monitor) {
+ socketFactory = socketFactoryCreator.createSocketFactory(
+ configServerInfo,
+ configServerInfo.getKeyStoreOptions());
+
+ configServerApis.forEach(api -> api.setSSLConnectionSocketFactory(socketFactory));
+ }
+ }
+}
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/certificate/ConfigServerKeyStoreRefresherFactory.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/certificate/ConfigServerKeyStoreRefresherFactory.java
new file mode 100644
index 00000000000..d7685e737f5
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/certificate/ConfigServerKeyStoreRefresherFactory.java
@@ -0,0 +1,17 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.node.admin.configserver.certificate;
+
+import com.yahoo.vespa.hosted.node.admin.configserver.ConfigServerApi;
+import com.yahoo.vespa.hosted.node.admin.util.KeyStoreOptions;
+
+/**
+ * @author hakon
+ */
+@FunctionalInterface
+public interface ConfigServerKeyStoreRefresherFactory {
+ ConfigServerKeyStoreRefresher create(
+ KeyStoreOptions keyStoreOptions,
+ Runnable keyStoreUpdatedCallback,
+ ConfigServerApi configServerApi,
+ String hostname);
+}
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/HealthCode.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/HealthCode.java
new file mode 100644
index 00000000000..7ca7a1b30dd
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/HealthCode.java
@@ -0,0 +1,32 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.node.admin.configserver.state;
+
+/**
+ * The healthiness of a remote Vespa server based on REST API
+ *
+ * @author hakon
+ */
+public enum HealthCode {
+ DOWN("down"),
+ INITIALIZING("initializing"),
+ UP("up");
+
+ private final String code;
+
+ HealthCode(String code) {
+ this.code = code;
+ }
+
+ public static HealthCode fromString(String code) {
+ return HealthCode.valueOf(code.toUpperCase());
+ }
+
+ public String asString() {
+ return code;
+ }
+
+ @Override
+ public String toString() {
+ return asString();
+ }
+}
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/State.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/State.java
new file mode 100644
index 00000000000..ab9d0786f5a
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/State.java
@@ -0,0 +1,12 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.node.admin.configserver.state;
+
+/**
+ * The /state/v1 REST API of the config server
+ *
+ * @author hakon
+ */
+public interface State {
+ /** Issue GET on /state/v1/health */
+ HealthCode getHealth();
+}
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/StateImpl.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/StateImpl.java
new file mode 100644
index 00000000000..efeb3039379
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/StateImpl.java
@@ -0,0 +1,41 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.node.admin.configserver.state;
+
+import com.yahoo.vespa.hosted.node.admin.configserver.ConfigServerApi;
+import com.yahoo.vespa.hosted.node.admin.configserver.state.bindings.HealthResponse;
+
+/**
+ * @author hakon
+ */
+public class StateImpl implements State {
+ private final ConfigServerApi configServerApi;
+
+ public StateImpl(ConfigServerApi configServerApi) {
+ this.configServerApi = configServerApi;
+ }
+
+ @Override
+ public HealthCode getHealth() {
+ HealthResponse response;
+ try {
+ response = configServerApi.get("/state/v1/health", HealthResponse.class);
+ } catch (RuntimeException e) {
+ if (causedByConnectionRefused(e)) {
+ return HealthCode.DOWN;
+ }
+
+ throw e;
+ }
+ return HealthCode.fromString(response.status.code);
+ }
+
+ private static boolean causedByConnectionRefused(Throwable throwable) {
+ for (Throwable cause = throwable; cause != null; cause = cause.getCause()) {
+ if (cause instanceof java.net.ConnectException) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/bindings/HealthResponse.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/bindings/HealthResponse.java
new file mode 100644
index 00000000000..26ad5413fb8
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/configserver/state/bindings/HealthResponse.java
@@ -0,0 +1,36 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.node.admin.configserver.state.bindings;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Response from /state/v1/health
+ *
+ * @author hakon
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class HealthResponse {
+ @JsonProperty("status")
+ public Status status = new Status();
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class Status {
+ @JsonProperty("code")
+ public String code = "down";
+
+ @Override
+ public String toString() {
+ return "Status{" +
+ "code='" + code + '\'' +
+ '}';
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "HealthResponse{" +
+ "status=" + status +
+ '}';
+ }
+}
diff --git a/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApiImplTest.java b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApiImplTest.java
index 7c0b1f748f9..a4230e4dc8d 100644
--- a/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApiImplTest.java
+++ b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/ConfigServerApiImplTest.java
@@ -72,7 +72,7 @@ public class ConfigServerApiImplTest {
return response;
});
- executor = new ConfigServerApiImpl(configServers, httpMock);
+ executor = ConfigServerApiImpl.createForTestingWithClient(configServers, httpMock);
}
@Test
diff --git a/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConnectionSocketFactoryUpdaterTest.java b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConnectionSocketFactoryUpdaterTest.java
new file mode 100644
index 00000000000..490f45b094c
--- /dev/null
+++ b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/SslConnectionSocketFactoryUpdaterTest.java
@@ -0,0 +1,58 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.node.admin.configserver;
+
+import com.yahoo.vespa.hosted.node.admin.component.ConfigServerInfo;
+import com.yahoo.vespa.hosted.node.admin.configserver.certificate.ConfigServerKeyStoreRefresher;
+import com.yahoo.vespa.hosted.node.admin.configserver.certificate.ConfigServerKeyStoreRefresherFactory;
+import com.yahoo.vespa.hosted.node.admin.util.KeyStoreOptions;
+import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Optional;
+
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author hakon
+ */
+public class SslConnectionSocketFactoryUpdaterTest {
+ private final ConfigServerInfo configServerInfo = mock(ConfigServerInfo.class);
+ private final String hostname = "host.oath.com";
+ private final ConfigServerKeyStoreRefresherFactory refresherFactory =
+ mock(ConfigServerKeyStoreRefresherFactory.class);
+ private final ConfigServerKeyStoreRefresher refresher =
+ mock(ConfigServerKeyStoreRefresher.class);
+ private final SslConnectionSocketFactoryCreator socketFactoryCreator =
+ mock(SslConnectionSocketFactoryCreator.class);
+ private final SSLConnectionSocketFactory socketFactory = mock(SSLConnectionSocketFactory.class);
+
+ @Before
+ public void setUp() {
+ KeyStoreOptions keyStoreOptions = mock(KeyStoreOptions.class);
+ when(configServerInfo.getKeyStoreOptions()).thenReturn(Optional.of(keyStoreOptions));
+ when(refresherFactory.create(any(), any(), any(), any())).thenReturn(refresher);
+ when(socketFactoryCreator.createSocketFactory(any(), any()))
+ .thenReturn(socketFactory);
+ }
+
+ @Test
+ public void testSettingOfSocketFactory() {
+ SslConnectionSocketFactoryUpdater updater = new SslConnectionSocketFactoryUpdater(
+ configServerInfo,
+ hostname,
+ refresherFactory,
+ socketFactoryCreator);
+
+ assertTrue(socketFactory == updater.getCurrentSocketFactory());
+
+ ConfigServerApi api = mock(ConfigServerApi.class);
+ updater.registerConfigServerApi(api);
+ verify(api, times(1)).setSSLConnectionSocketFactory(socketFactory);
+ }
+} \ No newline at end of file
diff --git a/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/noderepository/RealNodeRepositoryTest.java b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/noderepository/RealNodeRepositoryTest.java
index d1237353e1c..269c79b125c 100644
--- a/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/noderepository/RealNodeRepositoryTest.java
+++ b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/noderepository/RealNodeRepositoryTest.java
@@ -4,13 +4,13 @@ package com.yahoo.vespa.hosted.node.admin.configserver.noderepository;
import com.yahoo.application.Networking;
import com.yahoo.application.container.JDisc;
-import com.yahoo.vespa.hosted.node.admin.NodeSpec;
import com.yahoo.vespa.hosted.dockerapi.DockerImage;
+import com.yahoo.vespa.hosted.node.admin.NodeSpec;
import com.yahoo.vespa.hosted.node.admin.configserver.ConfigServerApiImpl;
import com.yahoo.vespa.hosted.node.admin.nodeagent.NodeAttributes;
import com.yahoo.vespa.hosted.provision.Node;
import com.yahoo.vespa.hosted.provision.testutils.ContainerConfig;
-
+import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
@@ -63,7 +63,9 @@ public class RealNodeRepositoryTest {
try {
final int port = findRandomOpenPort();
container = JDisc.fromServicesXml(ContainerConfig.servicesXmlV2(port), Networking.enable);
- configServerApi = new ConfigServerApiImpl(Collections.singleton(URI.create("http://127.0.0.1:" + port)));
+ configServerApi = ConfigServerApiImpl.createWithSocketFactory(
+ Collections.singletonList(URI.create("http://127.0.0.1:" + port)),
+ SSLConnectionSocketFactory.getSocketFactory());
return;
} catch (RuntimeException e) {
lastException = e;
diff --git a/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/state/HealthResponseTest.java b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/state/HealthResponseTest.java
new file mode 100644
index 00000000000..fcb6f6786a8
--- /dev/null
+++ b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/state/HealthResponseTest.java
@@ -0,0 +1,54 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.node.admin.configserver.state;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yahoo.vespa.hosted.node.admin.configserver.state.bindings.HealthResponse;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class HealthResponseTest {
+ @Test
+ public void deserializationOfNormalResponse() throws Exception {
+ String jsonResponse = "{\n" +
+ " \"metrics\": {\n" +
+ " \"snapshot\": {\n" +
+ " \"from\": 1.523614569023E9,\n" +
+ " \"to\": 1.523614629023E9\n" +
+ " },\n" +
+ " \"values\": [\n" +
+ " {\n" +
+ " \"name\": \"requestsPerSecond\",\n" +
+ " \"values\": {\n" +
+ " \"count\": 121,\n" +
+ " \"rate\": 2.0166666666666666\n" +
+ " }\n" +
+ " },\n" +
+ " {\n" +
+ " \"name\": \"latencySeconds\",\n" +
+ " \"values\": {\n" +
+ " \"average\": 5.537190082644628E-4,\n" +
+ " \"count\": 121,\n" +
+ " \"last\": 0.001,\n" +
+ " \"max\": 0.001,\n" +
+ " \"min\": 0,\n" +
+ " \"rate\": 2.0166666666666666\n" +
+ " }\n" +
+ " }\n" +
+ " ]\n" +
+ " },\n" +
+ " \"status\": {\"code\": \"up\"},\n" +
+ " \"time\": 1523614629451\n" +
+ "}";
+
+ HealthResponse response = deserialize(jsonResponse);
+
+ assertEquals(response.status.code, "up");
+ }
+
+ private static HealthResponse deserialize(String json) throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+
+ return mapper.readValue(json, HealthResponse.class);
+ }
+} \ No newline at end of file
diff --git a/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/state/StateImplTest.java b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/state/StateImplTest.java
new file mode 100644
index 00000000000..01aaa385d85
--- /dev/null
+++ b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/configserver/state/StateImplTest.java
@@ -0,0 +1,37 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.node.admin.configserver.state;
+
+import com.yahoo.vespa.hosted.node.admin.configserver.ConfigServerApi;
+import com.yahoo.vespa.hosted.node.admin.configserver.state.bindings.HealthResponse;
+import org.junit.Test;
+
+import java.net.ConnectException;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class StateImplTest {
+ private final ConfigServerApi api = mock(ConfigServerApi.class);
+ private final StateImpl state = new StateImpl(api);
+
+ @Test
+ public void testWhenUp() {
+ HealthResponse response = new HealthResponse();
+ response.status.code = "up";
+ when(api.get(any(), any())).thenReturn(response);
+
+ HealthCode code = state.getHealth();
+ assertEquals(HealthCode.UP, code);
+ }
+
+ @Test
+ public void connectException() {
+ RuntimeException exception = new RuntimeException(new ConnectException("connection refused"));
+ when(api.get(any(), any())).thenThrow(exception);
+
+ HealthCode code = state.getHealth();
+ assertEquals(HealthCode.DOWN, code);
+ }
+} \ No newline at end of file