├── .travis.yml ├── .gitignore ├── src ├── main │ └── java │ │ └── me │ │ └── magnet │ │ └── consultant │ │ ├── ConsulException.java │ │ ├── ConsultantException.java │ │ ├── Check.java │ │ ├── Node.java │ │ ├── KeyValueEntry.java │ │ ├── ConfigListener.java │ │ ├── SettingListener.java │ │ ├── ConfigValidator.java │ │ ├── CheckStatus.java │ │ ├── ServiceRegistration.java │ │ ├── Service.java │ │ ├── RoutingStrategy.java │ │ ├── ServiceInstance.java │ │ ├── Path.java │ │ ├── PropertiesUtil.java │ │ ├── PathParser.java │ │ ├── ConfigWriter.java │ │ ├── ServiceIdentifier.java │ │ ├── ServiceLocator.java │ │ ├── RoutingStrategies.java │ │ ├── ConfigUpdater.java │ │ ├── ServiceInstanceBackend.java │ │ └── Consultant.java └── test │ ├── resources │ └── logback.xml │ └── java │ └── me │ └── magnet │ └── consultant │ ├── RandomizedStrategyTest.java │ ├── NetworkDistanceStrategyTest.java │ ├── RandomizedWeightedDistanceStrategyTest.java │ ├── KeyValueEntryTest.java │ ├── PathTest.java │ ├── HttpUtils.java │ ├── MockedHttpClientBuilder.java │ ├── RoundRobinStrategyTest.java │ ├── PropertiesUtilTest.java │ ├── PathParserTest.java │ ├── ServiceIdentifierTest.java │ ├── RoutingStrategyTest.java │ ├── ConfigUpdaterTest.java │ └── ConsultantTest.java ├── README.md ├── pom.xml └── LICENSE /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | jdk: 3 | - openjdk8 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .classpath 2 | .project 3 | .settings/ 4 | *.iml 5 | .idea 6 | target/ 7 | bin/ 8 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/ConsulException.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | public class ConsulException extends RuntimeException { 4 | 5 | private final int status; 6 | 7 | public ConsulException(int status, String message) { 8 | super(message); 9 | this.status = status; 10 | } 11 | 12 | public int getStatus() { 13 | return status; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/ConsultantException.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | public class ConsultantException extends RuntimeException { 4 | 5 | public ConsultantException(String message) { 6 | super(message); 7 | } 8 | 9 | public ConsultantException(String message, Throwable throwable) { 10 | super(message, throwable); 11 | } 12 | 13 | public ConsultantException(Throwable throwable) { 14 | super(throwable); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/Check.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | @JsonIgnoreProperties(ignoreUnknown = true) 7 | public class Check { 8 | 9 | @JsonProperty("HTTP") 10 | private final String http; 11 | 12 | @JsonProperty("Interval") 13 | private final Integer interval; 14 | 15 | Check(String http, Integer interval) { 16 | this.http = http; 17 | this.interval = interval; 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/Node.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | @JsonIgnoreProperties(ignoreUnknown = true) 7 | public class Node { 8 | 9 | @JsonProperty("Node") 10 | private String node; 11 | 12 | @JsonProperty("Address") 13 | private String address; 14 | 15 | public String getNode() { 16 | return node; 17 | } 18 | 19 | public String getAddress() { 20 | return address; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/KeyValueEntry.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | @JsonIgnoreProperties(ignoreUnknown = true) 7 | public class KeyValueEntry { 8 | 9 | @JsonProperty("Key") 10 | private String key; 11 | 12 | @JsonProperty("Value") 13 | private String value; 14 | 15 | public String getKey() { 16 | return key; 17 | } 18 | 19 | public String getValue() { 20 | return value; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/ConfigListener.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import java.util.Properties; 4 | 5 | /** 6 | * This interface allows you to handle updates to the Properties object containing your service's configuration. 7 | */ 8 | @FunctionalInterface 9 | public interface ConfigListener { 10 | 11 | /** 12 | * This method is fired when the Properties object containing your service's configuration is modified. 13 | * 14 | * @param properties The updated Properties object. 15 | */ 16 | void onConfigUpdate(Properties properties); 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %d{dd MMM HH:mm:ss.SSS} [%thread] %-5level %logger{36} \(%L\) - %msg%n 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/SettingListener.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | /** 4 | * This interface allows you to handle updates to a particular setting of your service's configuration. 5 | */ 6 | @FunctionalInterface 7 | public interface SettingListener { 8 | 9 | /** 10 | * This method is fired when the a particular setting of your service's configuration is modified. 11 | * 12 | * @param key The key of the modified setting. 13 | * @param oldValue The old value of the setting. 14 | * @param newValue The new value of the setting. 15 | */ 16 | void onSettingUpdate(String key, String oldValue, String newValue); 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/RandomizedStrategyTest.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | 5 | import java.util.Set; 6 | 7 | import com.google.common.collect.Sets; 8 | import org.junit.Test; 9 | 10 | public class RandomizedStrategyTest extends RoutingStrategyTest { 11 | 12 | public RandomizedStrategyTest() { 13 | super(RoutingStrategies.RANDOMIZED); 14 | } 15 | 16 | @Test 17 | public void testThatAllServicesAreReturned() { 18 | ServiceLocator locations = strategy.locateInstances(serviceInstanceBackend, SERVICE_1); 19 | Set instances = Sets.newHashSet(iterate(locations)); 20 | assertEquals(Sets.newHashSet(dc1node1service1, dc1node2service1, dc1node3service1), instances); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/NetworkDistanceStrategyTest.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | 5 | import java.util.List; 6 | 7 | import com.google.common.collect.Lists; 8 | import org.junit.Test; 9 | 10 | public class NetworkDistanceStrategyTest extends RoutingStrategyTest { 11 | 12 | public NetworkDistanceStrategyTest() { 13 | super(RoutingStrategies.NETWORK_DISTANCE); 14 | } 15 | 16 | @Test 17 | public void testThatInstancesAreReturnedInOrder() { 18 | ServiceLocator locations = strategy.locateInstances(serviceInstanceBackend, SERVICE_1); 19 | List instances = iterate(locations); 20 | assertEquals(Lists.newArrayList(dc1node1service1, dc1node2service1, dc1node3service1, dc2node1service1), instances); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/RandomizedWeightedDistanceStrategyTest.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | 5 | import java.util.Set; 6 | 7 | import com.google.common.collect.Sets; 8 | import org.junit.Test; 9 | 10 | public class RandomizedWeightedDistanceStrategyTest extends RoutingStrategyTest { 11 | 12 | public RandomizedWeightedDistanceStrategyTest() { 13 | super(RoutingStrategies.RANDOMIZED_WEIGHTED_DISTANCE); 14 | } 15 | 16 | @Test 17 | public void testThatAllServicesAreReturned() { 18 | ServiceLocator locations = strategy.locateInstances(serviceInstanceBackend, SERVICE_1); 19 | Set instances = Sets.newHashSet(iterate(locations)); 20 | assertEquals(Sets.newHashSet(dc1node1service1, dc1node2service1, dc1node3service1), instances); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/ConfigValidator.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import java.util.Properties; 4 | 5 | /** 6 | * This interface allows you to validate the new config for your service, before it's actually exposed to your 7 | * service. If the updated Properties object is deemed invalid it will not be exposed to your service. 8 | */ 9 | @FunctionalInterface 10 | public interface ConfigValidator { 11 | 12 | /** 13 | * This method verifies if a new config as specified by the Properties object is valid or not. Throwing a 14 | * RuntimeException (or a subclass) indicates that the configuration is invalid. Not throwing a RuntimeException 15 | * means that the configuration is deemed valid. 16 | * 17 | * @param properties The configuration to validate. 18 | */ 19 | void validateConfig(Properties properties); 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/KeyValueEntryTest.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import java.lang.reflect.Constructor; 4 | import java.lang.reflect.Modifier; 5 | import java.util.stream.Stream; 6 | 7 | import org.junit.Test; 8 | 9 | public class KeyValueEntryTest { 10 | 11 | @Test 12 | public void verifyThatPublicNoArgsConstructorExists() throws ReflectiveOperationException { 13 | Constructor[] constructors = KeyValueEntry.class.getDeclaredConstructors(); 14 | 15 | Stream.of(constructors) 16 | .filter(constructor -> Modifier.isPublic(constructor.getModifiers())) 17 | .filter(constructor -> constructor.getParameterCount() == 0) 18 | .findFirst() 19 | .orElseThrow(() -> new ReflectiveOperationException("The class '" + KeyValueEntry.class 20 | + "' must contain one public no-args constructor!")); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/CheckStatus.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | @JsonIgnoreProperties(ignoreUnknown = true) 7 | public class CheckStatus { 8 | 9 | @JsonProperty("Name") 10 | private final String name; 11 | 12 | @JsonProperty("Output") 13 | private final String output; 14 | 15 | @JsonProperty("Status") 16 | private final String status; 17 | 18 | CheckStatus() { 19 | this.name = null; 20 | this.output = null; 21 | this.status = null; 22 | } 23 | 24 | CheckStatus(String name, String output, String status) { 25 | this.name = name; 26 | this.output = output; 27 | this.status = status; 28 | } 29 | 30 | public String getName() { 31 | return name; 32 | } 33 | 34 | public String getOutput() { 35 | return output; 36 | } 37 | 38 | public String getStatus() { 39 | return status; 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/ServiceRegistration.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | @JsonIgnoreProperties(ignoreUnknown = true) 7 | public class ServiceRegistration { 8 | 9 | @JsonProperty("ID") 10 | private final String id; 11 | 12 | @JsonProperty("Name") 13 | private final String name; 14 | 15 | @JsonProperty("Tags") 16 | private final String[] tags; 17 | 18 | @JsonProperty("Address") 19 | private final String address; 20 | 21 | @JsonProperty("Port") 22 | private final int port; 23 | 24 | @JsonProperty("Check") 25 | private final Check check; 26 | 27 | ServiceRegistration(String id, String name, String address, int port, Check check, String... tags) { 28 | this.id = id; 29 | this.name = name; 30 | this.address = address; 31 | this.port = port; 32 | this.tags = tags; 33 | this.check = check; 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/Service.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import com.fasterxml.jackson.annotation.JsonAlias; 4 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 5 | import com.fasterxml.jackson.annotation.JsonProperty; 6 | 7 | @JsonIgnoreProperties(ignoreUnknown = true) 8 | public class Service { 9 | 10 | @JsonAlias({"ID", "Id"}) 11 | private String id; 12 | 13 | @JsonProperty("Service") 14 | private String service; 15 | 16 | @JsonProperty("Tags") 17 | private String[] tags; 18 | 19 | @JsonProperty("Address") 20 | private String address; 21 | 22 | @JsonProperty("Port") 23 | private Integer port; 24 | 25 | public String getId() { 26 | return id; 27 | } 28 | 29 | public String getService() { 30 | return service; 31 | } 32 | 33 | public String[] getTags() { 34 | return tags; 35 | } 36 | 37 | public String getAddress() { 38 | return address; 39 | } 40 | 41 | public Integer getPort() { 42 | return port; 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/RoutingStrategy.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | /** 4 | * Specialized interface which can be used to locate a bunch of service instances. Implementations of this interface can 5 | * be used to do client-side load balancing, and determine the order in which the service instances can be tried. 6 | */ 7 | public interface RoutingStrategy { 8 | 9 | /** 10 | * Creates a new ServiceLocator object which can be used to locate one or more instances according to 11 | * implementation of this interface. The service instances matching the service name may be returned 12 | * in any particular order. 13 | * 14 | * @param serviceInstanceBackend The ServiceInstanceBackend to use to fetch data from Consul. 15 | * @param serviceName The name of the service instance to locate. 16 | * @return A ServiceLocator object to locate instances. 17 | */ 18 | ServiceLocator locateInstances(ServiceInstanceBackend serviceInstanceBackend, String serviceName); 19 | 20 | /** 21 | * Resets any internal state of this RoutingStrategy implementation. 22 | */ 23 | default void reset() { 24 | // Do nothing 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/PathTest.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | 5 | import nl.jqno.equalsverifier.EqualsVerifier; 6 | import nl.jqno.equalsverifier.Warning; 7 | import org.junit.Test; 8 | 9 | public class PathTest { 10 | 11 | @Test(expected = NullPointerException.class) 12 | public void verifyThatConstructorThrowsExceptionOnNullAsServiceIdentifier() { 13 | new Path(null, null, null); 14 | } 15 | 16 | @Test 17 | public void verifyThatConstructorDoesNotThrowExceptionWhenServiceIdentifierIsNotNull() { 18 | new Path(null, new ServiceIdentifier("oauth", null, null, null), null); 19 | } 20 | 21 | @Test 22 | public void verifyToStringOnFullPath() { 23 | ServiceIdentifier id = new ServiceIdentifier("oauth", "eu-central", "web-1", "master"); 24 | Path path = new Path("some-prefix/sub-fix", id, "some.key"); 25 | assertEquals("some-prefix/sub-fix/oauth/[dc=eu-central,host=web-1,instance=master]/some.key", path.toString()); 26 | } 27 | 28 | @Test 29 | public void verifyEqualsMethod() { 30 | EqualsVerifier.forClass(Path.class) 31 | .suppress(Warning.STRICT_INHERITANCE) 32 | .verify(); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/ServiceInstance.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import java.util.List; 4 | 5 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 6 | import com.fasterxml.jackson.annotation.JsonProperty; 7 | import com.google.common.collect.ImmutableList; 8 | 9 | @JsonIgnoreProperties(ignoreUnknown = true) 10 | public class ServiceInstance { 11 | 12 | @JsonProperty("Node") 13 | private final Node node; 14 | 15 | @JsonProperty("Service") 16 | private final Service service; 17 | 18 | @JsonProperty("Checks") 19 | private final List checks; 20 | 21 | private ServiceInstance() { 22 | this.node = null; 23 | this.service = null; 24 | this.checks = null; 25 | } 26 | 27 | ServiceInstance(Node node, Service service, List checks) { 28 | this.node = node; 29 | this.service = service; 30 | this.checks = checks; 31 | } 32 | 33 | public Node getNode() { 34 | return node; 35 | } 36 | 37 | public Service getService() { 38 | return service; 39 | } 40 | 41 | public List getChecks() { 42 | return ImmutableList.copyOf(checks); 43 | } 44 | 45 | @Override 46 | public String toString() { 47 | return "[" + service.getService() + " - " + service.getId() + " @ " + node.getNode() + "]"; 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/HttpUtils.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import java.io.IOException; 4 | import java.util.Base64; 5 | import java.util.Base64.Encoder; 6 | import java.util.Map; 7 | import java.util.Optional; 8 | import java.util.stream.Collectors; 9 | 10 | import com.fasterxml.jackson.databind.ObjectMapper; 11 | import org.apache.http.ProtocolVersion; 12 | import org.apache.http.StatusLine; 13 | import org.apache.http.entity.StringEntity; 14 | import org.apache.http.message.BasicStatusLine; 15 | 16 | public class HttpUtils { 17 | 18 | private HttpUtils() { 19 | // Prevent instantiation... 20 | } 21 | 22 | public static StatusLine createStatus(int status, String phrase) { 23 | return new BasicStatusLine(new ProtocolVersion("http", 1, 1), status, phrase); 24 | } 25 | 26 | public static StringEntity toJson(Map entries) { 27 | Encoder encoder = Base64.getEncoder(); 28 | try { 29 | return new StringEntity("[" + entries.entrySet().stream() 30 | .map(entry -> { 31 | String value = Optional.ofNullable(entry.getValue()) 32 | .map(entryValue -> "\"" + encoder.encodeToString(entryValue.getBytes()) + "\"") 33 | .orElse("null"); 34 | 35 | return "{\"Key\":\"" + entry.getKey() + "\",\"Value\":" + value + "}"; 36 | }) 37 | .collect(Collectors.joining(",")) + "]"); 38 | } 39 | catch (IOException e) { 40 | throw new RuntimeException(e); 41 | } 42 | } 43 | 44 | public static StringEntity toJson(Object value) { 45 | ObjectMapper objectMapper = new ObjectMapper(); 46 | try { 47 | return new StringEntity(objectMapper.writeValueAsString(value)); 48 | } 49 | catch (IOException e) { 50 | throw new RuntimeException(e); 51 | } 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/MockedHttpClientBuilder.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static org.mockito.Matchers.any; 4 | import static org.mockito.Mockito.when; 5 | 6 | import java.io.IOException; 7 | import java.net.URI; 8 | import java.util.function.Function; 9 | 10 | import com.google.common.collect.HashBasedTable; 11 | import com.google.common.collect.Table; 12 | import org.apache.http.client.methods.CloseableHttpResponse; 13 | import org.apache.http.client.methods.HttpGet; 14 | import org.apache.http.client.methods.HttpPost; 15 | import org.apache.http.client.methods.HttpRequestBase; 16 | import org.apache.http.impl.client.CloseableHttpClient; 17 | import org.mockito.Mockito; 18 | 19 | public class MockedHttpClientBuilder { 20 | 21 | private final Table> handlers; 22 | 23 | MockedHttpClientBuilder() { 24 | this.handlers = HashBasedTable.create(); 25 | } 26 | 27 | public MockedHttpClientBuilder onGet(String path, Function consumer) { 28 | handlers.put(path, "GET", request -> consumer.apply((HttpGet) request)); 29 | return this; 30 | } 31 | 32 | public MockedHttpClientBuilder onPost(String path, Function consumer) { 33 | handlers.put(path, "POST", request -> consumer.apply((HttpPost) request)); 34 | return this; 35 | } 36 | 37 | public CloseableHttpClient create() throws IOException { 38 | CloseableHttpClient mock = Mockito.mock(CloseableHttpClient.class); 39 | when(mock.execute(any())).then(invocationOnMock -> { 40 | Object[] arguments = invocationOnMock.getArguments(); 41 | HttpRequestBase argument = (HttpRequestBase) arguments[0]; 42 | 43 | URI uri = argument.getURI(); 44 | String path = uri.getPath(); 45 | if (uri.getRawQuery() != null) { 46 | path += "?" + uri.getRawQuery(); 47 | } 48 | 49 | Function handler = handlers.get(path, argument.getMethod()); 50 | if (handler == null) { 51 | throw new UnsupportedOperationException("No support for type: " + argument.getClass().getSimpleName() 52 | + " and path: " + path); 53 | } 54 | 55 | return handler.apply(argument); 56 | }); 57 | 58 | return mock; 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/Path.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static com.google.common.base.Preconditions.checkNotNull; 4 | import static com.google.common.base.Strings.isNullOrEmpty; 5 | 6 | import java.util.List; 7 | import java.util.stream.Collectors; 8 | 9 | import com.google.common.collect.Lists; 10 | import org.apache.commons.lang3.builder.EqualsBuilder; 11 | import org.apache.commons.lang3.builder.HashCodeBuilder; 12 | 13 | class Path { 14 | 15 | private final String prefix; 16 | private final ServiceIdentifier id; 17 | private final String key; 18 | 19 | Path(String prefix, ServiceIdentifier id, String key) { 20 | checkNotNull(id, "You must specify an 'id'!"); 21 | 22 | this.prefix = prefix; 23 | this.id = id; 24 | this.key = key; 25 | } 26 | 27 | public String getPrefix() { 28 | return prefix; 29 | } 30 | 31 | public ServiceIdentifier getId() { 32 | return id; 33 | } 34 | 35 | public String getKey() { 36 | return key; 37 | } 38 | 39 | @Override 40 | public int hashCode() { 41 | return new HashCodeBuilder() 42 | .append(prefix) 43 | .append(id) 44 | .append(key) 45 | .toHashCode(); 46 | } 47 | 48 | @Override 49 | public boolean equals(Object other) { 50 | if (other instanceof Path) { 51 | Path path = (Path) other; 52 | return new EqualsBuilder() 53 | .append(prefix, path.prefix) 54 | .append(id, path.id) 55 | .append(key, path.key) 56 | .isEquals(); 57 | } 58 | return false; 59 | } 60 | 61 | @Override 62 | public String toString() { 63 | List descriptors = Lists.newArrayList(); 64 | id.getDatacenter().ifPresent(dc -> descriptors.add("dc=" + dc)); 65 | id.getHostName().ifPresent(host -> descriptors.add("host=" + host)); 66 | id.getInstance().ifPresent(instance -> descriptors.add("instance=" + instance)); 67 | 68 | StringBuilder builder = new StringBuilder(); 69 | if (!isNullOrEmpty(prefix)) { 70 | builder.append(prefix).append("/"); 71 | } 72 | builder.append(id.getServiceName()); 73 | if (!descriptors.isEmpty()) { 74 | builder.append("/[") 75 | .append(descriptors.stream().collect(Collectors.joining(","))) 76 | .append("]"); 77 | } 78 | if (!isNullOrEmpty(key)) { 79 | builder.append("/").append(key); 80 | } 81 | return builder.toString(); 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/RoundRobinStrategyTest.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | 5 | import org.junit.Test; 6 | 7 | public class RoundRobinStrategyTest extends RoutingStrategyTest { 8 | 9 | public RoundRobinStrategyTest() { 10 | super(RoutingStrategies.ROUND_ROBIN); 11 | } 12 | 13 | @Test 14 | public void testFirstEntryIsInstanceOnFirstNode() { 15 | ServiceLocator locator = strategy.locateInstances(serviceInstanceBackend, SERVICE_1); 16 | ServiceInstance first = locator.next().get(); 17 | assertEquals(dc1node1service1, first); 18 | } 19 | 20 | @Test 21 | public void testSecondEntryIsInstanceOnSecondNode() { 22 | ServiceLocator locator1 = strategy.locateInstances(serviceInstanceBackend, SERVICE_1); 23 | ServiceLocator locator2 = strategy.locateInstances(serviceInstanceBackend, SERVICE_1); 24 | 25 | locator1.next().get(); 26 | ServiceInstance second = locator2.next().get(); 27 | 28 | assertEquals(dc1node2service1, second); 29 | } 30 | 31 | @Test 32 | public void testSecondEntryIsInstanceOnThirdNode() { 33 | ServiceLocator locator1 = strategy.locateInstances(serviceInstanceBackend, SERVICE_1); 34 | ServiceLocator locator2 = strategy.locateInstances(serviceInstanceBackend, SERVICE_1); 35 | 36 | locator1.next().get(); 37 | locator1.next().get(); 38 | ServiceInstance third = locator2.next().get(); 39 | 40 | assertEquals(dc1node3service1, third); 41 | } 42 | 43 | @Test 44 | public void testForthEntryIsInstanceOnFirstNode() { 45 | ServiceLocator locator1 = strategy.locateInstances(serviceInstanceBackend, SERVICE_1); 46 | ServiceLocator locator2 = strategy.locateInstances(serviceInstanceBackend, SERVICE_1); 47 | 48 | locator1.next().get(); 49 | locator2.next().get(); 50 | locator1.next().get(); 51 | ServiceInstance fourth = locator2.next().get(); 52 | 53 | assertEquals(dc1node1service1, fourth); 54 | } 55 | 56 | @Test 57 | public void testFirstEntryOfOtherServiceIsInstanceOnFirstNode() { 58 | ServiceLocator locator1 = strategy.locateInstances(serviceInstanceBackend, SERVICE_1); 59 | ServiceLocator locator2 = strategy.locateInstances(serviceInstanceBackend, SERVICE_2); 60 | 61 | locator1.next().get(); 62 | ServiceInstance first = locator2.next().get(); 63 | 64 | assertEquals(dc1node1service2, first); 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/PropertiesUtilTest.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | import static org.junit.Assert.assertFalse; 5 | import static org.junit.Assert.assertNull; 6 | 7 | import java.util.Properties; 8 | 9 | import org.junit.Test; 10 | 11 | public class PropertiesUtilTest { 12 | 13 | @Test(expected = NullPointerException.class) 14 | public void verifyThatSyncMethodWithNullSourcePropertiesThrowsException() { 15 | PropertiesUtil.sync(null, new Properties()); 16 | } 17 | 18 | @Test(expected = NullPointerException.class) 19 | public void verifyThatSyncMethodWithNullTargetPropertiesThrowsException() { 20 | PropertiesUtil.sync(new Properties(), null); 21 | } 22 | 23 | @Test 24 | public void verifyThatAllPropertiesInSourceAreTransferredToTarget() { 25 | Properties source = new Properties(); 26 | Properties target = new Properties(); 27 | 28 | source.setProperty("key-1", "some-value"); 29 | PropertiesUtil.sync(source, target); 30 | 31 | assertEquals("some-value", target.getProperty("key-1")); 32 | } 33 | 34 | @Test 35 | public void verifyThatAllPropertiesNotInSourceAreRemovedFromTarget() { 36 | Properties source = new Properties(); 37 | Properties target = new Properties(); 38 | 39 | target.setProperty("key-1", "some-value"); 40 | PropertiesUtil.sync(source, target); 41 | 42 | assertFalse(target.containsKey("key-1")); 43 | } 44 | 45 | @Test 46 | public void verifyThatAllPropertiesInSourceOverrideTargetProperties() { 47 | Properties source = new Properties(); 48 | Properties target = new Properties(); 49 | 50 | source.setProperty("key-1", "some-value"); 51 | target.setProperty("key-1", "some-other-value"); 52 | PropertiesUtil.sync(source, target); 53 | 54 | assertEquals("some-value", target.getProperty("key-1")); 55 | } 56 | 57 | @Test 58 | public void verifyMerge() { 59 | Properties source = new Properties(); 60 | Properties target = new Properties(); 61 | 62 | source.setProperty("key-1", "some-value"); 63 | source.setProperty("key-2", "other-value"); 64 | 65 | target.setProperty("key-1", "some-other-value"); 66 | target.setProperty("key-3", "new-value"); 67 | 68 | PropertiesUtil.sync(source, target); 69 | 70 | assertEquals("some-value", target.getProperty("key-1")); 71 | assertEquals("other-value", target.getProperty("key-2")); 72 | assertNull(target.getProperty("key-3")); 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/PropertiesUtil.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static com.google.common.base.Preconditions.checkNotNull; 4 | 5 | import java.util.Map; 6 | import java.util.Properties; 7 | import java.util.Set; 8 | 9 | import com.google.common.collect.Maps; 10 | import com.google.common.collect.Sets; 11 | import org.apache.commons.lang3.tuple.Pair; 12 | 13 | public class PropertiesUtil { 14 | 15 | /** 16 | * Copies all the properties from source to target. If a property exists in both 17 | * Properties objects, then it is overwritten. If a property exists in target, but not in 18 | * source, it is removed from the target Properties object. 19 | * 20 | * @param source The source Properties object. 21 | * @param target The target Properties object. 22 | * 23 | * @return A Map of changes. The key of the Map is the setting name, whereas the value is a Pair object with the 24 | * old and new value of the setting. 25 | */ 26 | public static Map> sync(Properties source, Properties target) { 27 | checkNotNull(source, "You must specify a 'source' Properties object!"); 28 | checkNotNull(target, "You must specify a 'source' Properties object!"); 29 | 30 | Set sourceKeys = source.stringPropertyNames(); 31 | Set targetKeys = target.stringPropertyNames(); 32 | 33 | Set added = Sets.newHashSet(Sets.difference(sourceKeys, targetKeys)); 34 | Set modified = Sets.newHashSet(Sets.intersection(sourceKeys, targetKeys)); 35 | Set removed = Sets.newHashSet(Sets.difference(targetKeys, sourceKeys)); 36 | 37 | Map> changes = Maps.newHashMap(); 38 | 39 | added.forEach(key -> { 40 | String newValue = source.getProperty(key); 41 | changes.put(key, Pair.of(null, newValue)); 42 | target.setProperty(key, newValue); 43 | }); 44 | 45 | modified.forEach(key -> { 46 | String oldValue = target.getProperty(key); 47 | String newValue = source.getProperty(key); 48 | changes.put(key, Pair.of(oldValue, newValue)); 49 | target.setProperty(key, newValue); 50 | }); 51 | 52 | removed.forEach(key -> { 53 | String oldValue = target.getProperty(key); 54 | changes.put(key, Pair.of(oldValue, null)); 55 | target.remove(key); 56 | }); 57 | 58 | return changes; 59 | } 60 | 61 | private PropertiesUtil() { 62 | // Prevent instantiation. 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/PathParser.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static com.google.common.base.Preconditions.checkArgument; 4 | import static com.google.common.base.Strings.emptyToNull; 5 | import static com.google.common.base.Strings.isNullOrEmpty; 6 | 7 | import java.util.regex.Matcher; 8 | import java.util.regex.Pattern; 9 | 10 | class PathParser { 11 | 12 | private static final Pattern DC_FIELD = Pattern.compile("^dc\\s*=\\s*(?.*)$"); 13 | private static final Pattern HOST_FIELD = Pattern.compile("^host\\s*=\\s*(?.*)$"); 14 | private static final Pattern INSTANCE_FIELD = Pattern.compile("^instance\\s*=\\s*(?.*)$"); 15 | 16 | static Path parse(String prefix, String path) { 17 | checkArgument(!isNullOrEmpty(path), "You must specify an 'path'!"); 18 | 19 | String tail = path; 20 | if (prefix != null) { 21 | if (!path.startsWith(prefix + "/")) { 22 | return null; 23 | } 24 | tail = path.substring(prefix.length() + 1); 25 | } 26 | 27 | String serviceName; 28 | String datacenter = null; 29 | String hostName = null; 30 | String serviceInstance = null; 31 | 32 | if (tail.contains("/[")) { 33 | int index = tail.indexOf("/["); 34 | serviceName = tail.substring(0, index); 35 | 36 | String part = tail.substring(index + 2); 37 | part = part.substring(0, part.indexOf(']')); 38 | String[] splits = part.split(","); 39 | for (String split : splits) { 40 | if (datacenter == null) { 41 | Matcher matcher = DC_FIELD.matcher(split); 42 | if (matcher.find()) { 43 | datacenter = matcher.group("dc"); 44 | continue; 45 | } 46 | } 47 | if (hostName == null) { 48 | Matcher matcher = HOST_FIELD.matcher(split); 49 | if (matcher.find()) { 50 | hostName = matcher.group("host"); 51 | continue; 52 | } 53 | } 54 | if (serviceInstance == null) { 55 | Matcher matcher = INSTANCE_FIELD.matcher(split); 56 | if (matcher.find()) { 57 | serviceInstance = matcher.group("instance"); 58 | } 59 | } 60 | } 61 | tail = tail.substring(tail.indexOf("]") + 1); 62 | if (tail.startsWith(".")) { 63 | tail = tail.substring(1); 64 | } 65 | } 66 | else if (tail.contains("/")) { 67 | serviceName = tail.substring(0, tail.indexOf("/")); 68 | tail = tail.substring(tail.indexOf("/") + 1); 69 | } 70 | else { 71 | serviceName = tail; 72 | tail = ""; 73 | } 74 | 75 | ServiceIdentifier id = new ServiceIdentifier(serviceName, datacenter, hostName, serviceInstance); 76 | return new Path(prefix, id, emptyToNull(tail)); 77 | } 78 | 79 | private PathParser() { 80 | // Prevent instantiation. 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/PathParserTest.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | import static org.junit.Assert.assertNull; 5 | 6 | import org.junit.Test; 7 | 8 | public class PathParserTest { 9 | 10 | @Test(expected = IllegalArgumentException.class) 11 | public void verifyThatNullInputForPathThrowsException() { 12 | PathParser.parse(null, null); 13 | } 14 | 15 | @Test(expected = IllegalArgumentException.class) 16 | public void verifyThatEmptyInputForPathThrowsException() { 17 | PathParser.parse(null, ""); 18 | } 19 | 20 | @Test 21 | public void verifyThatOnlyServiceNameIsParsedCorrectly() { 22 | Path actual = PathParser.parse(null, "oauth"); 23 | Path expected = new Path(null, new ServiceIdentifier("oauth", null, null, null), null); 24 | assertEquals(expected, actual); 25 | } 26 | 27 | @Test 28 | public void verifyThatPrefixParsedCorrectly() { 29 | Path actual = PathParser.parse("some-prefix", "some-prefix/oauth"); 30 | Path expected = new Path("some-prefix", new ServiceIdentifier("oauth", null, null, null), null); 31 | assertEquals(expected, actual); 32 | } 33 | 34 | @Test 35 | public void verifyThatDifferentPrefixReturnsNull() { 36 | Path actual = PathParser.parse("some-other-prefix", "some-prefix/oauth"); 37 | assertNull(actual); 38 | } 39 | 40 | @Test 41 | public void verifyOnlyServiceNameWithDCIsParsedCorrectly() { 42 | Path actual = PathParser.parse("some-prefix", "some-prefix/oauth/[dc=eu-central]"); 43 | Path expected = new Path("some-prefix", new ServiceIdentifier("oauth", "eu-central", null, null), null); 44 | assertEquals(expected, actual); 45 | } 46 | 47 | @Test 48 | public void verifyThatServiceNameWithHostIsParsedCorrectly() { 49 | Path actual = PathParser.parse("some-prefix", "some-prefix/oauth/[host=web-1]"); 50 | Path expected = new Path("some-prefix", new ServiceIdentifier("oauth", null, "web-1", null), null); 51 | assertEquals(expected, actual); 52 | } 53 | 54 | @Test 55 | public void verifyThatServiceNameWithInstanceIsParsedCorrectly() { 56 | Path actual = PathParser.parse("some-prefix", "some-prefix/oauth/[instance=master]"); 57 | Path expected = new Path("some-prefix", new ServiceIdentifier("oauth", null, null, "master"), null); 58 | assertEquals(expected, actual); 59 | } 60 | 61 | @Test 62 | public void verifyThatServiceNameWithMultipleFieldsIsParsedCorrectly() { 63 | Path actual = PathParser.parse("some-prefix", "some-prefix/oauth/[dc=eu-central,host=web-1,instance=master]"); 64 | Path expected = new Path("some-prefix", new ServiceIdentifier("oauth", "eu-central", "web-1", "master"), null); 65 | assertEquals(expected, actual); 66 | } 67 | 68 | @Test 69 | public void verifyThatServiceNameWithMultipleFieldsInDifferentOrderIsParsedCorrectly() { 70 | Path actual = PathParser.parse("some-prefix", "some-prefix/oauth/[instance=master,host=web-1,dc=eu-central]"); 71 | Path expected = new Path("some-prefix", new ServiceIdentifier("oauth", "eu-central", "web-1", "master"), null); 72 | assertEquals(expected, actual); 73 | } 74 | 75 | @Test 76 | public void verifyThatConfigKeyIsParsedCorrectly() { 77 | Path actual = PathParser.parse("some-prefix", "some-prefix/oauth/[instance=master,host=web-1,dc=eu-central].some-key"); 78 | Path expected = new Path("some-prefix", new ServiceIdentifier("oauth", "eu-central", "web-1", "master"), "some-key"); 79 | assertEquals(expected, actual); 80 | } 81 | 82 | @Test 83 | public void verifyThatConfigKeyIsParsedCorrectlyWithDescriptors() { 84 | Path actual = PathParser.parse("some-prefix", "some-prefix/oauth/some-key"); 85 | Path expected = new Path("some-prefix", new ServiceIdentifier("oauth", null, null, null), "some-key"); 86 | assertEquals(expected, actual); 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Magnet.me Logo](https://cdn.magnet.me/images/logo-2015-full.svg)](https://magnet.me "Discover the best companies, jobs and internships at Magnet.me") 2 | 3 | # Consultant 4 | ###### Fetches your service's configuration from Consul, and subscribes to any changes. 5 | 6 | ## What's Consultant? 7 | Consultant is a Java library which allows your service to retrieve its configuration from Consul's Key/Value store. In addition to this, Consultant subscribes to any changes relevant to your service. 8 | 9 | ## How to use Consultant? 10 | In order use Consultant, you'll have to create a `Consultant` object first. This can be done using a `Builder`: 11 | 12 | ```java 13 | Consultant consultant = Consultant.builder() 14 | .identifyAs("oauth") 15 | .build(); 16 | ``` 17 | 18 | With the `identifyAs()` method you tell Consultant the identity of your service. Using this identity the correct configuration can be fetched from Consul's Key/Value store. You must at the very least specify the service's name. You can also optionally specify the name of the datacenter where the service is running, the hostname of the machine the service is running on, and instance name to describe the role of this particular instance. 19 | 20 | Alternative you can also define this identity through environment variables: 21 | 22 | | Environment variable | Corresponds to | Required | 23 | |:---------------------|:---------------|:---------| 24 | | SERVICE_NAME | Name of the service | Yes | 25 | | SERVICE_DC | Name of the datacenter where the service is running | No | 26 | | SERVICE_HOST | The name of the host where this service is running on | No | 27 | | SERVICE_INSTANCE | The name of this particular instance of the service | No | 28 | 29 | ### Specifying an alternative Consul address 30 | Consultant defaults Consul's REST API address to `http://localhost:8500`. If you wish to specify an alternative address to Consul's REST API, you can do so by using the `Builder`: 31 | 32 | ```java 33 | Consultant consultant = Consultant.builder() 34 | .identifyAs("oauth") 35 | .withConsulHost("http://some-other-host") 36 | .build(); 37 | ``` 38 | 39 | Or alternatively you can also define the this through an environment variable: 40 | 41 | | Environment variable | Corresponds to | 42 | |:---------------------|:---------------| 43 | | CONSUL_HOST | Address of Consul's REST API | 44 | 45 | ### Validating configurations 46 | 47 | If you wish to impose any kind of validation on configurations (before it's exposed to your service), you can solve this using the `Builder`: 48 | 49 | ```java 50 | Consultant consultant = Consultant.builder() 51 | .identifyAs("oauth") 52 | .validateConfigWith((config) -> { 53 | Preconditions.checkArgument(!Strings.isNullOrEmpty(config.getProperty("database.password"))); 54 | }) 55 | .build(); 56 | ``` 57 | 58 | ### Retrieving the configuration from Consultant 59 | 60 | You can retrieve the current configuration from Consultant by calling the `getProperties()` on the `Consultant` class: 61 | 62 | ```java 63 | Consultant consultant = Consultant.builder() 64 | .identifyAs("oauth") 65 | .build(); 66 | 67 | Properties properties = consultant.getProperties(); 68 | ``` 69 | 70 | Note that this `Properties` object is effectively a singleton, and is updated in-place by Consultant at run-time. 71 | 72 | ### Listening for updates to the configuration 73 | 74 | If you wish to be notified of updates to the configuration you can specify a callback in the `Builder`: 75 | 76 | ```java 77 | Consultant consultant = Consultant.builder() 78 | .identifyAs("oauth") 79 | .onValidConfig((config) -> { 80 | log.info("Yay, there's a new config available!"); 81 | }) 82 | .build(); 83 | ``` 84 | 85 | ## Licensing 86 | 87 | Consultant is available under the Apache 2 License, and is provided as is. 88 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/ConfigWriter.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static me.magnet.consultant.Consultant.CONFIG_PREFIX; 4 | 5 | import java.io.IOException; 6 | import java.net.URI; 7 | import java.net.URLEncoder; 8 | import java.util.List; 9 | import java.util.Optional; 10 | import java.util.stream.Collectors; 11 | 12 | import com.google.common.base.Strings; 13 | import com.google.common.collect.Lists; 14 | import org.apache.http.client.methods.CloseableHttpResponse; 15 | import org.apache.http.client.methods.HttpDelete; 16 | import org.apache.http.client.methods.HttpPut; 17 | import org.apache.http.entity.StringEntity; 18 | import org.apache.http.impl.client.CloseableHttpClient; 19 | import org.apache.http.util.EntityUtils; 20 | import org.slf4j.Logger; 21 | import org.slf4j.LoggerFactory; 22 | 23 | class ConfigWriter { 24 | 25 | private static final Logger log = LoggerFactory.getLogger(ConfigWriter.class); 26 | 27 | private final CloseableHttpClient httpClient; 28 | private final URI consulURI; 29 | private final String token; 30 | private final String kvPrefix; 31 | 32 | ConfigWriter(CloseableHttpClient httpClient, URI consulURI, String token, String kvPrefix) { 33 | this.httpClient = httpClient; 34 | this.consulURI = consulURI; 35 | this.token = token; 36 | this.kvPrefix = Optional.ofNullable(kvPrefix).orElse(CONFIG_PREFIX); 37 | } 38 | 39 | public boolean setConfig(ServiceIdentifier identifier, String key, String value) { 40 | try { 41 | if (value != null) { 42 | return put(identifier, key, value); 43 | } 44 | return delete(identifier, key); 45 | } 46 | catch (IOException | RuntimeException e) { 47 | log.error("Error occurred while pushing new config from Consul: " + e.getMessage(), e); 48 | return false; 49 | } 50 | } 51 | 52 | private boolean put(ServiceIdentifier identifier, String key, String value) throws IOException { 53 | StringBuilder builder = new StringBuilder(); 54 | builder.append(kvPrefix) 55 | .append("/") 56 | .append(identifier.getServiceName()) 57 | .append("/"); 58 | 59 | String identifierPrefix = createServiceIdentifierPrefix(identifier); 60 | if (!Strings.isNullOrEmpty(identifierPrefix)) { 61 | builder.append(identifierPrefix); 62 | } 63 | 64 | builder.append(key); 65 | 66 | String path = URLEncoder.encode(builder.toString(), "UTF-8"); 67 | String url = consulURI + "/v1/kv/" + path; 68 | 69 | HttpPut request = new HttpPut(url); 70 | request.setEntity(new StringEntity(value)); 71 | if (!Strings.isNullOrEmpty(token)) { 72 | request.setHeader("X-Consul-Token", token); 73 | } 74 | 75 | try (CloseableHttpResponse response = httpClient.execute(request)) { 76 | String body = EntityUtils.toString(response.getEntity()); 77 | return "true".equalsIgnoreCase(body); 78 | } 79 | } 80 | 81 | private boolean delete(ServiceIdentifier identifier, String key) throws IOException { 82 | StringBuilder builder = new StringBuilder(); 83 | builder.append(kvPrefix) 84 | .append("/") 85 | .append(identifier.getServiceName()) 86 | .append("/"); 87 | 88 | String identifierPrefix = createServiceIdentifierPrefix(identifier); 89 | if (!Strings.isNullOrEmpty(identifierPrefix)) { 90 | builder.append(identifierPrefix); 91 | } 92 | 93 | builder.append(key); 94 | 95 | String path = URLEncoder.encode(builder.toString(), "UTF-8"); 96 | String url = consulURI + "/v1/kv/" + path; 97 | 98 | HttpDelete request = new HttpDelete(url); 99 | if (!Strings.isNullOrEmpty(token)) { 100 | request.setHeader("X-Consul-Token", token); 101 | } 102 | 103 | try (CloseableHttpResponse response = httpClient.execute(request)) { 104 | String body = EntityUtils.toString(response.getEntity()); 105 | return "true".equalsIgnoreCase(body); 106 | } 107 | } 108 | 109 | private String createServiceIdentifierPrefix(ServiceIdentifier identifier) { 110 | List parts = Lists.newArrayList(); 111 | 112 | identifier.getDatacenter() 113 | .map(value -> "dc=" + value) 114 | .ifPresent(parts::add); 115 | 116 | identifier.getHostName() 117 | .map(value -> "host=" + value) 118 | .ifPresent(parts::add); 119 | 120 | identifier.getInstance() 121 | .map(value -> "instance=" + value) 122 | .ifPresent(parts::add); 123 | 124 | if (parts.isEmpty()) { 125 | return null; 126 | } 127 | 128 | return parts.stream() 129 | .collect(Collectors.joining(",", "[", "]")); 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/ServiceIdentifier.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static com.google.common.base.Preconditions.checkArgument; 4 | import static com.google.common.base.Preconditions.checkNotNull; 5 | import static com.google.common.base.Strings.isNullOrEmpty; 6 | 7 | import java.util.List; 8 | import java.util.Optional; 9 | import java.util.stream.Collectors; 10 | 11 | import com.google.common.collect.Lists; 12 | import org.apache.commons.lang3.builder.EqualsBuilder; 13 | import org.apache.commons.lang3.builder.HashCodeBuilder; 14 | 15 | public class ServiceIdentifier { 16 | 17 | private final String serviceName; 18 | private final Optional datacenter; 19 | private final Optional hostName; 20 | private final Optional instance; 21 | 22 | ServiceIdentifier(String serviceName, String datacenter, String hostName, String instance) { 23 | checkArgument(!isNullOrEmpty(serviceName), "You must specify a 'serviceName'!"); 24 | checkArgument(datacenter == null || !datacenter.isEmpty(), "You cannot specify 'datacenter' as empty String!"); 25 | checkArgument(hostName == null || !hostName.isEmpty(), "You cannot specify 'hostName' as empty String!"); 26 | checkArgument(instance == null || !instance.isEmpty(), "You cannot specify 'instance' as empty String!"); 27 | 28 | this.datacenter = Optional.ofNullable(datacenter); 29 | this.hostName = Optional.ofNullable(hostName); 30 | this.serviceName = serviceName; 31 | this.instance = Optional.ofNullable(instance); 32 | } 33 | 34 | public String getServiceName() { 35 | return serviceName; 36 | } 37 | 38 | public Optional getDatacenter() { 39 | return datacenter; 40 | } 41 | 42 | public Optional getHostName() { 43 | return hostName; 44 | } 45 | 46 | public Optional getInstance() { 47 | return instance; 48 | } 49 | 50 | public boolean appliesTo(ServiceIdentifier serviceIdentifier) { 51 | checkNotNull(serviceIdentifier, "You must specify a 'serviceIdentifier'!"); 52 | 53 | if (!getServiceName().equals(serviceIdentifier.getServiceName())) { 54 | return false; 55 | } 56 | else if (!matches(getDatacenter(), serviceIdentifier.getDatacenter())) { 57 | return false; 58 | } 59 | else if (!matches(getHostName(), serviceIdentifier.getHostName())) { 60 | return false; 61 | } 62 | else if (!matches(getInstance(), serviceIdentifier.getInstance())) { 63 | return false; 64 | } 65 | return true; 66 | } 67 | 68 | private boolean matches(Optional left, Optional right) { 69 | if (left.isPresent() && right.isPresent()) { 70 | // Both values are set, they must match. 71 | return left.get().equals(right.get()); 72 | } 73 | else if (left.isPresent()) { 74 | // Only the matching value is set, accept nothing. 75 | return false; 76 | } 77 | // Either the matching value is not set so accept everything. 78 | return true; 79 | } 80 | 81 | public boolean moreSpecificThan(ServiceIdentifier other) { 82 | checkNotNull(other, "You must specify a 'other'!"); 83 | 84 | if (!other.getInstance().isPresent() && getInstance().isPresent()) { 85 | return true; 86 | } 87 | else if (!other.getHostName().isPresent() && getHostName().isPresent()) { 88 | return true; 89 | } 90 | else if (!other.getDatacenter().isPresent() && getDatacenter().isPresent()) { 91 | return true; 92 | } 93 | return false; 94 | } 95 | 96 | @Override 97 | public boolean equals(Object other) { 98 | if (other instanceof ServiceIdentifier) { 99 | ServiceIdentifier id = (ServiceIdentifier) other; 100 | return new EqualsBuilder() 101 | .append(serviceName, id.serviceName) 102 | .append(datacenter, id.datacenter) 103 | .append(hostName, id.hostName) 104 | .append(instance, id.instance) 105 | .isEquals(); 106 | } 107 | return false; 108 | } 109 | 110 | @Override 111 | public int hashCode() { 112 | return new HashCodeBuilder() 113 | .append(serviceName) 114 | .append(datacenter) 115 | .append(hostName) 116 | .append(instance) 117 | .toHashCode(); 118 | } 119 | 120 | @Override 121 | public String toString() { 122 | List descriptors = Lists.newArrayList(); 123 | getDatacenter().ifPresent(dc -> descriptors.add("dc=" + dc)); 124 | getHostName().ifPresent(host -> descriptors.add("host=" + host)); 125 | getInstance().ifPresent(instance -> descriptors.add("instance=" + instance)); 126 | 127 | StringBuilder builder = new StringBuilder(serviceName); 128 | if (!descriptors.isEmpty()) { 129 | builder.append("["); 130 | builder.append(descriptors.stream().collect(Collectors.joining(","))); 131 | builder.append("]"); 132 | } 133 | return builder.toString(); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/ServiceLocator.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import java.util.Iterator; 4 | import java.util.Optional; 5 | import java.util.function.Consumer; 6 | import java.util.function.Function; 7 | import java.util.function.Supplier; 8 | 9 | /** 10 | * A class which implements an Iterator style pattern allowing the user of this class to fetch service instances in a 11 | * particular order one-by-one. 12 | */ 13 | public class ServiceLocator { 14 | 15 | private final Supplier> instanceSupplier; 16 | private final Supplier fallbackSupplier; 17 | 18 | private Iterator instances; 19 | private ServiceLocator fallback; 20 | 21 | private Consumer listener; 22 | 23 | /** 24 | * Creates a new ServiceLocator object returning only service instances which are generated by the 25 | * instanceSupplier. 26 | * 27 | * @param instanceSupplier A Supplier of a ServiceInstance Iterator returning service instances to emit. 28 | */ 29 | ServiceLocator(Supplier> instanceSupplier) { 30 | this(instanceSupplier, (ServiceLocator) null); 31 | } 32 | 33 | /** 34 | * Creates a new ServiceLocator object returning only service instances which are generated by the instanceSupplier 35 | * and then falls back onto another ServiceLocator for emitting service instances. 36 | * 37 | * @param instanceSupplier A Supplier of a ServiceInstance Iterator returning service instances to emit. 38 | * @param fallback The ServiceLocator to use as when all instances of the instanceSupplier were emitted. 39 | */ 40 | ServiceLocator(Supplier> instanceSupplier, ServiceLocator fallback) { 41 | this(instanceSupplier, () -> fallback); 42 | } 43 | 44 | /** 45 | * Creates a new ServiceLocator object returning only service instances which are generated by the instanceSupplier 46 | * and then falls back onto another ServiceLocator for emitting service instances. 47 | * 48 | * @param instanceSupplier A Supplier of a ServiceInstance Iterator returning service instances to emit. 49 | * @param fallbackSupplier The ServiceLocator to use as when all instances of the instanceSupplier were emitted. 50 | */ 51 | ServiceLocator(Supplier> instanceSupplier, 52 | Supplier fallbackSupplier) { 53 | this.instanceSupplier = instanceSupplier; 54 | this.fallbackSupplier = fallbackSupplier; 55 | } 56 | 57 | /** 58 | * Ensure that a certain callback is called when emitting a particular service instance. This can be used to keep 59 | * track of which service instances have been emitted in a RoutingStrategy implementation. 60 | * 61 | * @param listener The consumer of the service instance emit event. 62 | * 63 | * @return This ServiceLocator object. 64 | */ 65 | ServiceLocator setListener(Consumer listener) { 66 | this.listener = listener; 67 | if (fallback != null) { 68 | fallback.setListener(listener); 69 | } 70 | return this; 71 | } 72 | 73 | /** 74 | * Maps the batches of service instances emitted by this ServiceLocator, to a differently ordered batch of those 75 | * same service instances. This can be used to reorder the service instances, and ensure that with regards to the 76 | * client-side load balancing a different instance is used first. 77 | * 78 | * @param mapper Function which maps one ordered batch of instances to a differently ordered batch of those same 79 | * instances. 80 | * 81 | * @return The newly created ServiceLocator object. 82 | */ 83 | ServiceLocator map(Function, Iterator> mapper) { 84 | if (fallback != null) { 85 | return new ServiceLocator(() -> mapper.apply(instanceSupplier.get()), fallback.map(mapper)); 86 | } 87 | return new ServiceLocator(() -> mapper.apply(instanceSupplier.get())); 88 | } 89 | 90 | /** 91 | * @return The next ServiceInstance which can be used for client-side load balancing. Will return an empty 92 | * Optional in case no service instance is available, or all service instances have been fetched already. 93 | */ 94 | public Optional next() { 95 | if (instances == null) { 96 | instances = instanceSupplier.get(); 97 | } 98 | if (instances.hasNext()) { 99 | ServiceInstance instance = instances.next(); 100 | if (listener != null) { 101 | listener.accept(instance); 102 | } 103 | return Optional.of(instance); 104 | } 105 | 106 | if (fallback == null) { 107 | fallback = fallbackSupplier.get(); 108 | } 109 | if (fallback != null) { 110 | return fallback.next(); 111 | } 112 | 113 | return Optional.empty(); 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/ServiceIdentifierTest.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static org.junit.Assert.assertFalse; 4 | import static org.junit.Assert.assertTrue; 5 | 6 | import nl.jqno.equalsverifier.EqualsVerifier; 7 | import nl.jqno.equalsverifier.Warning; 8 | import org.junit.Test; 9 | 10 | public class ServiceIdentifierTest { 11 | 12 | @Test(expected = IllegalArgumentException.class) 13 | public void verifyThatConstructorThrowsExceptionWhenServiceNameIsNull() { 14 | new ServiceIdentifier(null, null, null, null); 15 | } 16 | 17 | @Test(expected = IllegalArgumentException.class) 18 | public void verifyThatConstructorThrowsExceptionWhenServiceNameIsEmpty() { 19 | new ServiceIdentifier("", null, null, null); 20 | } 21 | 22 | @Test(expected = IllegalArgumentException.class) 23 | public void verifyThatConstructorThrowsExceptionWhenDatacenterIsEmpty() { 24 | new ServiceIdentifier("oauth", "", null, null); 25 | } 26 | 27 | @Test(expected = IllegalArgumentException.class) 28 | public void verifyThatConstructorThrowsExceptionWhenHostIsEmpty() { 29 | new ServiceIdentifier("oauth", null, "", null); 30 | } 31 | 32 | @Test(expected = IllegalArgumentException.class) 33 | public void verifyThatConstructorThrowsExceptionWhenInstanceIsEmpty() { 34 | new ServiceIdentifier("oauth", null, null, ""); 35 | } 36 | 37 | @Test(expected = NullPointerException.class) 38 | public void verifyThatCallingMoreSpecificThanMethodThrowsExceptionOnNull() { 39 | new ServiceIdentifier("oauth", null, null, null).moreSpecificThan(null); 40 | } 41 | 42 | @Test 43 | public void verifyThatServiceNameOnlyIsLessSpecificThanServiceNameAndDC() { 44 | ServiceIdentifier id1 = new ServiceIdentifier("oauth", null, null, null); 45 | ServiceIdentifier id2 = new ServiceIdentifier("oauth", "eu-central", null, null); 46 | 47 | assertTrue(id2.moreSpecificThan(id1)); 48 | assertFalse(id1.moreSpecificThan(id2)); 49 | } 50 | 51 | @Test 52 | public void verifyThatServiceNameAndDCIsLessSpecificThanServiceNameDCAndHost() { 53 | ServiceIdentifier id1 = new ServiceIdentifier("oauth", "eu-central", null, null); 54 | ServiceIdentifier id2 = new ServiceIdentifier("oauth", "eu-central", "web-1", null); 55 | 56 | assertTrue(id2.moreSpecificThan(id1)); 57 | assertFalse(id1.moreSpecificThan(id2)); 58 | } 59 | 60 | @Test 61 | public void verifyThatServiceNameDCAndHostIsLessSpecificThanAllFieldsSet() { 62 | ServiceIdentifier id1 = new ServiceIdentifier("oauth", "eu-central", "web-1", null); 63 | ServiceIdentifier id2 = new ServiceIdentifier("oauth", "eu-central", "web-1", "master"); 64 | 65 | assertTrue(id2.moreSpecificThan(id1)); 66 | assertFalse(id1.moreSpecificThan(id2)); 67 | } 68 | 69 | @Test 70 | public void verifyThatSameServiceNameApplies() { 71 | ServiceIdentifier id1 = new ServiceIdentifier("oauth", null, null, null); 72 | ServiceIdentifier id2 = new ServiceIdentifier("oauth", "eu-central", "web-1", "master"); 73 | 74 | assertFalse(id2.appliesTo(id1)); 75 | assertTrue(id1.appliesTo(id2)); 76 | } 77 | 78 | @Test 79 | public void verifyThatDifferentServiceNameDoesNotApply() { 80 | ServiceIdentifier id1 = new ServiceIdentifier("logstash", null, null, null); 81 | ServiceIdentifier id2 = new ServiceIdentifier("oauth", "eu-central", "web-1", "master"); 82 | 83 | assertFalse(id2.appliesTo(id1)); 84 | assertFalse(id1.appliesTo(id2)); 85 | } 86 | 87 | @Test 88 | public void verifyThatSameDCApplies() { 89 | ServiceIdentifier id1 = new ServiceIdentifier("oauth", "eu-central", null, null); 90 | ServiceIdentifier id2 = new ServiceIdentifier("oauth", "eu-central", "web-1", "master"); 91 | 92 | assertFalse(id2.appliesTo(id1)); 93 | assertTrue(id1.appliesTo(id2)); 94 | } 95 | 96 | @Test 97 | public void verifyThatDifferentDCDoesNotApply() { 98 | ServiceIdentifier id1 = new ServiceIdentifier("oauth", "us-east", null, null); 99 | ServiceIdentifier id2 = new ServiceIdentifier("oauth", "eu-central", "web-1", "master"); 100 | 101 | assertFalse(id2.appliesTo(id1)); 102 | assertFalse(id1.appliesTo(id2)); 103 | } 104 | 105 | @Test 106 | public void verifyThatSameHostApplies() { 107 | ServiceIdentifier id1 = new ServiceIdentifier("oauth", null, "web-1", null); 108 | ServiceIdentifier id2 = new ServiceIdentifier("oauth", "eu-central", "web-1", "master"); 109 | 110 | assertFalse(id2.appliesTo(id1)); 111 | assertTrue(id1.appliesTo(id2)); 112 | } 113 | 114 | @Test 115 | public void verifyThatDifferentHostDoesNotApply() { 116 | ServiceIdentifier id1 = new ServiceIdentifier("oauth", null, "web-2", null); 117 | ServiceIdentifier id2 = new ServiceIdentifier("oauth", "eu-central", "web-1", "master"); 118 | 119 | assertFalse(id2.appliesTo(id1)); 120 | assertFalse(id1.appliesTo(id2)); 121 | } 122 | 123 | @Test 124 | public void verifyThatSameInstanceApplies() { 125 | ServiceIdentifier id1 = new ServiceIdentifier("oauth", null, null, "master"); 126 | ServiceIdentifier id2 = new ServiceIdentifier("oauth", "eu-central", "web-1", "master"); 127 | 128 | assertFalse(id2.appliesTo(id1)); 129 | assertTrue(id1.appliesTo(id2)); 130 | } 131 | 132 | @Test 133 | public void verifyThatDifferentInstanceDoesNotApply() { 134 | ServiceIdentifier id1 = new ServiceIdentifier("oauth", null, null, "slave"); 135 | ServiceIdentifier id2 = new ServiceIdentifier("oauth", "eu-central", "web-1", "master"); 136 | 137 | assertFalse(id2.appliesTo(id1)); 138 | assertFalse(id1.appliesTo(id2)); 139 | } 140 | 141 | @Test 142 | public void verifyEqualsMethod() { 143 | EqualsVerifier.forClass(ServiceIdentifier.class) 144 | .suppress(Warning.STRICT_INHERITANCE) 145 | .verify(); 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/RoutingStrategyTest.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static org.mockito.Matchers.anyString; 4 | import static org.mockito.Mockito.mock; 5 | import static org.mockito.Mockito.when; 6 | 7 | import java.util.List; 8 | import java.util.Optional; 9 | 10 | import com.google.common.collect.Lists; 11 | import org.junit.After; 12 | import org.junit.Before; 13 | 14 | public abstract class RoutingStrategyTest { 15 | 16 | protected static final String SERVICE_1 = "hello"; 17 | protected static final String SERVICE_2 = "world"; 18 | protected static final String SERVICE_3 = "foobar"; 19 | 20 | protected final RoutingStrategy strategy; 21 | 22 | protected ServiceInstanceBackend serviceInstanceBackend; 23 | 24 | protected ServiceInstance dc1node1service1; 25 | protected ServiceInstance dc1node2service1; 26 | protected ServiceInstance dc1node3service1; 27 | protected ServiceInstance dc2node1service1; 28 | 29 | protected ServiceInstance dc1node1service2; 30 | protected ServiceInstance dc1node2service2; 31 | protected ServiceInstance dc1node3service2; 32 | 33 | protected ServiceInstance dc1node1service3; 34 | protected ServiceInstance dc1node2service3; 35 | protected ServiceInstance dc1node3service3; 36 | 37 | public RoutingStrategyTest(RoutingStrategy routingStrategy) { 38 | this.strategy = routingStrategy; 39 | } 40 | 41 | @Before 42 | public void setUp() { 43 | Node dc1node1 = createNode("app1", "10.0.0.1"); 44 | Node dc1node2 = createNode("app2", "10.0.0.2"); 45 | Node dc1node3 = createNode("app3", "10.0.0.3"); 46 | Node dc2node1 = createNode("app1", "10.0.1.1"); 47 | 48 | Service service1 = createService(SERVICE_1); 49 | Service service2 = createService(SERVICE_2); 50 | Service service3 = createService(SERVICE_3); 51 | 52 | CheckStatus passing = new CheckStatus("Serf test", "All is OK", "passing"); 53 | List checks = Lists.newArrayList(passing); 54 | 55 | this.dc1node1service1 = new ServiceInstance(dc1node1, service1, checks); 56 | this.dc1node2service1 = new ServiceInstance(dc1node2, service1, checks); 57 | this.dc1node3service1 = new ServiceInstance(dc1node3, service1, checks); 58 | this.dc2node1service1 = new ServiceInstance(dc2node1, service1, checks); 59 | 60 | this.dc1node1service2 = new ServiceInstance(dc1node1, service2, checks); 61 | this.dc1node2service2 = new ServiceInstance(dc1node2, service2, checks); 62 | this.dc1node3service2 = new ServiceInstance(dc1node3, service2, checks); 63 | 64 | this.dc1node1service3 = new ServiceInstance(dc1node1, service3, checks); 65 | this.dc1node2service3 = new ServiceInstance(dc1node2, service3, checks); 66 | this.dc1node3service3 = new ServiceInstance(dc1node3, service3, checks); 67 | 68 | this.serviceInstanceBackend = mock(ServiceInstanceBackend.class); 69 | when(serviceInstanceBackend.listInstances(anyString())).thenAnswer(invocation -> { 70 | String serviceName = invocation.getArguments()[0].toString(); 71 | return getInstances(serviceName, "dc1"); 72 | }); 73 | 74 | when(serviceInstanceBackend.listInstances(anyString(), anyString())).thenAnswer(invocation -> { 75 | String serviceName = invocation.getArguments()[0].toString(); 76 | String dataCenter = invocation.getArguments()[1].toString(); 77 | return getInstances(serviceName, dataCenter); 78 | }); 79 | 80 | when(serviceInstanceBackend.getDatacenter()).thenReturn(Optional.of("dc1")); 81 | when(serviceInstanceBackend.listDatacenters()).thenReturn(Lists.newArrayList("dc1", "dc2")); 82 | } 83 | 84 | private List getInstances(String serviceName, String datacenter) { 85 | switch (serviceName) { 86 | case SERVICE_1: 87 | switch (datacenter) { 88 | case "dc1": 89 | return Lists.newArrayList(dc1node1service1, dc1node2service1, dc1node3service1); 90 | case "dc2": 91 | return Lists.newArrayList(dc2node1service1); 92 | } 93 | break; 94 | case SERVICE_2: 95 | switch (datacenter) { 96 | case "dc1": 97 | return Lists.newArrayList(dc1node1service2, dc1node2service2, dc1node3service2); 98 | case "dc2": 99 | return Lists.newArrayList(); 100 | } 101 | break; 102 | case SERVICE_3: 103 | switch (datacenter) { 104 | case "dc1": 105 | return Lists.newArrayList(dc1node1service3, dc1node2service3, dc1node3service3); 106 | case "dc2": 107 | return Lists.newArrayList(); 108 | } 109 | break; 110 | } 111 | throw new IllegalArgumentException("Invalid datacenter: " + datacenter); 112 | } 113 | 114 | @After 115 | public void tearDown() { 116 | strategy.reset(); 117 | } 118 | 119 | protected Node createNode(String node, String address) { 120 | Node result = mock(Node.class); 121 | when(result.getNode()).thenReturn(node); 122 | when(result.getAddress()).thenReturn(address); 123 | return result; 124 | } 125 | 126 | protected Service createService(String serviceName) { 127 | int port = serviceName.hashCode(); 128 | 129 | Service result = mock(Service.class); 130 | when(result.getId()).thenReturn(serviceName); 131 | when(result.getPort()).thenReturn(port); 132 | when(result.getService()).thenReturn(serviceName); 133 | when(result.getTags()).thenReturn(new String[0]); 134 | when(result.getAddress()).thenReturn("localhost:" + port); 135 | return result; 136 | } 137 | 138 | protected List iterate(ServiceLocator locations) { 139 | Optional instance; 140 | List instances = Lists.newArrayList(); 141 | while ((instance = locations.next()).isPresent()) { 142 | instances.add(instance.get()); 143 | } 144 | return instances; 145 | } 146 | 147 | } 148 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 4.0.0 7 | 8 | me.magnet 9 | consultant 10 | 2.0.14-SNAPSHOT 11 | jar 12 | 13 | consultant 14 | A library to retrieve your service's configuration from Consul, and subscribe to changes. 15 | https://github.com/Magnetme/consultant 16 | 17 | 18 | 1.8 19 | 1.8 20 | UTF-8 21 | UTF-8 22 | 23 | 24 | 25 | 26 | ossrh 27 | https://oss.sonatype.org/content/repositories/snapshots 28 | 29 | 30 | 31 | 32 | scm:git:git@github.com:Magnetme/consultant.git 33 | scm:git:git@github.com:Magnetme/consultant.git 34 | git@github.com:Magnetme/consultant.git 35 | 36 | 37 | 38 | 39 | Michael de Jong 40 | michael@magnet.me 41 | Magnet.me 42 | https://magnet.me 43 | 44 | 45 | 46 | 47 | 48 | The Apache License, Version 2.0 49 | http://www.apache.org/licenses/LICENSE-2.0.txt 50 | 51 | 52 | 53 | 54 | 55 | org.apache.httpcomponents 56 | httpclient 57 | 4.5.13 58 | 59 | 60 | com.fasterxml.jackson.core 61 | jackson-databind 62 | 2.16.1 63 | 64 | 65 | org.apache.commons 66 | commons-lang3 67 | 3.18.0 68 | 69 | 70 | com.google.guava 71 | guava 72 | 32.0.0-jre 73 | 74 | 75 | 76 | org.slf4j 77 | slf4j-api 78 | 2.0.9 79 | 80 | 81 | 82 | junit 83 | junit 84 | 4.13.1 85 | test 86 | 87 | 88 | org.mockito 89 | mockito-all 90 | 1.10.19 91 | test 92 | 93 | 94 | nl.jqno.equalsverifier 95 | equalsverifier 96 | 3.1.9 97 | test 98 | 99 | 100 | 101 | ch.qos.logback 102 | logback-classic 103 | 1.3.12 104 | test 105 | 106 | 107 | org.slf4j 108 | jcl-over-slf4j 109 | 2.0.9 110 | test 111 | 112 | 113 | org.slf4j 114 | jul-to-slf4j 115 | 2.0.9 116 | test 117 | 118 | 119 | 120 | 121 | 122 | 123 | org.apache.maven.plugins 124 | maven-source-plugin 125 | 3.1.0 126 | 127 | 128 | attach-sources 129 | 130 | jar-no-fork 131 | 132 | 133 | 134 | 135 | 136 | org.apache.maven.plugins 137 | maven-javadoc-plugin 138 | 3.1.1 139 | 140 | 141 | attach-javadocs 142 | 143 | jar 144 | 145 | 146 | 147 | 148 | 149 | org.apache.maven.plugins 150 | maven-gpg-plugin 151 | 1.6 152 | 153 | 154 | sign-artifacts 155 | verify 156 | 157 | sign 158 | 159 | 160 | 161 | 162 | 163 | org.sonatype.plugins 164 | nexus-staging-maven-plugin 165 | 1.6.13 166 | true 167 | 168 | ossrh 169 | https://oss.sonatype.org/ 170 | true 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | release 179 | 180 | 181 | 182 | org.apache.maven.plugins 183 | maven-gpg-plugin 184 | 1.6 185 | 186 | 187 | sign-artifacts 188 | verify 189 | 190 | sign 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/RoutingStrategies.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import java.util.Collections; 4 | import java.util.Comparator; 5 | import java.util.Iterator; 6 | import java.util.List; 7 | import java.util.Map; 8 | import java.util.NoSuchElementException; 9 | import java.util.Random; 10 | import java.util.Set; 11 | 12 | import com.google.common.collect.Lists; 13 | import com.google.common.collect.Maps; 14 | import com.google.common.collect.Sets; 15 | 16 | /** 17 | * A class which has a set of RoutingStrategies which can be used for client-side load balancing. 18 | */ 19 | public class RoutingStrategies { 20 | 21 | /** 22 | * A RoutingStrategy which returns service instances in order of network distance (order from nearest to farthest). 23 | */ 24 | public static final RoutingStrategy NETWORK_DISTANCE = (locator, serviceName) -> 25 | new ServiceLocator(() -> locator.listInstances(serviceName).iterator(), () -> { 26 | List datacenters = locator.listDatacenters(); 27 | Collections.reverse(datacenters); 28 | 29 | ServiceLocator current = null; 30 | 31 | for (String datacenter : datacenters) { 32 | boolean sameDatacenter = locator.getDatacenter() 33 | .map(datacenter::equals) 34 | .orElse(false); 35 | 36 | if (sameDatacenter) { 37 | continue; 38 | } 39 | 40 | if (current == null) { 41 | current = new ServiceLocator(() -> locator.listInstances(serviceName, datacenter).iterator()); 42 | } 43 | else { 44 | current = new ServiceLocator(() -> locator.listInstances(serviceName, datacenter).iterator(), 45 | current); 46 | } 47 | } 48 | 49 | return current; 50 | }); 51 | 52 | /** 53 | * Creates a new RoutingStrategy which returns service instances in a randomized order but prefers closer service 54 | * instances (in terms of network distance). The closest instance has a specified chance of being emitted first, 55 | * and if that chance is not met, the second closest has that same chance of being emitted, and if that chance is 56 | * not met... and so on. 57 | * 58 | * @param threshold The chance of emitting a particular service instance. 59 | * @return The RoutingStrategy with the specified chance. 60 | */ 61 | public static RoutingStrategy randomizedWeightedDistance(double threshold) { 62 | return new RoutingStrategy() { 63 | 64 | private final Random random = new Random(); 65 | 66 | @Override 67 | public ServiceLocator locateInstances(ServiceInstanceBackend serviceInstanceBackend, String serviceName) { 68 | return NETWORK_DISTANCE.locateInstances(serviceInstanceBackend, serviceName) 69 | .map(iterator -> { 70 | List instances = Lists.newArrayList(iterator); 71 | if (instances.size() <= 1) { 72 | // No routing to do. 73 | return instances.iterator(); 74 | } 75 | 76 | List reordered = Lists.newArrayList(); 77 | while (!instances.isEmpty()) { 78 | /* 79 | * Pick the index of the first instance to try based on a random chance (0..1 value). 80 | * If the chance was < 0.5 then use the instance on index 0. 81 | * If the chance was >= 0.5 and < 0.75 then use the instance on index 1. 82 | * If the chance was >= 0.75 and < 0.875 then use the instance on index 2. 83 | * If the chance was >= 0.875 and < 0.9375 then use the instance on index 3. 84 | * Etc... 85 | */ 86 | int index = 0; 87 | while (random.nextDouble() < threshold && index < instances.size() - 1) { 88 | index++; 89 | } 90 | 91 | reordered.add(instances.remove(index)); 92 | } 93 | 94 | return reordered.iterator(); 95 | }); 96 | } 97 | }; 98 | } 99 | 100 | /** 101 | * A RoutingStrategy which returns service instances in a randomized order of network distance (order from nearest 102 | * to farthest). Closer instances have a better chance at being emitted. 103 | */ 104 | public static final RoutingStrategy RANDOMIZED_WEIGHTED_DISTANCE = randomizedWeightedDistance(0.5); 105 | 106 | /** 107 | * A RoutingStrategy which returns service instances in a round robin manner. It ensures that any ServiceLocator 108 | * emitted through this RoutingStrategy doesn't emit the same instance at the same time in this JVM, but instead 109 | * emits a different not-yet emitted (for that particular ServiceLocator) service instance. 110 | */ 111 | public static final RoutingStrategy ROUND_ROBIN = new RoutingStrategy() { 112 | 113 | private final Map lastRequested = Maps.newConcurrentMap(); 114 | 115 | @Override 116 | public ServiceLocator locateInstances(ServiceInstanceBackend serviceInstanceBackend, String serviceName) { 117 | return NETWORK_DISTANCE.locateInstances(serviceInstanceBackend, serviceName) 118 | .map(iterator -> { 119 | // Ensure the instances are always sorted the same way. 120 | List instances = Lists.newArrayList(iterator); 121 | Comparator comparing = Comparator.comparing(entry -> entry.getNode().getNode()); 122 | comparing = comparing.thenComparing(instance -> instance.getService().getId()); 123 | Collections.sort(instances, comparing); 124 | 125 | Set attempted = Sets.newHashSet(); 126 | return new Iterator() { 127 | 128 | @Override 129 | public boolean hasNext() { 130 | return attempted.size() < instances.size(); 131 | } 132 | 133 | @Override 134 | public ServiceInstance next() { 135 | int start = 0; 136 | ServiceInstance lastInstanceUsed = lastRequested.get(serviceName); 137 | if (lastInstanceUsed != null) { 138 | start = instances.indexOf(lastInstanceUsed) + 1; 139 | } 140 | 141 | for (int i = start; i < start + instances.size(); i++) { 142 | ServiceInstance instance = instances.get(i % instances.size()); 143 | if (!attempted.add(instance)) { 144 | continue; 145 | } 146 | lastRequested.put(serviceName, instance); 147 | return instance; 148 | } 149 | throw new NoSuchElementException(); 150 | } 151 | 152 | }; 153 | }) 154 | .setListener(taken -> lastRequested.put(serviceName, taken)); 155 | } 156 | 157 | @Override 158 | public void reset() { 159 | lastRequested.clear(); 160 | } 161 | }; 162 | 163 | /** 164 | * A RoutingStrategy which emits the service instances in a random order. 165 | */ 166 | public static final RoutingStrategy RANDOMIZED = (serviceLocator, serviceName) -> 167 | NETWORK_DISTANCE.locateInstances(serviceLocator, serviceName) 168 | .map(iterator -> { 169 | List instances = Lists.newArrayList(iterator); 170 | Collections.shuffle(instances); 171 | return instances.iterator(); 172 | }); 173 | 174 | private RoutingStrategies() { 175 | // Prevent instantiation. 176 | } 177 | 178 | } 179 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/ConfigUpdater.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static me.magnet.consultant.Consultant.CONFIG_PREFIX; 4 | 5 | import java.io.IOException; 6 | import java.io.InputStream; 7 | import java.net.SocketException; 8 | import java.net.URI; 9 | import java.util.Base64; 10 | import java.util.List; 11 | import java.util.Map; 12 | import java.util.Optional; 13 | import java.util.Properties; 14 | import java.util.concurrent.ScheduledExecutorService; 15 | import java.util.concurrent.TimeUnit; 16 | import java.util.concurrent.atomic.AtomicBoolean; 17 | import java.util.concurrent.atomic.AtomicReference; 18 | import java.util.stream.Collectors; 19 | 20 | import com.fasterxml.jackson.core.type.TypeReference; 21 | import com.fasterxml.jackson.databind.ObjectMapper; 22 | import com.google.common.base.Strings; 23 | import com.google.common.collect.Maps; 24 | import org.apache.http.client.methods.CloseableHttpResponse; 25 | import org.apache.http.client.methods.HttpGet; 26 | import org.apache.http.impl.client.CloseableHttpClient; 27 | import org.apache.http.util.EntityUtils; 28 | import org.slf4j.Logger; 29 | import org.slf4j.LoggerFactory; 30 | 31 | class ConfigUpdater implements Runnable { 32 | 33 | private static class Setting { 34 | 35 | private final ServiceIdentifier identifier; 36 | private final String value; 37 | 38 | public Setting(ServiceIdentifier identifier, String value) { 39 | this.identifier = identifier; 40 | this.value = value; 41 | } 42 | 43 | public ServiceIdentifier getIdentifier() { 44 | return identifier; 45 | } 46 | 47 | public String getValue() { 48 | return value; 49 | } 50 | 51 | } 52 | 53 | private static final Logger log = LoggerFactory.getLogger(ConfigUpdater.class); 54 | 55 | private final CloseableHttpClient httpClient; 56 | private final ScheduledExecutorService executor; 57 | private final URI consulURI; 58 | private final String token; 59 | private final ServiceIdentifier identifier; 60 | private final ObjectMapper objectMapper; 61 | private final Properties config; 62 | private final ConfigListener listener; 63 | private final String kvPrefix; 64 | private final AtomicBoolean shutdownBegun = new AtomicBoolean(); 65 | private final AtomicReference request = new AtomicReference<>(); 66 | private String consulIndex; 67 | 68 | ConfigUpdater(ScheduledExecutorService executor, CloseableHttpClient httpClient, URI consulURI, String token, 69 | String consulIndex, ServiceIdentifier identifier, ObjectMapper objectMapper, 70 | Properties config, ConfigListener listener, String kvPrefix) { 71 | 72 | this.httpClient = httpClient; 73 | this.consulIndex = consulIndex; 74 | this.objectMapper = objectMapper; 75 | this.executor = executor; 76 | this.consulURI = consulURI; 77 | this.token = token; 78 | this.identifier = identifier; 79 | this.listener = listener; 80 | this.config = Optional.ofNullable(config).orElse(new Properties()); 81 | this.kvPrefix = Optional.ofNullable(kvPrefix).orElse(CONFIG_PREFIX); 82 | } 83 | 84 | @Override 85 | public void run() { 86 | if (shutdownBegun.get()) { 87 | log.info("Not retrieving new config since we're shutting down"); 88 | return; 89 | } 90 | long timeout = 500; 91 | try { 92 | String url = consulURI + "/v1/kv/" + kvPrefix + "/" + identifier.getServiceName() + "/?recurse=true"; 93 | if (consulIndex != null) { 94 | url += "&index=" + consulIndex; 95 | } 96 | 97 | request.set(new HttpGet(url) {{ 98 | if (!Strings.isNullOrEmpty(token)) { 99 | setHeader("X-Consul-Token", token); 100 | } 101 | }}); 102 | 103 | try (CloseableHttpResponse response = httpClient.execute(request.get())) { 104 | Properties newConfig = new Properties(); 105 | int status = response.getStatusLine().getStatusCode(); 106 | switch (status) { 107 | case 200: 108 | InputStream content = response.getEntity().getContent(); 109 | TypeReference> type = new TypeReference>() { 110 | }; 111 | List keys = objectMapper.readValue(content, type); 112 | newConfig = updateConfig(keys); 113 | onNewConfig(newConfig); 114 | 115 | consulIndex = response.getFirstHeader("X-Consul-Index").getValue(); 116 | break; 117 | case 404: // Not Found 118 | timeout = 5_000; 119 | onNewConfig(newConfig); 120 | break; 121 | case 204: // No Content 122 | case 504: // Gateway Timeout 123 | break; 124 | default: 125 | timeout = 60_000; 126 | String body = EntityUtils.toString(response.getEntity()); 127 | throw new RuntimeException("Failed to retrieve new config", new ConsulException(status, body)); 128 | } 129 | } 130 | } 131 | catch (IOException | RuntimeException e) { 132 | if (isShutdownException(e)) { 133 | return; 134 | } 135 | log.error("Error occurred while retrieving/publishing new config from Consul: " + e.getMessage(), e); 136 | } 137 | finally { 138 | if (!shutdownBegun.get()) { 139 | executor.schedule(this, timeout, TimeUnit.MILLISECONDS); 140 | } 141 | } 142 | } 143 | 144 | private boolean isShutdownException(Exception e) { 145 | return shutdownBegun.get() && (e instanceof SocketException || e instanceof InterruptedException); 146 | } 147 | 148 | private void onNewConfig(Properties newConfig) { 149 | if (!config.equals(newConfig)) { 150 | PropertiesUtil.sync(newConfig, config); 151 | log.debug("New config detected in Consul: \n{}", newConfig.entrySet().stream() 152 | .map(entry -> "\t" + entry.getKey() + ": " + entry.getValue()) 153 | .collect(Collectors.joining("\n"))); 154 | 155 | if (listener != null) { 156 | listener.onConfigUpdate(config); 157 | } 158 | } 159 | } 160 | 161 | private Properties updateConfig(List entries) { 162 | Map newConfig = Maps.newHashMap(); 163 | 164 | for (KeyValueEntry entry : entries) { 165 | Path path = PathParser.parse(kvPrefix, entry.getKey()); 166 | if (path == null || path.getKey() == null) { 167 | continue; 168 | } 169 | 170 | ServiceIdentifier id = path.getId(); 171 | if (id.appliesTo(identifier)) { 172 | String settingKey = path.getKey(); 173 | if (settingKey.isEmpty()) { 174 | continue; 175 | } 176 | 177 | Setting setting = newConfig.get(settingKey); 178 | if (setting == null || id.moreSpecificThan(setting.getIdentifier())) { 179 | String stringValue = Optional.ofNullable(entry.getValue()) 180 | .map(value -> new String(Base64.getDecoder().decode(value))) 181 | .orElse(null); 182 | newConfig.put(settingKey, new Setting(id, stringValue)); 183 | } 184 | } 185 | } 186 | 187 | Properties properties = new Properties(); 188 | newConfig.forEach((key, value) -> { 189 | if (value.getValue() == null) { 190 | properties.remove(key); 191 | } 192 | else { 193 | properties.setProperty(key, value.getValue()); 194 | } 195 | }); 196 | return properties; 197 | } 198 | 199 | /** 200 | * Shuts down any HTTP calls or scheduled calls to update the config. 201 | */ 202 | public void shutdown() { 203 | shutdownBegun.set(true); 204 | request.getAndUpdate(http -> { 205 | if (http != null) { 206 | try { 207 | request.get().abort(); 208 | } 209 | catch (RuntimeException e) { 210 | log.error("Could not abort request", e); 211 | } 212 | } 213 | return null; 214 | }); 215 | } 216 | 217 | } 218 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/ConfigUpdaterTest.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static me.magnet.consultant.HttpUtils.createStatus; 4 | import static me.magnet.consultant.HttpUtils.toJson; 5 | import static org.junit.Assert.assertEquals; 6 | import static org.mockito.Matchers.any; 7 | import static org.mockito.Matchers.eq; 8 | import static org.mockito.Mockito.mock; 9 | import static org.mockito.Mockito.spy; 10 | import static org.mockito.Mockito.times; 11 | import static org.mockito.Mockito.verify; 12 | import static org.mockito.Mockito.when; 13 | 14 | import java.util.Map; 15 | import java.util.Properties; 16 | import java.util.concurrent.CountDownLatch; 17 | import java.util.concurrent.ScheduledExecutorService; 18 | import java.util.concurrent.ScheduledThreadPoolExecutor; 19 | import java.util.concurrent.TimeUnit; 20 | import java.util.concurrent.TimeoutException; 21 | import java.util.concurrent.atomic.AtomicReference; 22 | 23 | import com.fasterxml.jackson.databind.ObjectMapper; 24 | import com.google.common.collect.ImmutableMap; 25 | import com.google.common.collect.Maps; 26 | import com.google.common.collect.Sets; 27 | import com.google.common.util.concurrent.SettableFuture; 28 | import org.apache.http.client.methods.CloseableHttpResponse; 29 | import org.apache.http.entity.StringEntity; 30 | import org.apache.http.impl.client.CloseableHttpClient; 31 | import org.apache.http.message.BasicHeader; 32 | import org.junit.After; 33 | import org.junit.Before; 34 | import org.junit.Test; 35 | 36 | 37 | public class ConfigUpdaterTest { 38 | 39 | private ScheduledExecutorService executor; 40 | private CloseableHttpClient http; 41 | private ObjectMapper objectMapper; 42 | private ServiceIdentifier id; 43 | 44 | @Before 45 | public void setUp() { 46 | this.executor = new ScheduledThreadPoolExecutor(1); 47 | this.http = mock(CloseableHttpClient.class); 48 | this.id = new ServiceIdentifier("oauth", null, null, null); 49 | this.objectMapper = new ObjectMapper(); 50 | } 51 | 52 | @After 53 | public void tearDown() { 54 | executor.shutdownNow(); 55 | } 56 | 57 | @Test(timeout = 5_000) 58 | public void verifyInitialConfigLoad() throws Exception { 59 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 60 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 61 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 62 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("some-prefix/oauth/some.key", "some-value"))); 63 | 64 | when(http.execute(any())).thenReturn(response); 65 | 66 | SettableFuture future = SettableFuture.create(); 67 | ConfigUpdater updater = new ConfigUpdater(executor, http, null, null, null, id, objectMapper, null, 68 | future::set, "some-prefix"); 69 | 70 | updater.run(); 71 | 72 | Properties properties = future.get(); 73 | assertEquals("some-value", properties.getProperty("some.key")); 74 | } 75 | 76 | @Test(timeout = 5_000) 77 | public void verifyConsecutiveConfigLoad() throws Exception { 78 | CloseableHttpResponse response1 = mock(CloseableHttpResponse.class); 79 | when(response1.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 80 | when(response1.getStatusLine()).thenReturn(createStatus(200, "OK")); 81 | when(response1.getEntity()).thenReturn(toJson(ImmutableMap.of("some-prefix/oauth/some.key", "some-value"))); 82 | 83 | CloseableHttpResponse response2 = mock(CloseableHttpResponse.class); 84 | when(response2.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1001")); 85 | when(response2.getStatusLine()).thenReturn(createStatus(200, "OK")); 86 | when(response2.getEntity()).thenReturn( 87 | toJson(ImmutableMap.of("some-prefix/oauth/some.key", "some-other-value"))); 88 | 89 | when(http.execute(any())).thenReturn(response1, response2); 90 | 91 | CountDownLatch latch = new CountDownLatch(2); 92 | AtomicReference properties = new AtomicReference<>(); 93 | 94 | ConfigUpdater updater = new ConfigUpdater(executor, http, null, null, null, id, objectMapper, null, 95 | config -> { 96 | latch.countDown(); 97 | properties.set(config); 98 | }, "some-prefix"); 99 | updater.run(); 100 | 101 | latch.await(); 102 | assertEquals("some-other-value", properties.get().getProperty("some.key")); 103 | } 104 | 105 | @Test(timeout = 10_000) 106 | public void verifyFolderIsIgnored() throws Exception { 107 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 108 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 109 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 110 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("some-prefix/oauth/", "some-value", 111 | "some-prefix/oauth/some.key", "some-value"))); 112 | 113 | when(http.execute(any())).thenReturn(response); 114 | 115 | SettableFuture future = SettableFuture.create(); 116 | ConfigUpdater updater = new ConfigUpdater(executor, http, null, null, null, id, objectMapper, null, 117 | future::set, "some-prefix"); 118 | 119 | updater.run(); 120 | 121 | Properties properties = future.get(); 122 | assertEquals(properties.keySet(), Sets.newHashSet("some.key")); 123 | } 124 | 125 | @Test(timeout = 5_000, expected = TimeoutException.class) 126 | public void verifyNoUpdateOnChangingDifferentKey() throws Exception { 127 | CloseableHttpResponse response1 = mock(CloseableHttpResponse.class); 128 | when(response1.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 129 | when(response1.getStatusLine()).thenReturn(createStatus(200, "OK")); 130 | when(response1.getEntity()).thenReturn(toJson(ImmutableMap.of("some-prefix/oauth/some.key", "some-value"))); 131 | 132 | when(http.execute(any())).thenReturn(response1); 133 | 134 | SettableFuture future = SettableFuture.create(); 135 | id = new ServiceIdentifier("database", null, null, null); 136 | ConfigUpdater updater = new ConfigUpdater(executor, http, null, null, null, id, objectMapper, null, 137 | future::set, "some-prefix"); 138 | 139 | updater.run(); 140 | 141 | future.get(2000, TimeUnit.MILLISECONDS); 142 | } 143 | 144 | @Test(timeout = 10_000) 145 | public void verify404ReschedulesAfter5Seconds() throws Exception { 146 | CloseableHttpResponse response1 = mock(CloseableHttpResponse.class); 147 | when(response1.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 148 | when(response1.getStatusLine()).thenReturn(createStatus(404, "Not Found")); 149 | when(response1.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/some.key", "some-value"))); 150 | 151 | when(http.execute(any())).thenReturn(response1); 152 | ScheduledExecutorService executorSpy = spy(executor); 153 | 154 | SettableFuture future = SettableFuture.create(); 155 | id = new ServiceIdentifier("oauth", null, null, null); 156 | ConfigUpdater updater = new ConfigUpdater(executor, http, null, null, null, id, objectMapper, null, 157 | future::set, "some-prefix"); 158 | 159 | updater.run(); 160 | 161 | Thread.sleep(5100); 162 | verify(executorSpy, times(2)); 163 | } 164 | 165 | @Test(timeout = 500_000) 166 | public void verifyThatUpdaterIsRescheduledAfterException() throws Exception { 167 | CloseableHttpResponse response1 = mock(CloseableHttpResponse.class); 168 | when(response1.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 169 | when(response1.getStatusLine()).thenReturn(createStatus(200, "OK")); 170 | when(response1.getEntity()).thenReturn(new StringEntity("{Some: \"weird object\"]")); 171 | 172 | when(http.execute(any())).thenReturn(response1); 173 | ScheduledExecutorService executorSpy = spy(executor); 174 | 175 | ConfigUpdater updater = new ConfigUpdater(executor, http, null, null, null, id, objectMapper, null, null, null); 176 | updater.run(); 177 | 178 | Thread.sleep(1100); 179 | verify(executorSpy, times(2)); 180 | } 181 | 182 | @Test(timeout = 10_000) 183 | public void verifyKeysWithNullValuesAreIgnored() throws Exception { 184 | Map entity = Maps.newHashMap(); 185 | entity.put("config/oauth/non-failing-key", "some-value"); // Just to ensure something valid is loaded. 186 | entity.put("config/oauth/failing-key", null); 187 | 188 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 189 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 190 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 191 | when(response.getEntity()).thenReturn(toJson(entity)); 192 | 193 | when(http.execute(any())).thenReturn(response); 194 | 195 | SettableFuture future = SettableFuture.create(); 196 | ConfigUpdater updater = new ConfigUpdater(executor, http, null, null, null, id, objectMapper, null, 197 | future::set, "config"); 198 | 199 | updater.run(); 200 | 201 | Properties properties = future.get(); 202 | assertEquals(properties.keySet(), Sets.newHashSet("non-failing-key")); 203 | } 204 | 205 | } 206 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/ServiceInstanceBackend.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import java.io.IOException; 4 | import java.io.InputStream; 5 | import java.net.URI; 6 | import java.util.List; 7 | import java.util.Optional; 8 | import java.util.concurrent.ExecutionException; 9 | import java.util.concurrent.TimeUnit; 10 | import java.util.stream.Collectors; 11 | 12 | import com.fasterxml.jackson.core.type.TypeReference; 13 | import com.fasterxml.jackson.databind.ObjectMapper; 14 | import com.google.common.base.Strings; 15 | import com.google.common.base.Supplier; 16 | import com.google.common.base.Suppliers; 17 | import com.google.common.cache.CacheBuilder; 18 | import com.google.common.cache.CacheLoader; 19 | import com.google.common.cache.LoadingCache; 20 | import org.apache.commons.lang3.builder.EqualsBuilder; 21 | import org.apache.commons.lang3.builder.HashCodeBuilder; 22 | import org.apache.http.client.methods.CloseableHttpResponse; 23 | import org.apache.http.client.methods.HttpGet; 24 | import org.apache.http.impl.client.CloseableHttpClient; 25 | import org.apache.http.util.EntityUtils; 26 | import org.slf4j.Logger; 27 | import org.slf4j.LoggerFactory; 28 | 29 | /** 30 | * A class which can be used to retrieve lists of instances or datacenters from Consul over its HTTP API. 31 | */ 32 | public class ServiceInstanceBackend { 33 | 34 | private static class ServiceIdentifierCacheKey { 35 | 36 | private final String datacenter; 37 | private final String serviceName; 38 | 39 | private ServiceIdentifierCacheKey(String datacenter, String serviceName) { 40 | this.datacenter = datacenter; 41 | this.serviceName = serviceName; 42 | } 43 | 44 | public String getDatacenter() { 45 | return datacenter; 46 | } 47 | 48 | public String getServiceName() { 49 | return serviceName; 50 | } 51 | 52 | public boolean equals(Object other) { 53 | if (other instanceof ServiceIdentifierCacheKey) { 54 | ServiceIdentifierCacheKey otherKey = (ServiceIdentifierCacheKey) other; 55 | return new EqualsBuilder() 56 | .append(datacenter, otherKey.datacenter) 57 | .append(serviceName, otherKey.serviceName) 58 | .isEquals(); 59 | } 60 | return false; 61 | } 62 | 63 | public int hashCode() { 64 | return new HashCodeBuilder() 65 | .append(datacenter) 66 | .append(serviceName) 67 | .toHashCode(); 68 | } 69 | 70 | public String toString() { 71 | if (datacenter != null) { 72 | return datacenter + "-" + serviceName; 73 | } 74 | return serviceName; 75 | } 76 | 77 | } 78 | 79 | private static final Logger log = LoggerFactory.getLogger(ConfigUpdater.class); 80 | 81 | private static final TypeReference> TYPES = new TypeReference>() {}; 82 | 83 | private final Optional datacenter; 84 | private final LoadingCache> serviceInstances; 85 | private final Supplier> datacenters; 86 | 87 | 88 | /** 89 | * Constructs a new ServiceInstanceBackend object. 90 | * 91 | * @param datacenter The datacenter as it is defined in the ServiceIdentifier. 92 | * @param consulUri The URI where Consul's API can be found. 93 | * @param token An optional token to be used to authenticate requests directed at Consul's API. 94 | * @param objectMapper The ObjectMapper which can be used to deserialize JSON. 95 | * @param http The HTTP client to use. 96 | * @param cacheLocateCallsForMillis How long the results of locate calls should be cached for. 97 | */ 98 | ServiceInstanceBackend(Optional datacenter, URI consulUri, String token, ObjectMapper objectMapper, 99 | CloseableHttpClient http, long cacheLocateCallsForMillis) { 100 | 101 | this.datacenter = datacenter; 102 | 103 | this.serviceInstances = CacheBuilder.newBuilder() 104 | .expireAfterWrite(cacheLocateCallsForMillis, TimeUnit.MILLISECONDS) 105 | .build(CacheLoader.from(key -> { 106 | String url = consulUri + "/v1/health/service/" + key.getServiceName() + "?near=_agent"; 107 | if (!Strings.isNullOrEmpty(key.getDatacenter())) { 108 | url += "&dc=" + key.getDatacenter(); 109 | } 110 | 111 | HttpGet request = new HttpGet(url); 112 | request.setHeader("User-Agent", "Consultant"); 113 | if (!Strings.isNullOrEmpty(token)) { 114 | request.setHeader("X-Consul-Token", token); 115 | } 116 | 117 | try (CloseableHttpResponse response = http.execute(request)) { 118 | int statusCode = response.getStatusLine().getStatusCode(); 119 | if (statusCode >= 200 && statusCode < 400) { 120 | InputStream content = response.getEntity().getContent(); 121 | List allInstances = objectMapper.readValue(content, TYPES); 122 | 123 | List passingInstances = allInstances.stream() 124 | .filter(instance -> instance.getChecks().stream() 125 | .allMatch(checkStatus -> "passing".equals(checkStatus.getStatus()))) 126 | .collect(Collectors.toList()); 127 | 128 | /* 129 | * If there are known instances matching the specified service name, but they all have at 130 | * least one failing health check (making them unavailable), log this so it's obvious to 131 | * whoever is debugging such issues. 132 | */ 133 | if (passingInstances.isEmpty() && !allInstances.isEmpty()) { 134 | StringBuilder builder = new StringBuilder(); 135 | builder.append("None of the known instances are passing all of their checks: \n"); 136 | 137 | for (ServiceInstance instance : allInstances) { 138 | String name = instance.getService().getService(); 139 | String nodeName = instance.getNode().getNode(); 140 | 141 | builder.append("\tService \"") 142 | .append(name) 143 | .append("\" on node \"") 144 | .append(nodeName) 145 | .append("\":\n"); 146 | 147 | for (CheckStatus checkStatus : instance.getChecks()) { 148 | builder.append("\t\t- Check \"") 149 | .append(checkStatus.getName()) 150 | .append("\" has status \"") 151 | .append(checkStatus.getStatus()) 152 | .append("\" with output: ") 153 | .append(checkStatus.getOutput()) 154 | .append("\n"); 155 | } 156 | } 157 | 158 | log.warn(builder.toString()); 159 | } 160 | 161 | return passingInstances; 162 | } 163 | 164 | String body = EntityUtils.toString(response.getEntity()); 165 | throw new ConsultantException("Could not locate service: " + key.getServiceName(), 166 | new ConsulException(statusCode, body)); 167 | } 168 | catch (IOException | RuntimeException e) { 169 | throw new ConsultantException(e); 170 | } 171 | })); 172 | 173 | this.datacenters = Suppliers.memoizeWithExpiration(() -> { 174 | String url = consulUri + "/v1/catalog/datacenters"; 175 | 176 | HttpGet request = new HttpGet(url); 177 | request.setHeader("User-Agent", "Consultant"); 178 | if (!Strings.isNullOrEmpty(token)) { 179 | request.setHeader("X-Consul-Token", token); 180 | } 181 | 182 | try (CloseableHttpResponse response = http.execute(request)) { 183 | int statusCode = response.getStatusLine().getStatusCode(); 184 | if (statusCode >= 200 && statusCode < 400) { 185 | InputStream content = response.getEntity().getContent(); 186 | return objectMapper.readValue(content, new TypeReference>() { 187 | }); 188 | } 189 | String body = EntityUtils.toString(response.getEntity()); 190 | throw new ConsultantException("Could not locate datacenters", 191 | new ConsulException(statusCode, body)); 192 | } 193 | catch (IOException | RuntimeException e) { 194 | throw new ConsultantException(e); 195 | } 196 | }, cacheLocateCallsForMillis, TimeUnit.MILLISECONDS); 197 | 198 | } 199 | 200 | /** 201 | * @return The name of the local datacenter. 202 | */ 203 | public Optional getDatacenter() { 204 | return datacenter; 205 | } 206 | 207 | /** 208 | * Retrieves a list of service instances from Consul ordered by network distance (nearest to farthest) in the 209 | * local datacenter. 210 | * 211 | * @param serviceName The name of the service to list. 212 | * @return A List of service instances located in the local datacenter. 213 | */ 214 | public List listInstances(String serviceName) { 215 | return listInstances(serviceName, null); 216 | } 217 | 218 | /** 219 | * Retrieves a list of service instances from Consul ordered by network distance (nearest to farthest) in the 220 | * specified datacenter. 221 | * 222 | * @param serviceName The name of the service to list. 223 | * @param datacenter The name of the datacenter to search. 224 | * @return A List of service instances located in the specified datacenter. 225 | */ 226 | public List listInstances(String serviceName, String datacenter) { 227 | try { 228 | return serviceInstances.get(new ServiceIdentifierCacheKey(datacenter, serviceName)); 229 | } 230 | catch (ExecutionException e) { 231 | if (e.getCause() instanceof RuntimeException) { 232 | throw (RuntimeException) e.getCause(); 233 | } 234 | throw new RuntimeException(e.getCause()); 235 | } 236 | } 237 | 238 | /** 239 | * @return A list of datacenters as registered in Consul. 240 | */ 241 | public List listDatacenters() { 242 | return datacenters.get(); 243 | } 244 | 245 | } 246 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /src/test/java/me/magnet/consultant/ConsultantTest.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static me.magnet.consultant.HttpUtils.createStatus; 4 | import static me.magnet.consultant.HttpUtils.toJson; 5 | import static org.junit.Assert.assertEquals; 6 | import static org.junit.Assert.assertFalse; 7 | import static org.junit.Assert.assertTrue; 8 | import static org.mockito.Matchers.eq; 9 | import static org.mockito.Mockito.mock; 10 | import static org.mockito.Mockito.when; 11 | 12 | import java.io.IOException; 13 | import java.util.Properties; 14 | import java.util.concurrent.CountDownLatch; 15 | import java.util.concurrent.TimeUnit; 16 | import java.util.concurrent.TimeoutException; 17 | 18 | import com.google.common.collect.ImmutableMap; 19 | import com.google.common.util.concurrent.SettableFuture; 20 | import me.magnet.consultant.Consultant.Builder.Agent; 21 | import me.magnet.consultant.Consultant.Builder.Config; 22 | import org.apache.commons.lang3.tuple.Pair; 23 | import org.apache.commons.lang3.tuple.Triple; 24 | import org.apache.http.client.methods.CloseableHttpResponse; 25 | import org.apache.http.message.BasicHeader; 26 | import org.junit.After; 27 | import org.junit.Before; 28 | import org.junit.Test; 29 | 30 | public class ConsultantTest { 31 | 32 | private Consultant consultant; 33 | private MockedHttpClientBuilder httpBuilder; 34 | 35 | @Before 36 | public void setUp() throws IOException { 37 | httpBuilder = prepareHttpClient(); 38 | } 39 | 40 | @After 41 | public void tearDown() throws InterruptedException { 42 | if (consultant != null) { 43 | consultant.shutdown(); 44 | } 45 | } 46 | 47 | @Test(timeout = 5_000) 48 | public void verifyInitialConfigLoad() throws Exception { 49 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true", request -> { 50 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 51 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 52 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 53 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/some.key", "some-value"))); 54 | return response; 55 | }); 56 | 57 | SettableFuture future = SettableFuture.create(); 58 | 59 | consultant = Consultant.builder() 60 | .usingHttpClient(httpBuilder.create()) 61 | .withConsulHost("http://localhost") 62 | .identifyAs("oauth", "eu-central", "web-1", "master") 63 | .onValidConfig(future::set) 64 | .build(); 65 | 66 | Properties properties = future.get(); 67 | assertEquals("some-value", properties.getProperty("some.key")); 68 | } 69 | 70 | @Test(timeout = 5_000, expected = TimeoutException.class) 71 | public void verifyThatInvalidConfigIsNotPublished() throws Exception { 72 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true", request -> { 73 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 74 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 75 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 76 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/some.key", "some-value"))); 77 | return response; 78 | }); 79 | 80 | SettableFuture future = SettableFuture.create(); 81 | 82 | consultant = Consultant.builder() 83 | .usingHttpClient(httpBuilder.create()) 84 | .withConsulHost("http://localhost") 85 | .identifyAs("oauth", "eu-central", "web-1", "master") 86 | .onValidConfig(future::set) 87 | .pullConfigFromConsul(false) 88 | .validateConfigWith((config) -> { 89 | throw new IllegalArgumentException("Config is invalid"); 90 | }) 91 | .build(); 92 | 93 | future.get(2_000, TimeUnit.MILLISECONDS); 94 | } 95 | 96 | @Test(timeout = 5_000) 97 | public void verifyThatValidConfigIsPublished() throws Exception { 98 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true", request -> { 99 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 100 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 101 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 102 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/some.key", "some-value"))); 103 | return response; 104 | }); 105 | 106 | SettableFuture future = SettableFuture.create(); 107 | 108 | consultant = Consultant.builder() 109 | .usingHttpClient(httpBuilder.create()) 110 | .withConsulHost("http://localhost") 111 | .identifyAs("oauth", "eu-central", "web-1", "master") 112 | .onValidConfig(future::set) 113 | .build(); 114 | 115 | Properties expected = new Properties(); 116 | expected.setProperty("some.key", "some-value"); 117 | assertEquals(expected, future.get(2_000, TimeUnit.MILLISECONDS)); 118 | } 119 | 120 | @Test(timeout = 5_000) 121 | public void verifyThatNewSettingsArePublishedThroughSettingsListener() throws Exception { 122 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true", request -> { 123 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 124 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 125 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 126 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/some.key", "some-value"))); 127 | return response; 128 | }); 129 | 130 | SettableFuture> future = SettableFuture.create(); 131 | 132 | consultant = Consultant.builder() 133 | .usingHttpClient(httpBuilder.create()) 134 | .withConsulHost("http://localhost") 135 | .identifyAs("oauth", "eu-central", "web-1", "master") 136 | .onSettingUpdate("some.key", (key, oldValue, newValue) -> { 137 | future.set(Triple.of(key, oldValue, newValue)); 138 | }) 139 | .build(); 140 | 141 | Triple expected = Triple.of("some.key", null, "some-value"); 142 | assertEquals(expected, future.get(2_000, TimeUnit.MILLISECONDS)); 143 | } 144 | 145 | @Test(timeout = 5_000) 146 | public void verifyThatModifiedSettingsArePublishedThroughSettingsListener() throws Exception { 147 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true", request -> { 148 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 149 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 150 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 151 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/some.key", "some-value"))); 152 | return response; 153 | }); 154 | 155 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true&index=1000", request -> { 156 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 157 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1001")); 158 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 159 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/some.key", "some-other-value"))); 160 | return response; 161 | }); 162 | 163 | SettableFuture> future = SettableFuture.create(); 164 | 165 | consultant = Consultant.builder() 166 | .usingHttpClient(httpBuilder.create()) 167 | .withConsulHost("http://localhost") 168 | .identifyAs("oauth", "eu-central", "web-1", "master") 169 | .onSettingUpdate("some.key", (key, oldValue, newValue) -> { 170 | if (oldValue != null) { 171 | future.set(Triple.of(key, oldValue, newValue)); 172 | } 173 | }) 174 | .build(); 175 | 176 | Triple expected = Triple.of("some.key", "some-value", "some-other-value"); 177 | assertEquals(expected, future.get(2_000, TimeUnit.MILLISECONDS)); 178 | } 179 | 180 | @Test(timeout = 5_000) 181 | public void verifyThatDeletedSettingsArePublishedThroughSettingsListener() throws Exception { 182 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true", request -> { 183 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 184 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 185 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 186 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/some.key", "some-value"))); 187 | return response; 188 | }); 189 | 190 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true&index=1000", request -> { 191 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 192 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1001")); 193 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 194 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of())); 195 | return response; 196 | }); 197 | 198 | SettableFuture> future = SettableFuture.create(); 199 | 200 | consultant = Consultant.builder() 201 | .usingHttpClient(httpBuilder.create()) 202 | .withConsulHost("http://localhost") 203 | .identifyAs("oauth", "eu-central", "web-1", "master") 204 | .onSettingUpdate("some.key", (key, oldValue, newValue) -> { 205 | if (newValue == null) { 206 | future.set(Pair.of(key, oldValue)); 207 | } 208 | }) 209 | .build(); 210 | 211 | Pair expected = Pair.of("some.key", "some-value"); 212 | assertEquals(expected, future.get(2_000, TimeUnit.MILLISECONDS)); 213 | } 214 | 215 | @Test(timeout = 5_000) 216 | public void verifyPropertiesObjectIsUpdatedOnNewConfig() throws Exception { 217 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true", request -> { 218 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 219 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 220 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 221 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/some.key", "some-value"))); 222 | return response; 223 | }); 224 | 225 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true&index=1000", request -> { 226 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 227 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1001")); 228 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 229 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/some.key", "some-other-value"))); 230 | return response; 231 | }); 232 | 233 | CountDownLatch latch = new CountDownLatch(2); 234 | 235 | consultant = Consultant.builder() 236 | .usingHttpClient(httpBuilder.create()) 237 | .withConsulHost("http://localhost") 238 | .identifyAs("oauth", "eu-central", "web-1", "master") 239 | .onValidConfig((config) -> latch.countDown()) 240 | .build(); 241 | 242 | Properties properties = consultant.getProperties(); 243 | 244 | latch.await(); 245 | assertEquals("some-other-value", properties.getProperty("some.key")); 246 | } 247 | 248 | @Test 249 | public void verifyPropertiesCanBeSetAsEnvironment() throws Exception { 250 | System.setProperty("CONSUL_HOST", "http://localhost"); 251 | System.setProperty("SERVICE_NAME", "oauth"); 252 | System.setProperty("SERVICE_DC", "eu-central"); 253 | System.setProperty("SERVICE_HOST", "web-1"); 254 | System.setProperty("SERVICE_INSTANCE", "master"); 255 | 256 | consultant = Consultant.builder() 257 | .usingHttpClient(httpBuilder.create()) 258 | .pullConfigFromConsul(false) 259 | .build(); 260 | 261 | ServiceIdentifier id = new ServiceIdentifier("oauth", "eu-central", "web-1", "master"); 262 | assertEquals(id, consultant.getServiceIdentifier()); 263 | } 264 | 265 | @Test 266 | public void verifyConsulHostDefaultsToPort8500() throws Exception { 267 | consultant = Consultant.builder() 268 | .identifyAs("oauth") 269 | .usingHttpClient(httpBuilder.create()) 270 | .withConsulHost("localhost") 271 | .pullConfigFromConsul(false) 272 | .build(); 273 | 274 | assertEquals("http://localhost:8500", consultant.getConsulHost()); 275 | } 276 | 277 | @Test 278 | public void verifyConsulHostDefaultsToHttp() throws Exception { 279 | consultant = Consultant.builder() 280 | .identifyAs("oauth") 281 | .usingHttpClient(httpBuilder.create()) 282 | .withConsulHost("localhost:8501") 283 | .pullConfigFromConsul(false) 284 | .build(); 285 | 286 | assertEquals("http://localhost:8501", consultant.getConsulHost()); 287 | } 288 | 289 | @Test 290 | public void verifyConsulHostWithSchemeDoesNotDefaultToDifferentPort() throws Exception { 291 | consultant = Consultant.builder() 292 | .identifyAs("oauth") 293 | .usingHttpClient(httpBuilder.create()) 294 | .withConsulHost("http://localhost") 295 | .pullConfigFromConsul(false) 296 | .build(); 297 | 298 | assertEquals("http://localhost", consultant.getConsulHost()); 299 | } 300 | 301 | @Test 302 | public void verifyConfigListenerCanBeRemoved() throws Exception { 303 | ConfigListener listener = System.out::println; 304 | consultant = Consultant.builder() 305 | .usingHttpClient(httpBuilder.create()) 306 | .withConsulHost("http://localhost") 307 | .identifyAs("oauth") 308 | .pullConfigFromConsul(false) 309 | .onValidConfig(listener) 310 | .build(); 311 | 312 | assertTrue(consultant.removeConfigListener(listener)); 313 | assertFalse(consultant.removeConfigListener(listener)); 314 | } 315 | 316 | @Test 317 | public void verifySettingListenerCanBeRemoved() throws Exception { 318 | SettingListener listener = (name, oldValue, newValue) -> {}; 319 | consultant = Consultant.builder() 320 | .usingHttpClient(httpBuilder.create()) 321 | .withConsulHost("http://localhost") 322 | .identifyAs("oauth") 323 | .onSettingUpdate("some-key", listener) 324 | .pullConfigFromConsul(false) 325 | .build(); 326 | 327 | assertTrue(consultant.removeSettingListener("some-key", listener)); 328 | assertFalse(consultant.removeSettingListener("some-key", listener)); 329 | } 330 | 331 | @Test(timeout = 5_000) 332 | public void verifyThatIrrelevantOverridesAreIgnored() throws Exception { 333 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true", request -> { 334 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 335 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 336 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 337 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/[dc=test].some.key", "some-value"))); 338 | return response; 339 | }); 340 | 341 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true&index=1000", request -> { 342 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 343 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1001")); 344 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 345 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/some.key", "some-other-value"))); 346 | return response; 347 | }); 348 | 349 | SettableFuture> future = SettableFuture.create(); 350 | 351 | consultant = Consultant.builder() 352 | .usingHttpClient(httpBuilder.create()) 353 | .withConsulHost("http://localhost") 354 | .identifyAs("oauth") 355 | .onSettingUpdate("some.key", (key, oldValue, newValue) -> future.set(Pair.of(key, newValue))) 356 | .build(); 357 | 358 | Pair expected = Pair.of("some.key", "some-other-value"); 359 | assertEquals(expected, future.get(2_000, TimeUnit.MILLISECONDS)); 360 | } 361 | 362 | @Test(timeout = 5_000) 363 | public void verifyThatRelevantOverridesAreProcessed() throws Exception { 364 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true", request -> { 365 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 366 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 367 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 368 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/some.key", "some-value"))); 369 | return response; 370 | }); 371 | 372 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true&index=1000", request -> { 373 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 374 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1001")); 375 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 376 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/[dc=eu-central].some.key", "some-other-value"))); 377 | return response; 378 | }); 379 | 380 | SettableFuture> future = SettableFuture.create(); 381 | 382 | consultant = Consultant.builder() 383 | .usingHttpClient(httpBuilder.create()) 384 | .withConsulHost("http://localhost") 385 | .identifyAs("oauth", "eu-central") 386 | .onSettingUpdate("some.key", (key, oldValue, newValue) -> { 387 | if (oldValue != null) { 388 | future.set(Pair.of(key, newValue)); 389 | } 390 | }) 391 | .build(); 392 | 393 | Pair expected = Pair.of("some.key", "some-other-value"); 394 | assertEquals(expected, future.get(2_000, TimeUnit.MILLISECONDS)); 395 | } 396 | 397 | @Test(timeout = 5_000) 398 | public void verifyThatSpecifyingNoConfigPullDoesNotUpdateConfig() throws Exception { 399 | httpBuilder.onGet("/v1/kv/config/oauth/?recurse=true", request -> { 400 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 401 | when(response.getFirstHeader(eq("X-Consul-Index"))).thenReturn(new BasicHeader("X-Consul-Index", "1000")); 402 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 403 | when(response.getEntity()).thenReturn(toJson(ImmutableMap.of("config/oauth/some.key", "some-value"))); 404 | return response; 405 | }); 406 | 407 | CountDownLatch latch = new CountDownLatch(1); 408 | 409 | consultant = Consultant.builder() 410 | .usingHttpClient(httpBuilder.create()) 411 | .pullConfigFromConsul(false) 412 | .withConsulHost("http://localhost") 413 | .identifyAs("oauth", "eu-central") 414 | .onSettingUpdate("some.key", (key, oldValue, newValue) -> { 415 | latch.countDown(); 416 | }) 417 | .build(); 418 | 419 | assertFalse(latch.await(4, TimeUnit.SECONDS)); 420 | } 421 | 422 | @Test(timeout = 5_000) 423 | public void testSpecifyingCustomProperties() throws Exception { 424 | Properties properties = new Properties(); 425 | properties.setProperty("some.key", "some-value"); 426 | 427 | consultant = Consultant.builder() 428 | .usingHttpClient(httpBuilder.create()) 429 | .pullConfigFromConsul(false) 430 | .startWith(properties) 431 | .withConsulHost("http://localhost") 432 | .identifyAs("oauth", "eu-central") 433 | .build(); 434 | 435 | Properties expected = new Properties(); 436 | expected.setProperty("some.key", "some-value"); 437 | 438 | assertEquals(expected, consultant.getProperties()); 439 | } 440 | 441 | @Test(expected = IllegalArgumentException.class) 442 | public void verifyThatSpecifyingNullForCustomPropertiesThrowsException() throws Exception { 443 | consultant = Consultant.builder() 444 | .usingHttpClient(httpBuilder.create()) 445 | .pullConfigFromConsul(false) 446 | .startWith(null) 447 | .withConsulHost("http://localhost") 448 | .identifyAs("oauth", "eu-central") 449 | .build(); 450 | } 451 | 452 | private MockedHttpClientBuilder prepareHttpClient() throws IOException { 453 | return new MockedHttpClientBuilder() 454 | .onGet("/v1/agent/self", request -> { 455 | CloseableHttpResponse response = mock(CloseableHttpResponse.class); 456 | when(response.getStatusLine()).thenReturn(createStatus(200, "OK")); 457 | 458 | Config config = new Config(); 459 | config.setDatacenter("eu-central"); 460 | config.setNodeName("app1"); 461 | 462 | Agent agent = new Agent(); 463 | agent.setConfig(config); 464 | 465 | when(response.getEntity()).thenReturn(toJson(agent)); 466 | return response; 467 | }); 468 | } 469 | 470 | } 471 | -------------------------------------------------------------------------------- /src/main/java/me/magnet/consultant/Consultant.java: -------------------------------------------------------------------------------- 1 | package me.magnet.consultant; 2 | 3 | import static com.google.common.base.Preconditions.checkArgument; 4 | import static com.google.common.base.Strings.isNullOrEmpty; 5 | 6 | import java.io.IOException; 7 | import java.net.InetSocketAddress; 8 | import java.net.URI; 9 | import java.net.URISyntaxException; 10 | import java.util.Collection; 11 | import java.util.List; 12 | import java.util.Map; 13 | import java.util.Map.Entry; 14 | import java.util.Objects; 15 | import java.util.Optional; 16 | import java.util.Properties; 17 | import java.util.Set; 18 | import java.util.UUID; 19 | import java.util.concurrent.ExecutionException; 20 | import java.util.concurrent.ScheduledExecutorService; 21 | import java.util.concurrent.ScheduledThreadPoolExecutor; 22 | import java.util.concurrent.TimeUnit; 23 | import java.util.concurrent.atomic.AtomicBoolean; 24 | 25 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 26 | import com.fasterxml.jackson.annotation.JsonProperty; 27 | import com.fasterxml.jackson.databind.ObjectMapper; 28 | import com.google.common.annotations.VisibleForTesting; 29 | import com.google.common.base.Strings; 30 | import com.google.common.collect.HashMultimap; 31 | import com.google.common.collect.Lists; 32 | import com.google.common.collect.Multimap; 33 | import com.google.common.collect.Multimaps; 34 | import com.google.common.collect.SetMultimap; 35 | import com.google.common.collect.Sets; 36 | import org.apache.commons.lang3.tuple.Pair; 37 | import org.apache.http.HttpEntity; 38 | import org.apache.http.client.methods.CloseableHttpResponse; 39 | import org.apache.http.client.methods.HttpGet; 40 | import org.apache.http.client.methods.HttpPut; 41 | import org.apache.http.entity.StringEntity; 42 | import org.apache.http.impl.client.CloseableHttpClient; 43 | import org.apache.http.impl.client.HttpClientBuilder; 44 | import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; 45 | import org.apache.http.util.EntityUtils; 46 | import org.slf4j.Logger; 47 | import org.slf4j.LoggerFactory; 48 | 49 | /** 50 | * The Consultant class allows you to retrieve the configuration for your application from Consul, and at the same 51 | * time subscribe to changes to that configuration. 52 | */ 53 | public class Consultant { 54 | 55 | private static final int HEALTH_CHECK_INTERVAL = 10; 56 | private static final int TERMINATION_TIMEOUT_SECONDS = 5; 57 | static final String CONFIG_PREFIX = "config"; 58 | 59 | /** 60 | * Allows you to build a custom Consultant object. 61 | */ 62 | public static class Builder { 63 | 64 | private static final int CONSUL_DEFAULT_PORT = 8500; 65 | private static final String CONSUL_ADDRESS = "http://localhost" + ":" + CONSUL_DEFAULT_PORT; 66 | 67 | @JsonIgnoreProperties(ignoreUnknown = true) 68 | public static class Agent { 69 | 70 | @JsonProperty("Config") 71 | private Config config; 72 | 73 | public Config getConfig() { 74 | return config; 75 | } 76 | 77 | void setConfig(Config config) { 78 | this.config = config; 79 | } 80 | 81 | } 82 | 83 | @JsonIgnoreProperties(ignoreUnknown = true) 84 | public static class Config { 85 | 86 | @JsonProperty("Datacenter") 87 | private String datacenter; 88 | 89 | @JsonProperty("NodeName") 90 | private String nodeName; 91 | 92 | public String getDatacenter() { 93 | return datacenter; 94 | } 95 | 96 | public String getNodeName() { 97 | return nodeName; 98 | } 99 | 100 | void setDatacenter(String datacenter) { 101 | this.datacenter = datacenter; 102 | } 103 | 104 | void setNodeName(String nodeName) { 105 | this.nodeName = nodeName; 106 | } 107 | 108 | } 109 | 110 | private ScheduledExecutorService executor; 111 | private ObjectMapper mapper; 112 | private CloseableHttpClient http; 113 | 114 | private ConfigValidator validator; 115 | private final SetMultimap settingListeners; 116 | private final Set configListeners; 117 | 118 | private String host; 119 | private String token; 120 | private Properties properties; 121 | private boolean pullConfig; 122 | 123 | private String serviceName; 124 | private String kvPrefix; 125 | private String datacenter; 126 | private String hostname; 127 | private String instanceName; 128 | private String healthEndpoint; 129 | private long whenLocatingServicesCacheResultsFor; 130 | 131 | private URI consulURI; 132 | 133 | 134 | private Builder() { 135 | this.settingListeners = HashMultimap.create(); 136 | this.configListeners = Sets.newHashSet(); 137 | this.properties = new Properties(); 138 | this.pullConfig = true; 139 | this.healthEndpoint = "/_health"; 140 | this.whenLocatingServicesCacheResultsFor = 1_000; 141 | } 142 | 143 | /** 144 | * States that Consultant should use a specific executor service for listening to configuration updates. If 145 | * no executor service is specified, Consultant will create a private executor service. This executor service 146 | * will automatically be cleaned up when the JVM terminates, or when shutdown() is called on the Consultant 147 | * object. 148 | * 149 | * @param executor The ScheduledExecutorService to use to schedule jobs on. 150 | * @return The Builder instance. 151 | */ 152 | public Builder usingExecutor(ScheduledExecutorService executor) { 153 | this.executor = executor; 154 | return this; 155 | } 156 | 157 | /** 158 | * States that Consultant should use a specific ObjectMapper for serialization and deserialization of JSON. 159 | * 160 | * @param mapper The ObjectMapper to use when deserializing JSON and serializing objects. 161 | * @return The Builder instance. 162 | */ 163 | public Builder usingObjectMapper(ObjectMapper mapper) { 164 | this.mapper = mapper; 165 | return this; 166 | } 167 | 168 | /** 169 | * Specifies where the Consul REST API can be reached on. Alternatively you can specify this through the 170 | * environment variable CONSUL_HOST=http://localhost. The default port (8500) is used. 171 | * 172 | * @param host The host where the Consul REST API can be found. 173 | * @return The Builder instance. 174 | */ 175 | public Builder withConsulHost(String host) { 176 | this.host = host; 177 | return this; 178 | } 179 | 180 | /** 181 | * Specifies that a token must be set using the X-Consul-Token header to authenticate requests 182 | * directed at Consul with. 183 | * 184 | * @param token The token to use when talking to Consul. 185 | * @return The Builder instance 186 | */ 187 | public Builder withConsulToken(String token) { 188 | this.token = token; 189 | return this; 190 | } 191 | 192 | /** 193 | * Allows the caller to specify the prefix when looking up the key value properties. This will default to 194 | * {@code config} if not specified. 195 | * 196 | * @param kvPrefix the path prefix to be used when looking-up the properties. 197 | * @return The Builder instance. 198 | */ 199 | public Builder withKvPrefix(String kvPrefix) { 200 | this.kvPrefix = kvPrefix; 201 | return this; 202 | } 203 | 204 | /** 205 | * States the identify of this application. This is used to figure out what configuration settings apply 206 | * to this application. If you don't set the identity using this method, you must define it using 207 | * environment variables such as SERVICE_NAME, and optionally SERVICE_DC and 208 | * SERVICE_HOST. 209 | * 210 | * @param serviceName The name of this service. 211 | * @return The Builder instance. 212 | */ 213 | public Builder identifyAs(String serviceName) { 214 | return identifyAs(serviceName, null, null, null); 215 | } 216 | 217 | /** 218 | * States the identify of this application. This is used to figure out what configuration settings apply 219 | * to this application. If you don't set the identity using this method, you should define it using 220 | * environment variables such as SERVICE_NAME, and optionally SERVICE_DC and 221 | * SERVICE_HOST. If the datacenter is not defined using environment variables either, this 222 | * value will default to the corresponding value of the Consul agent. 223 | * 224 | * @param serviceName The name of this service. 225 | * @param datacenter The name of the datacenter where this service is running in. 226 | * @return The Builder instance. 227 | */ 228 | public Builder identifyAs(String serviceName, String datacenter) { 229 | return identifyAs(serviceName, datacenter, null, null); 230 | } 231 | 232 | /** 233 | * States the identify of this application. This is used to figure out what configuration settings apply 234 | * to this application. If you don't set the identity using this method, you should define it using 235 | * environment variables such as SERVICE_NAME, and optionally SERVICE_DC and 236 | * SERVICE_HOST. If the datacenter and hostname are not defined using environment variables 237 | * either, these values will default to the corresponding values of the Consul agent. 238 | * 239 | * @param serviceName The name of this service. 240 | * @param datacenter The name of the datacenter where this service is running in. 241 | * @param hostname The name of the host where this service is running on. 242 | * @return The Builder instance. 243 | */ 244 | public Builder identifyAs(String serviceName, String datacenter, String hostname) { 245 | return identifyAs(serviceName, datacenter, hostname, null); 246 | } 247 | 248 | /** 249 | * States the identify of this application. This is used to figure out what configuration settings apply 250 | * to this application. If you don't set the identity using this method, you should define it using 251 | * environment variables such as SERVICE_NAME, and optionally SERVICE_DC, 252 | * SERVICE_HOST, and SERVICE_INSTANCE. If the datacenter and hostname are not 253 | * defined using environment variables either, these values will default to the corresponding values of 254 | * the Consul agent. 255 | * 256 | * @param serviceName The name of this service. 257 | * @param datacenter The name of the datacenter where this service is running in. 258 | * @param hostname The name of the host where this service is running on. 259 | * @param instanceName The name/role of this service instance. 260 | * @return The Builder instance. 261 | */ 262 | public Builder identifyAs(String serviceName, String datacenter, String hostname, String instanceName) { 263 | checkArgument(!isNullOrEmpty(serviceName), "You must specify a 'serviceName'!"); 264 | this.serviceName = serviceName; 265 | this.datacenter = datacenter; 266 | this.hostname = hostname; 267 | this.instanceName = instanceName; 268 | return this; 269 | } 270 | 271 | /** 272 | * States the endpoint used for checking the service's health. 273 | * 274 | * @param endpoint The endpoint to use for health checking. Defaults to "/_health". 275 | * @return The Builder instance. 276 | */ 277 | public Builder setHealthEndpoint(String endpoint) { 278 | this.healthEndpoint = endpoint; 279 | return this; 280 | } 281 | 282 | /** 283 | * States that Consultant should use a specific HTTP client. If no HTTP client is specified, Consultant will 284 | * create one itself. 285 | * 286 | * @param httpClient The CloseableHttpClient to use to communicate with Consul's API. 287 | * @return The Builder instance. 288 | */ 289 | @VisibleForTesting 290 | Builder usingHttpClient(CloseableHttpClient httpClient) { 291 | this.http = httpClient; 292 | return this; 293 | } 294 | 295 | /** 296 | * Specifies a callback listener which is notified of whenever a new and valid configuration is detected 297 | * in Consul. 298 | * 299 | * @param listener The listener to call when a new valid configuration is detected. 300 | * @return The Builder instance. 301 | */ 302 | public Builder onValidConfig(ConfigListener listener) { 303 | this.configListeners.add(listener); 304 | return this; 305 | } 306 | 307 | /** 308 | * Specifies a callback listener which is notified of whenever the specified setting is updated. 309 | * 310 | * @param key The key of the setting to listen for. 311 | * @param listener The listener to call when the specified setting is updated. 312 | * @return The Builder instance. 313 | */ 314 | public Builder onSettingUpdate(String key, SettingListener listener) { 315 | this.settingListeners.put(key, listener); 316 | return this; 317 | } 318 | 319 | /** 320 | * Specifies the validator which is used to determine if a new configuration detected in Consul is valid, 321 | * and may be published through the ConfigListener callback. 322 | * 323 | * @param validator The validator to call when a new configuration is detected. 324 | * @return The Builder instance. 325 | */ 326 | public Builder validateConfigWith(ConfigValidator validator) { 327 | this.validator = validator; 328 | return this; 329 | } 330 | 331 | /** 332 | * Specifies that Consultant should or should not fetch configuration from Consul. By default this is set to 333 | * true, but it can be useful to set this to false for testing. 334 | * 335 | * @param pullConfig True if configuration should be retrieved from Consul, or false if it should not. 336 | * @return The Builder instance. 337 | */ 338 | public Builder pullConfigFromConsul(boolean pullConfig) { 339 | this.pullConfig = pullConfig; 340 | return this; 341 | } 342 | 343 | /** 344 | * Ensures that Consultant starts out with a default Properties object. This object will be updated if 345 | * Consultant pulls configuration from Consul. By default Consultant will start out with an empty Properties 346 | * object. 347 | * 348 | * @param properties The Properties object to start Consultant with. 349 | * @return The Builder instance. 350 | */ 351 | public Builder startWith(Properties properties) { 352 | checkArgument(properties != null, "You must specify a non-null Properties object!"); 353 | this.properties = properties; 354 | return this; 355 | } 356 | 357 | /** 358 | * Specifies that the results from calls being made to Consul to locate services are being cached for at 359 | * most the specified duration. This is done as to not overload Consul with requests. If you specify a duration 360 | * of 0, caching will effectively be disabled. By default this is set to 1 second, which is good enough to 361 | * mitigate overloading Consul for a high volume of calls, but low enough not to return instances long after 362 | * they've deregistered. 363 | * 364 | * @param duration The duration of time to cache locate call results for. 365 | * @param unit The unit of the specified duration. 366 | * @return The Builder instance. 367 | */ 368 | public Builder whenLocatingServicesCacheResultsFor(long duration, TimeUnit unit) { 369 | checkArgument(duration >= 0, "You must specify a non-negative duration!"); 370 | checkArgument(unit != null, "You must specify a non-null unit!"); 371 | this.whenLocatingServicesCacheResultsFor = unit.toMillis(duration); 372 | return this; 373 | } 374 | 375 | /** 376 | * Builds a new instance of the Consultant class using the specified arguments. 377 | * 378 | * @return The constructed Consultant object. 379 | */ 380 | public Consultant build() { 381 | if (isNullOrEmpty(host)) { 382 | host = fromEnvironment("CONSUL_HOST"); 383 | } 384 | 385 | String consulHost = host; 386 | if (isNullOrEmpty(consulHost)) { 387 | consulHost = CONSUL_ADDRESS; 388 | } 389 | 390 | // Use regex here because URI cannot properly parse "localhost:8500". 391 | boolean changedConsulSchema = false; 392 | if (!consulHost.matches("[a-zA-Z0-9\\+\\-\\.]+://.*")) { 393 | consulHost = "http://" + consulHost; 394 | changedConsulSchema = true; 395 | } 396 | 397 | try { 398 | consulURI = new URI(consulHost); 399 | 400 | // Default consul URI to port 8500 if no port has been set, and no scheme was defined by the user. 401 | if (consulURI.getPort() == -1 && changedConsulSchema) { 402 | consulURI = new URI(consulURI.getScheme(), consulURI.getUserInfo(), consulURI.getHost(), 403 | CONSUL_DEFAULT_PORT, consulURI.getPath(), consulURI.getQuery(), consulURI.getFragment()); 404 | } 405 | } 406 | catch (URISyntaxException e) { 407 | throw new IllegalArgumentException("The specified CONSUL_HOST is not a valid URI: " + host, e); 408 | } 409 | 410 | serviceName = Optional.ofNullable(serviceName).orElse(fromEnvironment("SERVICE_NAME")); 411 | datacenter = Optional.ofNullable(datacenter).orElse(fromEnvironment("SERVICE_DC")); 412 | hostname = Optional.ofNullable(hostname).orElse(fromEnvironment("SERVICE_HOST")); 413 | instanceName = Optional.ofNullable(instanceName) 414 | .orElse(Optional.ofNullable(fromEnvironment("SERVICE_INSTANCE")) 415 | .orElse(UUID.randomUUID().toString())); 416 | 417 | if (mapper == null) { 418 | mapper = new ObjectMapper(); 419 | } 420 | 421 | if (executor == null) { 422 | executor = new ScheduledThreadPoolExecutor(1); 423 | } 424 | 425 | if (http == null) { 426 | PoolingHttpClientConnectionManager manager = new PoolingHttpClientConnectionManager(); 427 | manager.setMaxTotal(5); 428 | manager.setDefaultMaxPerRoute(5); 429 | 430 | http = HttpClientBuilder.create() 431 | .setConnectionManager(manager) 432 | .build(); 433 | } 434 | 435 | HttpGet request = new HttpGet(consulURI + "/v1/agent/self"); 436 | if (!Strings.isNullOrEmpty(token)) { 437 | request.setHeader("X-Consul-Token", token); 438 | } 439 | 440 | try (CloseableHttpResponse response = http.execute(request)) { 441 | HttpEntity entity = response.getEntity(); 442 | Agent agent = mapper.readValue(entity.getContent(), Agent.class); 443 | Config config = agent.getConfig(); 444 | 445 | if (isNullOrEmpty(datacenter)) { 446 | datacenter = config.getDatacenter(); 447 | } 448 | if (isNullOrEmpty(hostname)) { 449 | hostname = config.getNodeName(); 450 | } 451 | } 452 | catch (IOException e) { 453 | throw new RuntimeException("Could not fetch agent details from Consul.", e); 454 | } 455 | 456 | ServiceIdentifier id = new ServiceIdentifier(serviceName, datacenter, hostname, instanceName); 457 | Consultant consultant = new Consultant(executor, mapper, consulURI, token, id, settingListeners, 458 | configListeners, validator, http, pullConfig, healthEndpoint, kvPrefix, 459 | whenLocatingServicesCacheResultsFor); 460 | 461 | consultant.init(properties); 462 | return consultant; 463 | } 464 | 465 | private String fromEnvironment(String key) { 466 | String property = System.getProperty(key); 467 | if (property != null) { 468 | return property; 469 | } 470 | property = System.getenv(key); 471 | if (property != null) { 472 | return property; 473 | } 474 | return null; 475 | } 476 | } 477 | 478 | /** 479 | * @return A Builder for the Consultant class. 480 | */ 481 | public static Builder builder() { 482 | return new Builder(); 483 | } 484 | 485 | private static Logger log = LoggerFactory.getLogger(Consultant.class); 486 | 487 | private final AtomicBoolean registered; 488 | private final CloseableHttpClient http; 489 | private final ScheduledExecutorService executor; 490 | private final URI consulUri; 491 | private final String token; 492 | private final ServiceIdentifier id; 493 | private final ObjectMapper mapper; 494 | private final ConfigValidator validator; 495 | private final Properties validated; 496 | private final boolean pullConfig; 497 | private final String healthEndpoint; 498 | private final String kvPrefix; 499 | private final ConfigWriter configWriter; 500 | private ConfigUpdater poller; 501 | 502 | private final ServiceInstanceBackend serviceInstanceBackend; 503 | private final Multimap settingListeners; 504 | private final Set configListeners; 505 | private final AtomicBoolean shutdownBegun = new AtomicBoolean(false); 506 | 507 | private Consultant(ScheduledExecutorService executor, ObjectMapper mapper, URI consulUri, String token, 508 | ServiceIdentifier identifier, SetMultimap settingListeners, 509 | Set configListeners, ConfigValidator validator, CloseableHttpClient http, 510 | boolean pullConfig, String healthEndpoint, String kvPrefix, long whenLocatingServicesCacheResultsFor) { 511 | 512 | this.registered = new AtomicBoolean(); 513 | this.settingListeners = Multimaps.synchronizedSetMultimap(settingListeners); 514 | this.configListeners = Sets.newConcurrentHashSet(configListeners); 515 | this.serviceInstanceBackend = new ServiceInstanceBackend(identifier.getDatacenter(), consulUri, token, 516 | mapper, http, whenLocatingServicesCacheResultsFor); 517 | 518 | this.mapper = mapper; 519 | this.validator = validator; 520 | this.executor = executor; 521 | this.consulUri = consulUri; 522 | this.token = token; 523 | this.id = identifier; 524 | this.pullConfig = pullConfig; 525 | this.validated = new Properties(); 526 | this.healthEndpoint = healthEndpoint; 527 | this.http = http; 528 | this.configWriter = new ConfigWriter(http, consulUri, token, kvPrefix); 529 | this.kvPrefix = kvPrefix; 530 | } 531 | 532 | private void init(Properties initProperties) { 533 | updateValidatedConfig(initProperties); 534 | if (!pullConfig) { 535 | return; 536 | } 537 | 538 | log.info("Fetching initial configuration from Consul for serviceID: {}", id); 539 | poller = new ConfigUpdater(executor, http, consulUri, token, null, id, mapper, null, properties -> { 540 | if (validator == null) { 541 | updateValidatedConfig(properties); 542 | } 543 | else { 544 | try { 545 | validator.validateConfig(properties); 546 | updateValidatedConfig(properties); 547 | } 548 | catch (RuntimeException e) { 549 | log.warn("New config did not pass validation: " + e.getMessage(), e); 550 | } 551 | } 552 | }, kvPrefix); 553 | 554 | try { 555 | executor.submit(poller).get(); 556 | } 557 | catch (InterruptedException e) { 558 | Thread.currentThread().interrupt(); 559 | } 560 | catch (ExecutionException e) { 561 | throw new ConsultantException(e.getCause()); 562 | } 563 | } 564 | 565 | public void registerService(int port) { 566 | if (!registered.compareAndSet(false, true)) { 567 | log.warn("Cannot register the service, as service was already registered!"); 568 | return; 569 | } 570 | 571 | String url = consulUri + "/v1/agent/service/register"; 572 | log.info("Registering service with Consul: {}", id); 573 | 574 | try { 575 | String serviceId = id.getInstance().get(); 576 | String serviceName = id.getServiceName(); 577 | String serviceHost = id.getHostName().get(); 578 | Check check = new Check("http://" + serviceHost + ":" + port + healthEndpoint, HEALTH_CHECK_INTERVAL); 579 | ServiceRegistration registration = new ServiceRegistration(serviceId, serviceName, serviceHost, 580 | port, check); 581 | String serialized = mapper.writeValueAsString(registration); 582 | 583 | HttpPut request = new HttpPut(url); 584 | request.setEntity(new StringEntity(serialized)); 585 | request.setHeader("User-Agent", "Consultant"); 586 | if (!Strings.isNullOrEmpty(token)) { 587 | request.setHeader("X-Consul-Token", token); 588 | } 589 | 590 | try (CloseableHttpResponse response = http.execute(request)) { 591 | int statusCode = response.getStatusLine().getStatusCode(); 592 | if (statusCode >= 200 && statusCode < 400) { 593 | return; 594 | } 595 | log.error("Could not register service, status: " + statusCode); 596 | String body = EntityUtils.toString(response.getEntity()); 597 | throw new ConsultantException("Could not register service", new ConsulException(statusCode, body)); 598 | } 599 | } 600 | catch (IOException | RuntimeException e) { 601 | registered.set(false); 602 | log.error("Could not register service!", e); 603 | throw new ConsultantException(e); 604 | } 605 | } 606 | 607 | public void deregisterService() { 608 | if (!registered.compareAndSet(true, false)) { 609 | log.warn("Cannot deregister the service, as service wasn't registered or was already deregistered!"); 610 | return; 611 | } 612 | 613 | String serviceId = id.getInstance().get(); 614 | String url = consulUri + "/v1/agent/service/deregister/" + serviceId; 615 | log.info("Deregistering service from Consul: {}", id); 616 | 617 | HttpPut request = new HttpPut(url); 618 | request.setHeader("User-Agent", "Consultant"); 619 | if (!Strings.isNullOrEmpty(token)) { 620 | request.setHeader("X-Consul-Token", token); 621 | } 622 | 623 | try (CloseableHttpResponse response = http.execute(request)) { 624 | int statusCode = response.getStatusLine().getStatusCode(); 625 | if (statusCode >= 200 && statusCode < 400) { 626 | return; 627 | } 628 | log.error("Could not deregister service, status: " + statusCode); 629 | String body = EntityUtils.toString(response.getEntity()); 630 | throw new ConsultantException("Could not deregister service", new ConsulException(statusCode, body)); 631 | } 632 | catch (IOException | RuntimeException e) { 633 | registered.set(true); 634 | log.error("Could not deregister service!", e); 635 | throw new ConsultantException(e); 636 | } 637 | } 638 | 639 | /** 640 | * Lists all service instances of a particular service known to Consul ordered by network distance. This method is 641 | * scheduled for removal in the next major release. Avoid this method if possible due to possible performance 642 | * issues when having a multi datacenter Consul cluster. Use the locateAll() method instead. 643 | * 644 | * @param serviceName The name of the service to locate instances of. 645 | * @return A List of ServiceInstance objects. 646 | */ 647 | @Deprecated 648 | public List list(String serviceName) { 649 | Optional instance; 650 | List instances = Lists.newArrayList(); 651 | ServiceLocator locator = locateAll(serviceName, RoutingStrategies.NETWORK_DISTANCE); 652 | while ((instance = locator.next()).isPresent()) { 653 | instances.add(instance.get()); 654 | } 655 | return instances; 656 | } 657 | 658 | /** 659 | * Returns a ServiceLocator with which services matching the specified service name can be found. By default uses 660 | * the "Randomized Weighted Distance" RoutingStrategy. 661 | * 662 | * @param serviceName The name of the service to locate instances of. 663 | * @return The constructed ServiceLocator. 664 | */ 665 | public ServiceLocator locateAll(String serviceName) { 666 | return locateAll(serviceName, RoutingStrategies.RANDOMIZED_WEIGHTED_DISTANCE); 667 | } 668 | 669 | /** 670 | * Returns a ServiceLocator with which services matching the specified service name can be found. 671 | * 672 | * @param serviceName The name of the service to locate instances of. 673 | * @param routingStrategy The RoutingStrategy to use to locate instances. 674 | * @return The constructed ServiceLocator. 675 | */ 676 | public ServiceLocator locateAll(String serviceName, RoutingStrategy routingStrategy) { 677 | return routingStrategy.locateInstances(serviceInstanceBackend, serviceName); 678 | } 679 | 680 | /** 681 | * Returns the closest service instance's InetSocketAddress. 682 | * 683 | * @param serviceName The name of the service to locate. 684 | * @return An Optional containing the closest service instance's InetSocketAddress, or an empty Optional otherwise. 685 | */ 686 | @Deprecated 687 | public Optional locate(String serviceName) { 688 | return locateAll(serviceName, RoutingStrategies.NETWORK_DISTANCE).next() 689 | .map(instance -> { 690 | Node node = instance.getNode(); 691 | Service service = instance.getService(); 692 | String address = Optional.ofNullable(node.getAddress()) 693 | .orElse(service.getAddress()); 694 | 695 | return new InetSocketAddress(address, service.getPort()); 696 | }); 697 | } 698 | 699 | public void addConfigListener(ConfigListener listener) { 700 | configListeners.add(listener); 701 | } 702 | 703 | public boolean removeConfigListener(ConfigListener listener) { 704 | return configListeners.remove(listener); 705 | } 706 | 707 | public void addSettingListener(String key, SettingListener listener) { 708 | settingListeners.put(key, listener); 709 | } 710 | 711 | public boolean removeSettingListener(String key, SettingListener listener) { 712 | return settingListeners.remove(key, listener); 713 | } 714 | 715 | /** 716 | * Updates a config in Consul's KV store. 717 | * 718 | * @param key The key to set/update/delete. 719 | * @param value The new value for the key. Specifying a NULL will delete it. 720 | * @return True if the change was successful. 721 | */ 722 | public boolean setConfig(String key, String value) { 723 | return setConfig(new ServiceIdentifier(id.getServiceName(), null, null, null), key, value); 724 | } 725 | 726 | /** 727 | * Updates a config in Consul's KV store. 728 | * 729 | * @param identifier A qualifier for which this update should apply. Use this if you wish to limit the 730 | * config update to a specific datacenter, host, or instance. 731 | * @param key The key to set/update/delete. 732 | * @param value The new value for the key. Specifying a NULL will delete it. 733 | * @return True if the change was successful. 734 | */ 735 | public boolean setConfig(ServiceIdentifier identifier, String key, String value) { 736 | return configWriter.setConfig(identifier, key, value); 737 | } 738 | 739 | private void updateValidatedConfig(Properties newConfig) { 740 | Map> changes = PropertiesUtil.sync(newConfig, validated); 741 | if (changes.isEmpty()) { 742 | return; 743 | } 744 | 745 | for (ConfigListener listener : configListeners) { 746 | listener.onConfigUpdate(validated); 747 | } 748 | 749 | for (Entry> entry : changes.entrySet()) { 750 | String key = entry.getKey(); 751 | Collection listeners = settingListeners.get(key); 752 | if (listeners == null || listeners.isEmpty()) { 753 | continue; 754 | } 755 | 756 | for (SettingListener listener : listeners) { 757 | Pair change = entry.getValue(); 758 | if (!Objects.equals(change.getLeft(), change.getRight())) { 759 | // Only fire this for keys which have actually been changed 760 | listener.onSettingUpdate(key, change.getLeft(), change.getRight()); 761 | } 762 | } 763 | } 764 | } 765 | 766 | /** 767 | * Tears any outstanding resources down. 768 | * 769 | * @throws InterruptedException If it got interrupted while waiting for any open tasks 770 | */ 771 | public void shutdown() throws InterruptedException { 772 | if (registered.get()) { 773 | try { 774 | deregisterService(); 775 | } 776 | catch (ConsultantException e) { 777 | log.error("Error occurred while deregistering", e); 778 | } 779 | } 780 | 781 | executor.shutdownNow(); 782 | shutdownBegun.set(true); 783 | if (poller != null) { 784 | poller.shutdown(); 785 | } 786 | try { 787 | /* 788 | HTTP client does not have a way to interrupt long-running HTTP calls, so we have to shutdown the whole 789 | thing. 790 | */ 791 | http.close(); 792 | } 793 | catch (IOException | RuntimeException e) { 794 | log.error("Error occurred on shutdown: " + e.getMessage(), e); 795 | } 796 | 797 | if (pullConfig && !executor.isShutdown()) { 798 | boolean allTasksTerminated = executor.awaitTermination(TERMINATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); 799 | if (!allTasksTerminated) { 800 | log.warn("Could not shut down all executor tasks!"); 801 | } 802 | } 803 | } 804 | 805 | /** 806 | * @return The host on which the Consul agent can be found. Will typically return "http://localhost:8500" unless 807 | * otherwise specified in the Builder or environment variables. 808 | */ 809 | public String getConsulHost() { 810 | return consulUri.toString(); 811 | } 812 | 813 | /** 814 | * @return The identity of this service or application. 815 | */ 816 | public ServiceIdentifier getServiceIdentifier() { 817 | return id; 818 | } 819 | 820 | /** 821 | * @return The current valid configuration. 822 | */ 823 | public Properties getProperties() { 824 | return validated; 825 | } 826 | 827 | } 828 | --------------------------------------------------------------------------------