diff options
author | HÃ¥kon Hallingstad <hakon@oath.com> | 2018-04-17 08:45:55 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-04-17 08:45:55 +0200 |
commit | a2d5b8283794c8872376c754ef0a1a9fc0a978de (patch) | |
tree | 9326f991e2b1564b731d65239a45fbf357dbbc48 | |
parent | e05a40ea22ac94d63eeb054dbc20b47cbc3fce9c (diff) | |
parent | 791b893af409eec918521ce72c56d8a22a1be6be (diff) |
Merge pull request #5582 from vespa-engine/hakonhall/add-statusv1health-client
Add /status/v1/health client
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 |