Skip to content

Commit

Permalink
Proof-of-concept Consul backend
Browse files Browse the repository at this point in the history
This is a working Consul backend for Cloudname but it hasn't been tested
a lot. In short it creates a session which is kept alive by regular polls
to the Consul Agent. The session is then used to create ephmeral KV entries.

Permanent leases are just regular KV entries without an associated session.

Note that this implementation isn't tested in a real life production
environment and that the defaults are most likely a bit too aggressive for
to be used as is. In particular the polling when a watch gets 404 is very
aggressive and the lock-delay parameter is set to 0.

There's no integration with the service catalog in Consul either since that
is handled on a higher level in Cloudname. This might require a slight change
in the core API with some sort of hooks or custom service implementation on
top of Cloudname. A custom service implementation isn't ideal.

The backend tests are also updated a bit with more generous backend propagation
timeouts throughout.
  • Loading branch information
Ståle Dahl committed Dec 15, 2015
1 parent 49dcf44 commit f9340d8
Show file tree
Hide file tree
Showing 16 changed files with 1,216 additions and 3 deletions.
8 changes: 8 additions & 0 deletions cn-consul/docker/Dockerfile
@@ -0,0 +1,8 @@
FROM ubuntu:latest
MAINTAINER stalehd@telenordigital.com
CMD mkdir -p /consul/data
ADD consul_linux /consul/consul
ENV interface=localhost
ENTRYPOINT /consul/consul agent -server -bootstrap-expect 1 -data-dir /consul/data -bind ${interface} -client ${interface}


4 changes: 4 additions & 0 deletions cn-consul/docker/get-consul.sh
@@ -0,0 +1,4 @@
VERSION=0.6.0
wget https://releases.hashicorp.com/consul/${VERSION}/consul_${VERSION}_linux_amd64.zip
unzip consul_${VERSION}_linux_amd64.zip
mv consul consul_linux
71 changes: 71 additions & 0 deletions cn-consul/pom.xml
@@ -0,0 +1,71 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.cloudname</groupId>
<artifactId>cloudname-parent</artifactId>
<version>3.0-SNAPSHOT</version>
</parent>

<artifactId>cn-consul</artifactId>
<packaging>jar</packaging>

<name>Cloudname Consul backend</name>
<description>Consul backend for cloudname</description>
<url>https://github.com/Cloudname/cloudname</url>

<properties>
<jersey.version>2.19</jersey.version>
</properties>

<dependencies>
<dependency>
<groupId>org.cloudname</groupId>
<artifactId>cn-core</artifactId>
</dependency>

<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-all</artifactId>
<version>1.3</version>
</dependency>

<dependency>
<groupId>org.glassfish.jersey.core</groupId>
<artifactId>jersey-client</artifactId>
<version>${jersey.version}</version>
</dependency>

<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
</dependency>

<dependency>
<groupId>org.cloudname</groupId>
<artifactId>testtools</artifactId>
<scope>test</scope>
</dependency>

</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
234 changes: 234 additions & 0 deletions cn-consul/src/main/java/org/cloudname/backends/consul/Consul.java
@@ -0,0 +1,234 @@
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 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;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
* 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.
*
* 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.
*
* Permanent leases are just plain entries into the KV store.
*
* TODO: Use single session when creating leases.
* @author Ståle Dahl <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;
/**
* @param endpoint Consul agent endpoint
*/
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);
}
}

0 comments on commit f9340d8

Please sign in to comment.