├── .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 | [](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 |
--------------------------------------------------------------------------------