package org.cloudname.backends.consul;
import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.internal.util.Base64;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
/**
* Consul interface. We have to roll our own since the existing libraries doesn't support watches
* in any way but that's OK. We only use parts of Consul anyways. This implementation doesn't use
* Consul's service endpoints since they can't store any data besides a single host:port entry.
*
* <p>Temporary leases (aka ephemeral values) are created using sessions; a session is created with
* a keep-alive thread that pokes Consul every N seconds. The session flags are set to "delete"
* which will remove the entries tagged with that session in the KV store when the session expires.
* In Consul lingo this is a lock on a particular value. Pay close attention to the LockDelay
* parameter when using this class since it tells us how often the ephemeral values can be updated
* by the clients.
*
* <p>Permanent leases are just plain entries into the KV store.
*
* <p>TODO: Use single session when creating leases.
*
* @author stalehd@gmail.com
*/
public class Consul {
private static final Logger LOG = Logger.getLogger(Consul.class.getName());
public static final int DEFAULT_LOCK_DELAY = 15;
private final String endpoint;
private final Client httpClient;
/**
* Create new backend with the specified endpoint address.
*/
public Consul(final String endpoint) {
this.endpoint = endpoint;
httpClient = ClientBuilder.newClient();
httpClient.property(ClientProperties.CONNECT_TIMEOUT, 1000);
httpClient.property(ClientProperties.READ_TIMEOUT, 180000);
// Uncomment this for detailed logging. You are probably desperate by now, like I've been.
//httpClient.register(new LoggingFilter());
}
/**
* Check if it is a valid endpoint. This will do a request at the KV stores root entry and
* if it doesn't return 400 Bad Request the agent is probably not running at the specified
* endpoint.
*/
public boolean isValid() {
try {
final Response response = httpClient
.target(endpoint)
.path("/v1/kv/")
.request()
.get();
return response.getStatus() == Response.Status.BAD_REQUEST.getStatusCode();
} catch (final Exception ce) {
return false;
}
}
/**
* Create a session, PUT it to /v1/sessions/create, then build a complete session object
* with the returned ID.
*
* @param name Name of session. The name does not carry any particular semantics but makes
* it easier to correlate the sessions with the values in the KV store for
* outsiders rummaging around in Consul.
*
* @param ttlMs Session TTL. The frequency of keep-alive calls to Consul made by the session
* object. In milliseconds.
*
* @param lockDelay Delay between allowing locks to be set. This affects the speed at which
* you can set the ephemeral values. Normally this is 15 seconds but for
* tests you might want to set it lower. Very scarce documentation at
* https://www.consul.io/docs/agent/http/session.html, more detailed at
* https://www.consul.io/docs/internals/sessions.html (but nothing explaining
* the purpose of this parameter. The explanation can most likely be found in
* http://research.google.com/archive/chubby.html
*/
public ConsulSession createSession(final String name, final int ttlMs, final int lockDelay) {
// TODO: move http stuff into the session class.
final ConsulSession newSession
= new ConsulSession(this.endpoint, "id", name, ttlMs, lockDelay);
final String sessionString = newSession.toJson();
final Entity<String> entity = Entity.entity(sessionString, MediaType.APPLICATION_JSON);
final Response response = httpClient
.target(endpoint)
.path("/v1/session/create")
.request()
.accept(MediaType.APPLICATION_JSON)
.put(entity);
if (response.getStatus() != Response.Status.OK.getStatusCode()) {
LOG.log(Level.WARNING, "Expected 200 when creating session "
+ sessionString + " but got " + response.getStatus() + ". Consul Agent"
+ " responded with " + response.readEntity(String.class));
return null;
}
final ConsulSession session
= ConsulSession.fromJsonResponse(newSession, response.readEntity(String.class));
if (session != null) {
session.startKeepAlive();
}
return session;
}
/**
* Write ephemeral data to the KV store, linked to the session. This will also work if the
* value doesn't exist up front.
*/
public boolean writeSessionData(final String name, final String data, final String sessionId) {
final Response response = httpClient
.target(endpoint)
.path("/v1/kv/").path(name)
.queryParam("acquire", sessionId)
.request()
.put(Entity.text(data));
if (response.getStatus() != Response.Status.OK.getStatusCode()) {
LOG.log(Level.WARNING, "Could not write value " + name + "=" + data
+ " for session " + sessionId + " got response " + response.getStatus()
+ " but expected 200. Consul Agent says " + response.readEntity(String.class));
return false;
}
if (response.readEntity(String.class).equals("true")) {
return true;
}
return false;
}
/**
* Create a new (permanent) entry in the KV store. Fails if the entry already exists.
*/
public boolean createPermanentData(final String name, final String data) {
final Response response = httpClient
.target(endpoint)
.path("/v1/kv/").path(name)
.queryParam("cas", "0")
.request()
.put(Entity.text(data));
if (response.getStatus() != Response.Status.OK.getStatusCode()) {
LOG.log(Level.WARNING, "Could not create permanent value " + name + "/" + data
+ " got response " + response.getStatus() + " but expected 200");
return false;
}
// Well. THIS is ugly. Never mind the return code (409 anyone) but use a f--ing string
// to return the status. To top it off Consul says it is json.
// (*bangs head agains wall*)
final String result = response.readEntity(String.class);
if (result.equals("true")) {
return true;
}
return false;
}
/**
* Write to KV store in Consul.
*/
public boolean writePermanentData(
final String name, final String data) {
final Response response = httpClient
.target(endpoint)
.path("/v1/kv/").path(name)
.request()
.put(Entity.text(data));
LOG.info("Wrote " + data + " to " + name);
if (response.getStatus() != Response.Status.OK.getStatusCode()) {
LOG.log(Level.WARNING, "Could not write permanent value " + name + "/" + data
+ " got response " + response.getStatus() + " but expected 200");
return false;
}
return true;
}
/**
* Remove permanent value.
*/
public boolean removePermanentData(final String name) {
final Response response = httpClient
.target(endpoint)
.path("/v1/kv/").path(name)
.request()
.delete();
if (response.getStatus() != Response.Status.OK.getStatusCode()) {
LOG.log(Level.WARNING, "Could not remove permanent value " + name
+ ". Got response " + response.getStatus() + " but expected 200");
return false;
}
return true;
}
/**
* Read value from KV store. Value must exist.
*
* @return null if not found
*/
public String readData(final String name) {
final Response response = httpClient
.target(endpoint)
.path("/v1/kv/").path(name)
.request(MediaType.APPLICATION_JSON).get();
if (response.getStatus() != Response.Status.OK.getStatusCode()) {
LOG.log(Level.WARNING, "Got " + response.getStatus()
+ " from Consul Agent when querying for key named " + name);
return null;
}
final String dataJson = response.readEntity(String.class);
try {
final JSONArray array = new JSONArray(dataJson);
final JSONObject json = array.getJSONObject(0);
return Base64.decodeAsString(json.getString("Value"));
} catch (final JSONException je) {
LOG.log(Level.WARNING, "Couldn't grok JSON from Consul Agent for value "
+ name + ": " + dataJson);
return null;
}
}
/**
* Create a new watch object on the specified path. Note that the watch isn't started
* automatically. Start it manually to ensure you receive all callbacks.
*/
public ConsulWatch createWatch(final String pathToWatch) {
return new ConsulWatch(endpoint, pathToWatch);
}
}