├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── build.sbt ├── client └── src │ ├── it │ ├── resources │ │ ├── application.conf │ │ └── logback-test.xml │ └── scala │ │ └── stormlantern │ │ └── consul │ │ └── client │ │ ├── ServiceBrokerIntegrationTest.scala │ │ ├── dao │ │ └── AkkaHttpConsulClientIntegrationTest.scala │ │ └── util │ │ ├── ConsulDockerContainer.scala │ │ ├── ConsulRegistratorDockerContainer.scala │ │ └── TestActorSystem.scala │ ├── main │ ├── resources │ │ └── reference.conf │ └── scala │ │ └── stormlantern │ │ └── consul │ │ └── client │ │ ├── ServiceBroker.scala │ │ ├── ServiceBrokerActor.scala │ │ ├── dao │ │ ├── BinaryData.scala │ │ ├── ConsulHttpClient.scala │ │ ├── ConsulHttpProtocol.scala │ │ ├── HealthCheck.scala │ │ ├── Indexed.scala │ │ ├── IndexedServiceInstances.scala │ │ ├── KeyData.scala │ │ ├── ServiceRegistration.scala │ │ ├── SessionCreation.scala │ │ ├── SessionInfo.scala │ │ ├── SessionOp.scala │ │ └── akka │ │ │ └── AkkaHttpConsulClient.scala │ │ ├── discovery │ │ ├── ConnectionHolder.scala │ │ ├── ConnectionProvider.scala │ │ ├── ConnectionProviderFactory.scala │ │ ├── ConnectionStrategy.scala │ │ └── ServiceAvailabilityActor.scala │ │ ├── election │ │ ├── LeaderFollowerActor.scala │ │ └── LeaderInfo.scala │ │ ├── loadbalancers │ │ ├── CircularLinkedHashSet.scala │ │ ├── LoadBalancer.scala │ │ ├── LoadBalancerActor.scala │ │ └── RoundRobinLoadBalancer.scala │ │ ├── session │ │ └── SessionActor.scala │ │ └── util │ │ ├── Logging.scala │ │ └── RetryPolicy.scala │ └── test │ ├── resources │ ├── application.conf │ └── logback-test.xml │ └── scala │ └── stormlantern │ └── consul │ └── client │ ├── ServiceBrokerActorSpec.scala │ ├── ServiceBrokerSpec.scala │ ├── discovery │ └── ServiceAvailabilityActorSpec.scala │ ├── election │ └── LeaderFollowerActorSpec.scala │ ├── helpers │ ├── CallingThreadExecutor.scala │ └── ModelHelpers.scala │ └── loadbalancers │ ├── LoadBalancerActorSpec.scala │ └── RoundRobinLoadBalancerSpec.scala ├── dns-helper └── src │ └── main │ └── scala │ └── stormlantern │ └── consul │ └── client │ └── Dns.scala ├── docker-testkit └── src │ └── main │ └── scala │ └── stormlantern │ └── dockertestkit │ ├── DockerClientProvider.scala │ ├── DockerContainer.scala │ ├── DockerContainers.scala │ ├── client │ └── Container.scala │ └── orchestration │ └── Orchestration.scala ├── example ├── README.md ├── docker-compose.yml └── src │ └── main │ ├── resources │ ├── application.conf │ ├── assets │ │ ├── app.js │ │ ├── index.html │ │ └── paper-full.min.js │ └── logback.xml │ └── scala │ └── stormlantern │ └── consul │ └── example │ ├── Boot.scala │ ├── ReactiveConsulHttpServiceActor.scala │ └── SprayExampleServiceClient.scala ├── manifest.md └── project ├── Dependencies.scala ├── build.properties └── plugins.sbt /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | 4 | # sbt specific 5 | .cache/ 6 | .history/ 7 | .lib/ 8 | dist/* 9 | target/ 10 | lib_managed/ 11 | src_managed/ 12 | project/boot/ 13 | project/plugins/project/ 14 | 15 | # Scala-IDE specific 16 | .scala_dependencies 17 | .worksheet 18 | .classpath 19 | .project 20 | .settings/ 21 | .cache 22 | .cache-main 23 | .cache-tests 24 | bin/ 25 | 26 | # Intellij specific 27 | *.sc 28 | .idea 29 | .idea_modules 30 | projectFilesBackup 31 | 32 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | scala: 3 | - 2.11.8 4 | - 2.12.2 5 | jdk: 6 | - oraclejdk8 7 | 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Dirk Louwers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/dlouwers/reactive-consul.svg?branch=master)](https://travis-ci.org/dlouwers/reactive-consul) 2 | [![Maven Central](https://img.shields.io/maven-central/v/nl.stormlantern/reactive-consul_2.11.svg)](https://maven-badges.herokuapp.com/maven-central/nl.stormlantern/reactive-consul) 3 | 4 | # Reactive Consul 5 | This project is a Consul client for Scala. It uses non-blocking I/O to communicate with a Consul cluster. You can use 6 | the ServiceBroker to get out of the box support for automatic-clustering, loadbalancing and failover or you can use 7 | the low-level ConsulHttpClient. 8 | 9 | 10 | ## Releases 11 | 12 | ### 0.3.0 13 | 14 | * Multiple _connectionStrategies_ are now allowed per named service so that they can be distinguished between by tags 15 | 16 | ### 0.2.1 17 | 18 | * Fixed the POM having an unwanted dependencies 19 | 20 | ### 0.2.0 21 | 22 | * Supports Scala 2.12 23 | * Uses akka-http instead of Spray, reducing the amount of dependencies (thanks [David Buschman](https://github.com/dbuschman7)) 24 | * Uses native JDK 8 base64, reducing the amount of dependencies (thanks [David Buschman](https://github.com/dbuschman7)) 25 | * Bootstrapping the library with SRV record is now an extra dependency (thanks [David Buschman](https://github.com/dbuschman7)) 26 | 27 | ## Requirements 28 | 29 | * Java 8 30 | * Scala 2.11 or 2.12 31 | 32 | ## Adding it to your project 33 | Reactive Consul is available via [Maven Central](https://search.maven.org/), simply add it to your SBT build: 34 | 35 | ```scala 36 | libraryDependencies += "nl.stormlantern" %% "reactive-consul" % "0.3.0" 37 | ``` 38 | 39 | If you want to use a development snapshots, use the 40 | [Sonatype Snapshot Repository](https://oss.sonatype.org/content/repositories/snapshots/nl/stormlantern/). Add the 41 | following lines to your SBT build: 42 | ```scala 43 | resolvers ++= Seq( 44 | "Sonatype Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots/" 45 | ) 46 | 47 | libraryDependencies += "nl.stormlantern" %% "reactive-consul" % "0.4.0-SNAPSHOT" 48 | ``` 49 | 50 | ## Using the ServiceBroker 51 | The ServiceBroker can be used as follows: 52 | 53 | ```scala 54 | import stormlantern.consul.client.ServiceBroker 55 | 56 | val serviceBroker = ServiceBroker("localhost", connectionStrategies) 57 | 58 | val result = serviceBroker.withService("") { myServiceConnection => 59 | myServiceConnection.getData() 60 | } 61 | ``` 62 | 63 | _connectionStrategies_ are discussed in the next section. 64 | 65 | ## Creating a ConnectionStrategy 66 | A connection strategy can optionally encapsulate connectionpooling and provides loadbalancing but it can be really 67 | straightforward, especially if underlying services do most of the work for you. 68 | 69 | ### Example for MongoDB using Casbah 70 | The following example will create a connection strategy for connecting to MongoDB. MongoDB manages replication and 71 | sharding for you and will automatically route your query to the right instance, but you will have to connect to a node 72 | first. Consul can help you keep track of these. 73 | 74 | ```scala 75 | import stormlantern.consul.client.discovery.ConnectionProvider 76 | import stormlantern.consul.client.discovery.ConnectionStrategy 77 | import stormlantern.consul.client.ServiceBroker 78 | 79 | import com.mongodb.casbah.Imports._ 80 | 81 | val mongoConnectionProvider = (host: String, port: Int) => new ConnectionProvider { 82 | val client = new MongoClient(host, port) 83 | override def getConnection: Future[Any] = Future.successful(client) 84 | } 85 | val mongoConnectionStrategy = ConnectionStrategy("mongodb", mongoConnectionProvider) 86 | val serviceBroker = ServiceBroker("consul-http", Set(mongoConnectionStrategy)) 87 | ``` 88 | 89 | This example assumes that you have Consul available through DNS and that you have registered Consul's HTTP interface 90 | under the service name "consul-http" and your MongoDB instances as "mongodb". 91 | 92 | Instead of passing the full serviceBroker to your MongoDB DAO implementation you could declare your DAO implementations 93 | as a trait and then have them implement to following trait: 94 | 95 | ```scala 96 | trait MongoDbService { 97 | def withService[T]: (MongoClient => Future[T]) => Future[T] 98 | } 99 | ``` 100 | 101 | Then your MongoDB DAO implementations can be instantated as such: 102 | 103 | ```scala 104 | val myMongoDAO = new MyMongoDAO { 105 | def withService[T] = serviceBroker.withService[MongoClient, T]("mongodb") 106 | } 107 | ``` 108 | 109 | Or, more traditionally: 110 | 111 | ```scala 112 | class MongoDbServiceProvider(serviceBroker: ServiceBroker) { 113 | def withService[T] = serviceBroker.withService[MongoClient, T]("mongodb") 114 | } 115 | ``` 116 | 117 | and pass an instance of it to your MongoDB DAO implementation. 118 | 119 | ### Example for Postgres using c3p0 connection pooling 120 | The following example will create a connection strategy for connecting to Postgres. This example assumes a setup with 121 | one master and two replication servers. Consul can help you keep track of these. 122 | 123 | ```scala 124 | import stormlantern.consul.client.discovery.ConnectionProvider 125 | import stormlantern.consul.client.discovery.ConnectionStrategy 126 | import stormlantern.consul.client.ServiceBroker 127 | 128 | import scala.concurrent.Future 129 | import java.sql.Connection 130 | import com.mchange.v2.c3p0._ 131 | 132 | 133 | val c3p0ConnectionProvider = (host: String, port: Int) => new ConnectionProvider { 134 | val pool = { 135 | val cpds = new ComboPooledDataSource() 136 | cpds.setDriverClass("org.postgresql.Driver") 137 | cpds.setJdbcUrl(s"jdbc:postgresql://$host:$port/mydb") 138 | cpds.setUser("dbuser") 139 | cpds.setPassword("dbpassword") 140 | cpds 141 | } 142 | override def getConnection: Future[Any] = Future.successful(pool.getConnection()) 143 | override def returnConnection(connectionHolder: ConnectionHolder): Unit = 144 | connectionHolder.connection.foreach(_.asInstanceOf[Connection].close()) 145 | override def destroy(): Unit = pool.close() 146 | } 147 | 148 | val postgresReadConnectionStrategy = ConnectionStrategy( 149 | ServiceDefinition("postgres-read", "postgres")), 150 | c3p0ConnectionProvider, 151 | new RoundRobinLoadBalancer 152 | ) 153 | val postgresWriteConnectionStrategy = ConnectionStrategy( 154 | ServiceDefinition("postgres-write", "postgres", Set("master"), 155 | c3p0ConnectionProvider 156 | new RoundRobinLoadBalancer 157 | ) 158 | val serviceBroker = ServiceBroker("consul-http", Set(postgresReadConnectionStrategy, postgresWriteConnectionStrategy)) 159 | ``` 160 | 161 | This example assumes that you have Consul available through DNS and that you have registered Consul's HTTP interface 162 | under the service name "consul-http", your Postgres instances as "postgres" and your Postgres master is tagged as "master". 163 | Consul's tag support is used to identify the postgres master, all writes are sent to it. Reads can go to any postgres instance. 164 | 165 | Now you can connect to your database using: 166 | 167 | ```scala 168 | class PostgresServiceProvider(serviceBroker: ServiceBroker) { 169 | def withReadingConnection[T] = serviceBroker.withService[Connection, T]("postgres") 170 | def withWritingConnection[T] = serviceBroker.withService[Connection, T]("postgres-master") 171 | } 172 | 173 | class MyDao(connectionProvider: PostgresServiceProvider) { 174 | def findStuff(name: String): Future[Option[Stuff]]] = { 175 | connectionProvider.withReadingConnection { c => 176 | Future.successful { 177 | c.doSqlStuff() 178 | } 179 | } 180 | } 181 | } 182 | 183 | val myDao = new MyDao(new PostgresService(serviceBroker)) 184 | ``` 185 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import Dependencies._ 2 | import sbt.Keys._ 3 | import com.typesafe.sbt.SbtScalariform 4 | import com.typesafe.sbt.SbtScalariform.ScalariformKeys 5 | 6 | import scalariform.formatter.preferences._ 7 | 8 | // Common variables 9 | lazy val commonSettings = Seq( 10 | scalaVersion := "2.11.8", 11 | crossScalaVersions := Seq("2.11.8", "2.12.2"), 12 | organization := "nl.stormlantern", 13 | version := "0.4.0-SNAPSHOT", 14 | resolvers ++= Dependencies.resolutionRepos, 15 | scalacOptions ++= Seq("-unchecked", "-deprecation", "-feature") 16 | ) 17 | 18 | lazy val reactiveConsul = (project in file(".")) 19 | .settings( commonSettings: _* ) 20 | .settings( publishSettings: _* ) 21 | .aggregate(client, dnsHelper, dockerTestkit/*, example*/) 22 | 23 | 24 | lazy val dnsHelper = (project in file("dns-helper")) 25 | .settings( commonSettings: _* ) 26 | .settings( publishSettings: _* ) 27 | .settings( 28 | name := "reactive-consul-dns", 29 | publishArtifact in Compile := true, 30 | publishArtifact in makePom := true, 31 | publishArtifact in Test := false, 32 | publishArtifact in IntegrationTest := false, 33 | fork := true, 34 | libraryDependencies ++= Seq( 35 | spotifyDns 36 | ), 37 | ScalariformKeys.preferences := ScalariformKeys.preferences.value 38 | .setPreference(AlignSingleLineCaseStatements, true) 39 | .setPreference(DoubleIndentClassDeclaration, true) 40 | .setPreference(DanglingCloseParenthesis, Preserve) 41 | .setPreference(RewriteArrowSymbols, true) 42 | ) 43 | 44 | lazy val client = (project in file("client")) 45 | .settings( commonSettings: _* ) 46 | .settings( publishSettings: _* ) 47 | .settings( 48 | name := "reactive-consul", 49 | publishArtifact in Compile := true, 50 | publishArtifact in makePom := true, 51 | publishArtifact in Test := false, 52 | publishArtifact in IntegrationTest := false, 53 | fork := true, 54 | libraryDependencies ++= Seq( 55 | akkaHttp, 56 | sprayJson, 57 | akkaActor, 58 | slf4j, 59 | akkaSlf4j, 60 | scalaTest % "it,test", 61 | scalaMock % "test", 62 | logback % "it,test", 63 | akkaTestKit % "it,test", 64 | spotifyDocker % "it,test" 65 | ), 66 | ScalariformKeys.preferences := ScalariformKeys.preferences.value 67 | .setPreference(AlignSingleLineCaseStatements, true) 68 | .setPreference(DoubleIndentClassDeclaration, true) 69 | .setPreference(DanglingCloseParenthesis, Preserve) 70 | .setPreference(RewriteArrowSymbols, true) 71 | ) 72 | .configs( IntegrationTest ) 73 | .settings( Defaults.itSettings : _* ) 74 | .settings( SbtScalariform.scalariformSettingsWithIt : _* ) 75 | .dependsOn(dockerTestkit % "it-internal") 76 | 77 | lazy val dockerTestkit = (project in file("docker-testkit")) 78 | .settings( commonSettings: _* ) 79 | .settings( 80 | libraryDependencies ++= Seq( 81 | slf4j, 82 | scalaTest, 83 | spotifyDocker 84 | ) 85 | ) 86 | .configs( IntegrationTest ) 87 | .settings( Defaults.itSettings : _* ) 88 | .settings( SbtScalariform.scalariformSettingsWithIt : _* ) 89 | .settings( publishSettings: _* ) 90 | 91 | 92 | //lazy val example = (project in file("example")) 93 | // .aggregate(client) 94 | // .dependsOn(client, dnsHelper) 95 | // .settings( commonSettings: _* ) 96 | // .settings( 97 | // crossScalaVersions := Seq() 98 | // ) 99 | // .settings( 100 | // libraryDependencies ++= Seq( 101 | // sprayClient, 102 | // sprayRouting, 103 | // sprayJson, 104 | // slf4j, 105 | // logback 106 | // ) 107 | // ) 108 | // .settings( 109 | // fork := true, 110 | // libraryDependencies ++= Seq( 111 | // akkaActor, 112 | // sprayClient, 113 | // sprayJson 114 | // ) 115 | // ) 116 | // .settings( publishSettings: _* ) 117 | // .enablePlugins(JavaAppPackaging) 118 | // .settings( 119 | // packageName in Docker := "reactive-consul-example", 120 | // maintainer in Docker := "Dirk Louwers ", 121 | // dockerExposedPorts in Docker := Seq(8080), 122 | // dockerExposedVolumes in Docker := Seq("/opt/docker/logs") 123 | // ) 124 | 125 | lazy val publishSettings = Seq( 126 | publishArtifact := false, 127 | publishMavenStyle := true, 128 | pomIncludeRepository := { _ => false }, 129 | credentials += Credentials(Path.userHome / ".ivy2" / ".credentials"), 130 | publishTo := { 131 | val nexus = "https://oss.sonatype.org/" 132 | if (isSnapshot.value) 133 | Some("snapshots" at nexus + "content/repositories/snapshots") 134 | else 135 | Some("releases" at nexus + "service/local/staging/deploy/maven2") 136 | }, 137 | pomExtra := ( 138 | http://github.com/dlouwers/reactive-consul 139 | 140 | 141 | MIT 142 | https://opensource.org/licenses/MIT 143 | repo 144 | 145 | 146 | 147 | git@github.com:dlouwers/reactive-consul.git 148 | scm:git@github.com:dlouwers/reactive-consul.git 149 | 150 | 151 | 152 | dlouwers 153 | Dirk Louwers 154 | http://github.com/dlouwers 155 | 156 | 157 | ) 158 | ) 159 | 160 | Revolver.settings.settings 161 | -------------------------------------------------------------------------------- /client/src/it/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | loggers = ["akka.event.slf4j.Slf4jLogger"] 3 | loglevel = "INFO" 4 | logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" 5 | log-dead-letters-during-shutdown = off 6 | } -------------------------------------------------------------------------------- /client/src/it/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/src/it/scala/stormlantern/consul/client/ServiceBrokerIntegrationTest.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client 2 | 3 | import java.net.URL 4 | 5 | import org.scalatest._ 6 | import org.scalatest.concurrent.{ Eventually, IntegrationPatience, ScalaFutures } 7 | import stormlantern.consul.client.dao.akka.AkkaHttpConsulClient 8 | import stormlantern.consul.client.dao.{ ConsulHttpClient, ServiceRegistration } 9 | import stormlantern.consul.client.discovery.{ ConnectionProvider, ConnectionProviderFactory, ConnectionStrategy, ServiceDefinition } 10 | import stormlantern.consul.client.loadbalancers.RoundRobinLoadBalancer 11 | import stormlantern.consul.client.util.{ ConsulDockerContainer, Logging, TestActorSystem } 12 | 13 | import scala.concurrent.Future 14 | 15 | class ServiceBrokerIntegrationTest extends FlatSpec with Matchers with ScalaFutures with Eventually with IntegrationPatience with ConsulDockerContainer with TestActorSystem with Logging { 16 | 17 | import scala.concurrent.ExecutionContext.Implicits.global 18 | 19 | "The ServiceBroker" should "provide a usable connection to consul" in withConsulHost { (host, port) ⇒ 20 | withActorSystem { implicit actorSystem ⇒ 21 | val akkaHttpClient = new AkkaHttpConsulClient(new URL(s"http://$host:$port")) 22 | // Register the HTTP interface 23 | akkaHttpClient.putService(ServiceRegistration("consul-http", Some("consul-http-1"), address = Some(host), port = Some(port))) 24 | akkaHttpClient.putService(ServiceRegistration("consul-http", Some("consul-http-2"), address = Some(host), port = Some(port))) 25 | val connectionProviderFactory = new ConnectionProviderFactory { 26 | override def create(host: String, port: Int): ConnectionProvider = new ConnectionProvider { 27 | logger.info(s"Asked to create connection provider for $host:$port") 28 | val httpClient: ConsulHttpClient = new AkkaHttpConsulClient(new URL(s"http://$host:$port")) 29 | override def getConnection: Future[Any] = Future.successful(httpClient) 30 | } 31 | } 32 | val connectionStrategy = ConnectionStrategy(ServiceDefinition("consul-http"), connectionProviderFactory, new RoundRobinLoadBalancer) 33 | val sut = ServiceBroker(actorSystem, akkaHttpClient, Set(connectionStrategy)) 34 | eventually { 35 | sut.withService("consul-http") { connection: ConsulHttpClient ⇒ 36 | connection.getService("bogus").map(_.resource should have size 0) 37 | } 38 | sut 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/src/it/scala/stormlantern/consul/client/dao/AkkaHttpConsulClientIntegrationTest.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.dao 2 | 3 | import java.net.URL 4 | import java.util.UUID 5 | 6 | import org.scalatest._ 7 | import org.scalatest.concurrent.{ Eventually, IntegrationPatience, ScalaFutures } 8 | import stormlantern.consul.client.dao.akka.AkkaHttpConsulClient 9 | import stormlantern.consul.client.util.{ ConsulDockerContainer, Logging, RetryPolicy, TestActorSystem } 10 | 11 | class AkkaHttpConsulClientIntegrationTest extends FlatSpec with Matchers with ScalaFutures with Eventually with IntegrationPatience with ConsulDockerContainer with TestActorSystem with RetryPolicy with Logging { 12 | 13 | import scala.concurrent.ExecutionContext.Implicits.global 14 | 15 | def withConsulHttpClient[T](f: ConsulHttpClient ⇒ T): T = withConsulHost { (host, port) ⇒ 16 | withActorSystem { implicit actorSystem ⇒ 17 | val subject: ConsulHttpClient = new AkkaHttpConsulClient(new URL(s"http://$host:$port")) 18 | f(subject) 19 | } 20 | } 21 | 22 | "The AkkaHttpConsulClient" should "retrieve a single Consul service from a freshly started Consul instance" in withConsulHttpClient { subject ⇒ 23 | eventually { 24 | subject.getService("consul").map { result ⇒ 25 | result.resource should have size 1 26 | result.resource.head.serviceName shouldEqual "consul" 27 | }.futureValue 28 | } 29 | } 30 | 31 | it should "retrieve no unknown service from a freshly started Consul instance" in withConsulHttpClient { subject ⇒ 32 | eventually { 33 | subject.getService("bogus").map { result ⇒ 34 | logger.info(s"Index is ${result.index}") 35 | result.resource should have size 0 36 | }.futureValue 37 | } 38 | } 39 | 40 | it should "retrieve a single Consul service from a freshly started Consul instance and timeout after the second request if nothing changes" in withConsulHttpClient { subject ⇒ 41 | eventually { 42 | subject.getService("consul").flatMap { result ⇒ 43 | result.resource should have size 1 44 | result.resource.head.serviceName shouldEqual "consul" 45 | subject.getService("consul", None, Some(result.index), Some("500ms")).map { secondResult ⇒ 46 | secondResult.resource should have size 1 47 | secondResult.index shouldEqual result.index 48 | } 49 | }.futureValue 50 | } 51 | } 52 | 53 | it should "register and deregister a new service with Consul" in withConsulHttpClient { subject ⇒ 54 | subject.putService(ServiceRegistration("newservice", Some("newservice-1"))) 55 | .futureValue should equal("newservice-1") 56 | subject.putService(ServiceRegistration("newservice", Some("newservice-2"), check = Some(TTLHealthCheck("2s")))) 57 | .futureValue should equal("newservice-2") 58 | eventually { 59 | subject.getService("newservice").map { result ⇒ 60 | result.resource should have size 2 61 | result.resource.head.serviceName shouldEqual "newservice" 62 | }.futureValue 63 | } 64 | subject.deleteService("newservice-1").futureValue should equal(()) 65 | eventually { 66 | subject.getService("newservice").map { result ⇒ 67 | result.resource should have size 1 68 | result.resource.head.serviceName shouldEqual "newservice" 69 | } 70 | } 71 | } 72 | 73 | it should "retrieve a service matching tags and leave out others" in withConsulHttpClient { subject ⇒ 74 | subject.putService(ServiceRegistration("newservice", Some("newservice-1"), Set("tag1", "tag2"))) 75 | .futureValue should equal("newservice-1") 76 | subject.putService(ServiceRegistration("newservice", Some("newservice-2"), Set("tag2", "tag3"))) 77 | .futureValue should equal("newservice-2") 78 | eventually { 79 | subject.getService("newservice").map { result ⇒ 80 | result.resource should have size 2 81 | result.resource.head.serviceName shouldEqual "newservice" 82 | }.futureValue 83 | subject.getService("newservice", Some("tag2")).map { result ⇒ 84 | result.resource should have size 2 85 | result.resource.head.serviceName shouldEqual "newservice" 86 | }.futureValue 87 | subject.getService("newservice", Some("tag3")).map { result ⇒ 88 | result.resource should have size 1 89 | result.resource.head.serviceName shouldEqual "newservice" 90 | result.resource.head.serviceId shouldEqual "newservice-2" 91 | }.futureValue 92 | } 93 | } 94 | 95 | it should "register a session and get it's ID then read it back" in withConsulHttpClient { subject ⇒ 96 | val id: UUID = subject.putSession(Some(SessionCreation(name = Some("MySession")))).futureValue 97 | subject.getSessionInfo(id).map { sessionInfo ⇒ 98 | sessionInfo should be('defined) 99 | sessionInfo.get.id shouldEqual id 100 | }.futureValue 101 | } 102 | 103 | it should "get a session lock on a key/value pair and fail to get a second lock" in withConsulHttpClient { subject ⇒ 104 | val id: UUID = subject.putSession(Some(SessionCreation(name = Some("MySession")))).futureValue 105 | val payload = """ { "name" : "test" } """.getBytes("UTF-8") 106 | subject.putKeyValuePair("my/key", payload, Some(AcquireSession(id))).futureValue should be(true) 107 | subject.putKeyValuePair("my/key", payload, Some(AcquireSession(id))).futureValue should be(false) 108 | subject.putKeyValuePair("my/key", payload, Some(ReleaseSession(id))).futureValue should be(true) 109 | } 110 | 111 | it should "get a session lock on a key/value pair and get a second lock after release" in withConsulHttpClient { subject ⇒ 112 | val id: UUID = subject.putSession(Some(SessionCreation(name = Some("MySession")))).futureValue 113 | val payload = """ { "name" : "test" } """.getBytes("UTF-8") 114 | subject.putKeyValuePair("my/key", payload, Some(AcquireSession(id))).futureValue should be(true) 115 | subject.putKeyValuePair("my/key", payload, Some(ReleaseSession(id))).futureValue should be(true) 116 | subject.putKeyValuePair("my/key", payload, Some(AcquireSession(id))).futureValue should be(true) 117 | subject.putKeyValuePair("my/key", payload, Some(ReleaseSession(id))).futureValue should be(true) 118 | } 119 | 120 | it should "write a key/value pair and read it back" in withConsulHttpClient { subject ⇒ 121 | val payload = """ { "name" : "test" } """.getBytes("UTF-8") 122 | subject.putKeyValuePair("my/key", payload).futureValue should be(true) 123 | val keyDataSeq = subject.getKeyValuePair("my/key").futureValue 124 | keyDataSeq.head.value should equal(BinaryData(payload)) 125 | } 126 | 127 | it should "fail when aquiring a lock on a key with a non-existent session" in withConsulHttpClient { subject ⇒ 128 | val payload = """ { "name" : "test" } """.getBytes("UTF-8") 129 | val nonExistentSessionId = UUID.fromString("9A3BB9C-E2E7-43DF-BFD5-845417146552") 130 | val result = subject.putKeyValuePair("my/key", payload, Some(AcquireSession(nonExistentSessionId))).futureValue should be(false) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /client/src/it/scala/stormlantern/consul/client/util/ConsulDockerContainer.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client 2 | package util 3 | 4 | import com.spotify.docker.client.messages.ContainerConfig 5 | import org.scalatest.Suite 6 | import stormlantern.dockertestkit.{ DockerClientProvider, DockerContainer } 7 | 8 | import scala.collection.JavaConversions._ 9 | 10 | trait ConsulDockerContainer extends DockerContainer { this: Suite ⇒ 11 | 12 | def image: String = "progrium/consul" 13 | def command: Seq[String] = Seq("-server", "-bootstrap", DockerClientProvider.hostname) 14 | override def containerConfig = ContainerConfig.builder().image(image).hostConfig(hostConfig).cmd(command).build() 15 | 16 | def withConsulHost[T](f: (String, Int) ⇒ T): T = super.withDockerHost("8500/tcp")(f) 17 | } 18 | -------------------------------------------------------------------------------- /client/src/it/scala/stormlantern/consul/client/util/ConsulRegistratorDockerContainer.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.util 2 | 3 | import com.spotify.docker.client.messages.ContainerConfig 4 | import org.scalatest.Suite 5 | import stormlantern.dockertestkit.{ DockerClientProvider, DockerContainers } 6 | 7 | import scala.collection.JavaConversions._ 8 | 9 | trait ConsulRegistratorDockerContainer extends DockerContainers { this: Suite ⇒ 10 | 11 | def consulContainerConfig = { 12 | val image: String = "progrium/consul" 13 | val command: Seq[String] = Seq("-server", "-bootstrap", "-advertise", DockerClientProvider.hostname) 14 | ContainerConfig.builder().image(image).cmd(command).build() 15 | } 16 | 17 | def registratorContainerConfig = { 18 | val hostname = DockerClientProvider.hostname 19 | val image: String = "progrium/registrator" 20 | val command: String = s"consul://$hostname:8500" 21 | val volume: String = "/var/run/docker.sock:/tmp/docker.sock" 22 | ContainerConfig.builder().image(image).cmd(command).hostname(hostname).volumes(volume).build() 23 | } 24 | 25 | override def containerConfigs = Set(consulContainerConfig, registratorContainerConfig) 26 | 27 | def withConsulHost[T](f: (String, Int) ⇒ T): T = super.withDockerHosts(Set("8500/tcp")) { pb ⇒ 28 | val (h, p) = pb("8500/tcp") 29 | f(h, p) 30 | } 31 | } -------------------------------------------------------------------------------- /client/src/it/scala/stormlantern/consul/client/util/TestActorSystem.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.util 2 | 3 | import akka.actor.ActorSystem 4 | 5 | trait TestActorSystem { 6 | def withActorSystem[T](f: ActorSystem ⇒ T): T = { 7 | val actorSystem = ActorSystem("test") 8 | try { 9 | f(actorSystem) 10 | } finally { 11 | actorSystem.terminate() 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /client/src/main/resources/reference.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | loggers = ["akka.event.slf4j.Slf4jLogger"] 3 | loglevel = "DEBUG" 4 | logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" 5 | } -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/ServiceBroker.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client 2 | 3 | import java.net.URL 4 | 5 | import scala.concurrent.duration._ 6 | import scala.concurrent._ 7 | 8 | import akka.actor._ 9 | import akka.util.Timeout 10 | import akka.pattern.ask 11 | 12 | import stormlantern.consul.client.dao._ 13 | import stormlantern.consul.client.dao.akka.AkkaHttpConsulClient 14 | import stormlantern.consul.client.discovery._ 15 | import stormlantern.consul.client.election.LeaderInfo 16 | import stormlantern.consul.client.loadbalancers.LoadBalancerActor 17 | import stormlantern.consul.client.util._ 18 | 19 | class ServiceBroker(serviceBrokerActor: ActorRef, consulClient: ConsulHttpClient)(implicit ec: ExecutionContext) extends RetryPolicy with Logging { 20 | 21 | private[this] implicit val timeout = Timeout(10.seconds) 22 | 23 | def withService[A, B](name: String)(f: A ⇒ Future[B]): Future[B] = { 24 | logger.info(s"Trying to get connection for service $name") 25 | serviceBrokerActor.ask(ServiceBrokerActor.GetServiceConnection(name)).mapTo[ConnectionHolder].flatMap { connectionHolder ⇒ 26 | logger.info(s"Received connectionholder $connectionHolder") 27 | try { 28 | connectionHolder.connection.flatMap(c ⇒ f(c.asInstanceOf[A])) 29 | } finally { 30 | connectionHolder.loadBalancer ! LoadBalancerActor.ReturnConnection(connectionHolder) 31 | } 32 | } 33 | } 34 | 35 | def registerService(registration: ServiceRegistration): Future[Unit] = { 36 | consulClient.putService(registration).map { serviceId ⇒ 37 | // Add shutdown hook 38 | val deregisterService = new Runnable { 39 | override def run(): Unit = consulClient.deleteService(serviceId) 40 | } 41 | Runtime.getRuntime.addShutdownHook(new Thread(deregisterService)) 42 | } 43 | } 44 | 45 | def withLeader[A](key: String)(f: Option[LeaderInfo] ⇒ Future[A]): Future[A] = { 46 | ??? 47 | } 48 | 49 | def joinElection(key: String): Future[Unit] = { 50 | ??? 51 | } 52 | } 53 | 54 | object ServiceBroker { 55 | 56 | def apply(rootActor: ActorSystem, httpClient: ConsulHttpClient, services: Set[ConnectionStrategy]): ServiceBroker = { 57 | implicit val ec = ExecutionContext.Implicits.global 58 | val serviceAvailabilityActorFactory = (factory: ActorRefFactory, service: ServiceDefinition, listener: ActorRef) ⇒ 59 | factory.actorOf(ServiceAvailabilityActor.props(httpClient, service, listener)) 60 | val actorRef = rootActor.actorOf(ServiceBrokerActor.props(services, serviceAvailabilityActorFactory), "ServiceBroker") 61 | new ServiceBroker(actorRef, httpClient) 62 | } 63 | 64 | def apply(consulAddress: URL, services: Set[ConnectionStrategy]): ServiceBroker = { 65 | implicit val rootActor = ActorSystem("reactive-consul") 66 | val httpClient = new AkkaHttpConsulClient(consulAddress) 67 | ServiceBroker(rootActor, httpClient, services) 68 | } 69 | 70 | } 71 | 72 | case class ServiceUnavailableException(service: String) extends RuntimeException(s"$service service unavailable") 73 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/ServiceBrokerActor.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client 2 | 3 | import java.util.UUID 4 | 5 | import akka.actor.Status.Failure 6 | import akka.actor._ 7 | import akka.util.Timeout 8 | import stormlantern.consul.client.dao.ServiceInstance 9 | import stormlantern.consul.client.discovery.{ ConnectionStrategy, ServiceAvailabilityActor, ServiceDefinition } 10 | import stormlantern.consul.client.loadbalancers.LoadBalancerActor 11 | import stormlantern.consul.client.loadbalancers.LoadBalancerActor.{ GetConnection, HasAvailableConnectionProvider } 12 | import ServiceAvailabilityActor._ 13 | import stormlantern.consul.client.ServiceBrokerActor._ 14 | 15 | import scala.collection.mutable 16 | import scala.concurrent.{ ExecutionContext, Future } 17 | import scala.concurrent.duration._ 18 | 19 | class ServiceBrokerActor( 20 | services: Set[ConnectionStrategy], 21 | serviceAvailabilityActorFactory: (ActorRefFactory, ServiceDefinition, ActorRef) ⇒ ActorRef)(implicit ec: ExecutionContext) 22 | extends Actor with ActorLogging with Stash { 23 | 24 | // Actor state 25 | val indexedServices: Map[String, ConnectionStrategy] = services.map(s ⇒ (s.serviceDefinition.key, s)).toMap 26 | val loadbalancers: mutable.Map[String, ActorRef] = mutable.Map.empty 27 | val serviceAvailability: mutable.Set[ActorRef] = mutable.Set.empty 28 | val sessionId: Option[UUID] = None 29 | var initializationCountdown: Int = services.size 30 | 31 | override def preStart(): Unit = { 32 | indexedServices.foreach { 33 | case (key, strategy) ⇒ 34 | loadbalancers.put(key, strategy.loadBalancerFactory(context)) 35 | log.info(s"Starting service availability Actor for $key") 36 | val serviceAvailabilityActorRef = serviceAvailabilityActorFactory(context, strategy.serviceDefinition, self) 37 | serviceAvailabilityActorRef ! Start 38 | serviceAvailability += serviceAvailabilityActorRef 39 | } 40 | } 41 | 42 | def receive: Receive = { 43 | case Started ⇒ 44 | log.debug(s"Service availability initialized for ${sender()}") 45 | initializationCountdown -= 1 46 | if (initializationCountdown == 0) { 47 | unstashAll() 48 | } 49 | case ServiceAvailabilityUpdate(key, added, removed) ⇒ 50 | log.debug(s"Adding connection providers for $key: $added") 51 | addConnectionProviders(key, added) 52 | log.debug(s"Removing conection providers for $key: $removed") 53 | removeConnectionProviders(key, removed) 54 | case GetServiceConnection(key: String) ⇒ 55 | if (initializationCountdown != 0) { 56 | stash() 57 | } else { 58 | log.debug(s"Getting a service connection for $key") 59 | loadbalancers.get(key) match { 60 | case Some(loadbalancer) ⇒ 61 | loadbalancer forward GetConnection 62 | case None ⇒ 63 | sender ! Failure(ServiceUnavailableException(key)) 64 | } 65 | } 66 | case HasAvailableConnectionProviderFor(key: String) ⇒ 67 | loadbalancers.get(key) match { 68 | case Some(loadbalancer) ⇒ 69 | loadbalancer forward HasAvailableConnectionProvider 70 | case None ⇒ 71 | sender ! false 72 | } 73 | case AllConnectionProvidersAvailable ⇒ 74 | import akka.pattern.pipe 75 | queryConnectionProviderAvailability pipeTo sender 76 | case JoinElection(key) ⇒ 77 | } 78 | 79 | // Internal methods 80 | def addConnectionProviders(key: String, added: Set[ServiceInstance]): Unit = { 81 | added.foreach { s ⇒ 82 | val host = if (s.serviceAddress.isEmpty) s.address else s.serviceAddress 83 | val connectionProvider = indexedServices(key).connectionProviderFactory.create(host, s.servicePort) 84 | loadbalancers(key) ! LoadBalancerActor.AddConnectionProvider(s.serviceId, connectionProvider) 85 | } 86 | } 87 | 88 | def removeConnectionProviders(key: String, removed: Set[ServiceInstance]): Unit = { 89 | removed.foreach { s ⇒ 90 | loadbalancers(key) ! LoadBalancerActor.RemoveConnectionProvider(s.serviceId) 91 | } 92 | } 93 | 94 | def queryConnectionProviderAvailability: Future[Boolean] = { 95 | implicit val timeout: Timeout = 1.second 96 | import akka.pattern.ask 97 | Future.sequence(loadbalancers.values.map(_.ask(LoadBalancerActor.HasAvailableConnectionProvider).mapTo[Boolean])) 98 | .map(_.forall(p ⇒ p)) 99 | } 100 | } 101 | 102 | object ServiceBrokerActor { 103 | // Constructors 104 | def props( 105 | services: Set[ConnectionStrategy], 106 | serviceAvailabilityActorFactory: (ActorRefFactory, ServiceDefinition, ActorRef) ⇒ ActorRef)(implicit ec: ExecutionContext): Props = 107 | Props(new ServiceBrokerActor(services, serviceAvailabilityActorFactory)) 108 | case class GetServiceConnection(key: String) 109 | case object Stop 110 | case class HasAvailableConnectionProviderFor(key: String) 111 | case object AllConnectionProvidersAvailable 112 | case class JoinElection(key: String) 113 | } 114 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/dao/BinaryData.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.dao 2 | 3 | import java.util 4 | 5 | case class BinaryData(data: Array[Byte]) { 6 | 7 | override def equals(other: Any): Boolean = { 8 | if (this.canEqual(other)) { 9 | util.Arrays.equals(this.data, other.asInstanceOf[BinaryData].data) 10 | } else { 11 | false 12 | } 13 | } 14 | 15 | override def hashCode(): Int = { 16 | util.Arrays.hashCode(this.data) 17 | } 18 | 19 | override def toString: String = { 20 | "[ " + this.data.map(_.toString).mkString(", ") + " ]" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/dao/ConsulHttpClient.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.dao 2 | 3 | import java.util.UUID 4 | 5 | import scala.concurrent.Future 6 | 7 | trait ConsulHttpClient { 8 | def getService( 9 | service: String, 10 | tag: Option[String] = None, 11 | index: Option[Long] = None, 12 | wait: Option[String] = None, 13 | dataCenter: Option[String] = None 14 | ): Future[IndexedServiceInstances] 15 | def putService(registration: ServiceRegistration): Future[String] 16 | def deleteService(serviceId: String): Future[Unit] 17 | def putSession( 18 | sessionCreation: Option[SessionCreation] = None, 19 | dataCenter: Option[String] = None 20 | ): Future[UUID] 21 | def getSessionInfo(sessionId: UUID, index: Option[Long] = None, dataCenter: Option[String] = None): Future[Option[SessionInfo]] 22 | def putKeyValuePair(key: String, value: Array[Byte], sessionOp: Option[SessionOp] = None): Future[Boolean] 23 | def getKeyValuePair( 24 | key: String, 25 | index: Option[Long] = None, 26 | wait: Option[String] = None, 27 | recurse: Boolean = false, 28 | keysOnly: Boolean = false 29 | ): Future[Seq[KeyData]] 30 | } 31 | 32 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/dao/ConsulHttpProtocol.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.dao 2 | 3 | import java.util.UUID 4 | 5 | import spray.json._ 6 | 7 | import scala.util.control.NonFatal 8 | import java.util.Base64 9 | 10 | trait ConsulHttpProtocol extends DefaultJsonProtocol { 11 | 12 | implicit val uuidFormat = new JsonFormat[UUID] { 13 | override def read(json: JsValue): UUID = json match { 14 | case JsString(uuid) ⇒ try { 15 | UUID.fromString(uuid) 16 | } catch { 17 | case NonFatal(e) ⇒ deserializationError("Expected UUID, but got " + uuid) 18 | } 19 | case x ⇒ deserializationError("Expected UUID as JsString, but got " + x) 20 | } 21 | 22 | override def write(obj: UUID): JsValue = JsString(obj.toString) 23 | } 24 | 25 | implicit val binaryDataFormat = new JsonFormat[BinaryData] { 26 | override def read(json: JsValue): BinaryData = json match { 27 | case JsString(data) ⇒ try { 28 | BinaryData(Base64.getMimeDecoder.decode(data)) 29 | } catch { 30 | case NonFatal(e) ⇒ deserializationError("Expected base64 encoded binary data, but got " + data) 31 | } 32 | case x ⇒ deserializationError("Expected base64 encoded binary data as JsString, but got " + x) 33 | } 34 | 35 | override def write(obj: BinaryData): JsValue = JsString(Base64.getMimeEncoder.encodeToString(obj.data)) 36 | } 37 | 38 | implicit val serviceFormat = jsonFormat( 39 | (node: String, address: String, serviceId: String, serviceName: String, serviceTags: Option[Set[String]], serviceAddress: String, servicePort: Int) ⇒ 40 | ServiceInstance(node, address, serviceId, serviceName, serviceTags.getOrElse(Set.empty), serviceAddress, servicePort), 41 | "Node", "Address", "ServiceID", "ServiceName", "ServiceTags", "ServiceAddress", "ServicePort" 42 | ) 43 | implicit val httpCheckFormat = jsonFormat(HttpHealthCheck, "HTTP", "Interval") 44 | implicit val scriptCheckFormat = jsonFormat(ScriptHealthCheck, "Script", "Interval") 45 | implicit val ttlCheckFormat = jsonFormat(TTLHealthCheck, "TTL") 46 | implicit val checkWriter = lift { 47 | new JsonWriter[HealthCheck] { 48 | override def write(obj: HealthCheck): JsValue = obj match { 49 | case obj: ScriptHealthCheck ⇒ obj.toJson 50 | case obj: HttpHealthCheck ⇒ obj.toJson 51 | case obj: TTLHealthCheck ⇒ obj.toJson 52 | } 53 | } 54 | } 55 | implicit val serviceRegistrationFormat = jsonFormat(ServiceRegistration, "Name", "ID", "Tags", "Address", "Port", "Check") 56 | implicit val sessionCreationFormat = jsonFormat(SessionCreation, "LockDelay", "Name", "Node", "Checks", "Behavior", "TTL") 57 | implicit val keyDataFormat = jsonFormat(KeyData, "Key", "CreateIndex", "ModifyIndex", "LockIndex", "Flags", "Value", "Session") 58 | implicit val sessionInfoFormat = jsonFormat(SessionInfo, "LockDelay", "Checks", "Node", "ID", "CreateIndex", "Name", "Behavior", "TTL") 59 | } 60 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/dao/HealthCheck.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.dao 2 | 3 | sealed trait HealthCheck 4 | case class ScriptHealthCheck(script: String, interval: String) extends HealthCheck 5 | case class HttpHealthCheck(http: String, interval: String) extends HealthCheck 6 | case class TTLHealthCheck(ttl: String) extends HealthCheck 7 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/dao/Indexed.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.dao 2 | 3 | trait Indexed[T] { 4 | def index: Long 5 | def resource: T 6 | } 7 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/dao/IndexedServiceInstances.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.dao 2 | 3 | //[ 4 | // { 5 | // "Node": "foobar", 6 | // "Address": "10.1.10.12", 7 | // "ServiceID": "redis", 8 | // "ServiceName": "redis", 9 | // "ServiceTags": null, 10 | // "ServiceAddress": "", 11 | // "ServicePort": 8000 12 | // } 13 | //] 14 | 15 | case class ServiceInstance( 16 | node: String, 17 | address: String, 18 | serviceId: String, 19 | serviceName: String, 20 | serviceTags: Set[String], 21 | serviceAddress: String, 22 | servicePort: Int 23 | ) 24 | 25 | case class IndexedServiceInstances(index: Long, resource: Set[ServiceInstance]) extends Indexed[Set[ServiceInstance]] { 26 | def filterForTags(tags: Set[String]): IndexedServiceInstances = { 27 | this.copy(resource = resource.filter { s ⇒ 28 | tags.forall(s.serviceTags.contains) 29 | }) 30 | } 31 | } 32 | 33 | object IndexedServiceInstances { 34 | def empty = IndexedServiceInstances(0, Set.empty) 35 | } 36 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/dao/KeyData.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.dao 2 | 3 | import java.util.UUID 4 | 5 | /** 6 | * [ 7 | * { 8 | * "CreateIndex": 100, 9 | * "ModifyIndex": 200, 10 | * "LockIndex": 200, 11 | * "Key": "zip", 12 | * "Flags": 0, 13 | * "Value": "dGVzdA==", 14 | * "Session": "adf4238a-882b-9ddc-4a9d-5b6758e4159e" 15 | * } 16 | * ] 17 | */ 18 | case class KeyData( 19 | key: String, 20 | createIndex: Long, 21 | modifyIndex: Long, 22 | lockIndex: Long, 23 | flags: Long, 24 | value: BinaryData, 25 | session: Option[UUID] 26 | ) 27 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/dao/ServiceRegistration.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.dao 2 | 3 | //{ 4 | // "ID": "redis1", 5 | // "Name": "redis", 6 | // "Tags": [ 7 | // "master", 8 | // "v1" 9 | // ], 10 | // "Address": "127.0.0.1", 11 | // "Port": 8000, 12 | // "Check": { 13 | // "Script": "/usr/local/bin/check_redis.py", 14 | // "HTTP": "http://localhost:5000/health", 15 | // "Interval": "10s", 16 | // "TTL": "15s" 17 | // } 18 | //} 19 | case class ServiceRegistration( 20 | name: String, 21 | id: Option[String] = None, 22 | tags: Set[String] = Set.empty, 23 | address: Option[String] = None, 24 | port: Option[Long] = None, 25 | check: Option[HealthCheck] = None 26 | ) -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/dao/SessionCreation.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.dao 2 | 3 | case class SessionCreation( 4 | lockDelay: Option[String] = None, 5 | name: Option[String] = None, 6 | node: Option[String] = None, 7 | checks: Set[HealthCheck] = Set.empty, 8 | behavior: Option[String] = None, 9 | TTL: Option[String] = None 10 | ) -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/dao/SessionInfo.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.dao 2 | 3 | import java.util.UUID 4 | 5 | case class SessionInfo( 6 | lockDelay: Long, 7 | checks: Set[String], 8 | node: String, 9 | id: UUID, 10 | createIndex: Long, 11 | name: Option[String], 12 | behavior: String, 13 | TTL: String 14 | ) 15 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/dao/SessionOp.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.dao 2 | 3 | import java.util.UUID 4 | 5 | sealed trait SessionOp 6 | case class AcquireSession(id: UUID) extends SessionOp 7 | case class ReleaseSession(id: UUID) extends SessionOp 8 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/dao/akka/AkkaHttpConsulClient.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.dao.akka 2 | 3 | import java.net.URL 4 | import java.util.UUID 5 | 6 | import akka.actor.{ ActorSystem, Scheduler } 7 | import akka.http.scaladsl.Http 8 | import akka.http.scaladsl.model.{ HttpHeader, StatusCode, _ } 9 | import akka.stream.{ ActorMaterializer, Materializer } 10 | import akka.util.ByteString 11 | import spray.json._ 12 | import stormlantern.consul.client.dao._ 13 | import stormlantern.consul.client.util.{ Logging, RetryPolicy } 14 | 15 | import scala.concurrent.{ ExecutionContextExecutor, Future } 16 | import scala.util.{ Failure, Success, Try } 17 | 18 | class AkkaHttpConsulClient(host: URL)(implicit actorSystem: ActorSystem) extends ConsulHttpClient 19 | with ConsulHttpProtocol with RetryPolicy with Logging { 20 | 21 | implicit val executionContext: ExecutionContextExecutor = actorSystem.dispatcher 22 | implicit val scheduler: Scheduler = actorSystem.scheduler 23 | implicit val materializer: Materializer = ActorMaterializer() 24 | 25 | private val JsonMediaType = ContentTypes.`application/json`.mediaType 26 | private val TextMediaType = ContentTypes.`text/plain(UTF-8)`.mediaType 27 | 28 | // 29 | // Services 30 | // ///////////////// 31 | def getService(service: String, tag: Option[String] = None, index: Option[Long] = None, wait: Option[String] = None, dataCenter: Option[String] = None): Future[IndexedServiceInstances] = { 32 | val dcParameter = dataCenter.map(dc ⇒ s"dc=$dc") 33 | val waitParameter = wait.map(w ⇒ s"wait=$w") 34 | val indexParameter = index.map(i ⇒ s"index=$i") 35 | val tagParameter = tag.map(t ⇒ s"tag=$t") 36 | val parameters = Seq(dcParameter, tagParameter, waitParameter, indexParameter).flatten.mkString("&") 37 | val request: HttpRequest = HttpRequest(HttpMethods.GET).withUri(s"$host/v1/catalog/service/$service?$parameters") 38 | 39 | retry[IndexedServiceInstances]() { 40 | getResponse(request, JsonMediaType).flatMap { response ⇒ 41 | validIndex(response).map { idx ⇒ 42 | val services = response.body.parseJson.convertTo[Option[Set[ServiceInstance]]] 43 | IndexedServiceInstances(idx, services.getOrElse(Set.empty[ServiceInstance])) 44 | } 45 | } 46 | } 47 | } 48 | 49 | def putService(registration: ServiceRegistration): Future[String] = { 50 | val request = HttpRequest(HttpMethods.PUT).withUri(s"$host/v1/agent/service/register") 51 | .withEntity(registration.toJson.asJsObject().toString.getBytes) 52 | 53 | retry[ConsulResponse]() { 54 | getResponse(request, TextMediaType) 55 | }.map(r ⇒ registration.id.getOrElse(registration.name)) 56 | } 57 | 58 | def deleteService(serviceId: String): Future[Unit] = { 59 | val request = HttpRequest(HttpMethods.PUT).withUri(s"$host/v1/agent/service/deregister/$serviceId") 60 | 61 | retry[ConsulResponse]() { 62 | getResponse(request, TextMediaType) 63 | }.map(r ⇒ ()) 64 | } 65 | 66 | // 67 | // Sessions 68 | // ///////////////// 69 | def putSession(sessionCreation: Option[SessionCreation], dataCenter: Option[String]): Future[UUID] = { 70 | val dcParameter = dataCenter.map(dc ⇒ s"dc=$dc") 71 | val parameters = Seq(dcParameter).flatten.mkString("&") 72 | val request = sessionCreation.map(_.toJson.asJsObject.toString.getBytes) match { 73 | case None ⇒ HttpRequest(HttpMethods.PUT).withUri(s"$host/v1/session/create?$parameters") 74 | case Some(entity) ⇒ HttpRequest(HttpMethods.PUT).withUri(s"$host/v1/session/create?$parameters").withEntity(entity) 75 | } 76 | 77 | retry[UUID]() { 78 | getResponse(request, JsonMediaType).map { response ⇒ 79 | response.body.parseJson.asJsObject.fields("ID").convertTo[UUID] 80 | } 81 | } 82 | } 83 | 84 | def getSessionInfo(sessionId: UUID, index: Option[Long], dataCenter: Option[String]): Future[Option[SessionInfo]] = { 85 | val dcParameter = dataCenter.map(dc ⇒ s"dc=$dc") 86 | val indexParameter = index.map(i ⇒ s"index=$i") 87 | val parameters = Seq(dcParameter, indexParameter).flatten.mkString("&") 88 | val request = HttpRequest(HttpMethods.GET).withUri(s"$host/v1/session/info/$sessionId?$parameters") 89 | 90 | retry[Option[SessionInfo]]() { 91 | getResponse(request, JsonMediaType).map { response ⇒ 92 | response.body.parseJson.convertTo[Option[Set[SessionInfo]]].getOrElse(Set.empty).headOption 93 | } 94 | } 95 | } 96 | 97 | // 98 | // Key Values 99 | // ///////////////// 100 | def putKeyValuePair(key: String, value: Array[Byte], sessionOp: Option[SessionOp]): Future[Boolean] = { 101 | import StatusCodes._ 102 | 103 | val opParameter = sessionOp.map { 104 | case AcquireSession(id) ⇒ s"acquire=$id" 105 | case ReleaseSession(id) ⇒ s"release=$id" 106 | } 107 | val parameters = opParameter.getOrElse("") 108 | val request = HttpRequest(HttpMethods.PUT).withUri(s"$host/v1/kv/$key?$parameters").withEntity(value) 109 | 110 | def validator(response: HttpResponse): Boolean = response.status.isSuccess() || response.status == InternalServerError 111 | 112 | retry[Boolean]() { 113 | getResponse(request, JsonMediaType, validator).flatMap { 114 | case ConsulResponse(OK, _, body) ⇒ Future successful Option(body.toBoolean).getOrElse(false) 115 | case ConsulResponse(InternalServerError, _, "Invalid session") ⇒ Future successful false 116 | case ConsulResponse(status, _, body) ⇒ Future failed new Exception(s"Request returned status code $status - $body") 117 | } 118 | } 119 | } 120 | 121 | def getKeyValuePair(key: String, index: Option[Long], wait: Option[String], recurse: Boolean, keysOnly: Boolean): Future[Seq[KeyData]] = { 122 | 123 | val waitParameter = wait.map(p ⇒ s"wait=$p") 124 | val indexParameter = index.map(p ⇒ s"index=$p") 125 | val recurseParameter = if (recurse) Some("recurse") else None 126 | val keysOnlyParameter = if (keysOnly) Some("keys") else None 127 | val parameters = Seq(indexParameter, waitParameter, recurseParameter, keysOnlyParameter).flatten.mkString("&") 128 | val request = HttpRequest(HttpMethods.GET).withUri(s"$host/v1/kv/$key?$parameters") 129 | 130 | retry[Seq[KeyData]]() { 131 | getResponse(request, JsonMediaType, _ ⇒ true).map { response ⇒ 132 | if (response.status == StatusCodes.NotFound) { 133 | Seq.empty 134 | } else { 135 | response.body.parseJson.convertTo[Seq[KeyData]] 136 | } 137 | } 138 | } 139 | } 140 | 141 | // 142 | // Internal Helpers 143 | // ////////////////////////// 144 | private def getResponse[T, U](request: HttpRequest, expectedMediaType: MediaType, validator: HttpResponse ⇒ Boolean = (in) ⇒ in.status.isSuccess()): Future[ConsulResponse] = { 145 | 146 | def validStatus(response: HttpResponse) = if (validator(response)) { 147 | Future successful response 148 | } else { 149 | parseBody(response).flatMap { body ⇒ Future failed ConsulException(s"Bad status code: ${response.status.intValue()} with body $body") } 150 | } 151 | 152 | // 153 | // Consul does not return the Charset with the Response Content Type, so just MediaType comparison 154 | // Furthermore, when an error is returned the content type is text/plain, thank you HashiCorp ... 155 | // ///////////////////// 156 | def validContenType(resp: HttpResponse) = { 157 | val expected = resp.status match { 158 | case st if st.isSuccess() ⇒ expectedMediaType 159 | case st if st.isFailure() ⇒ TextMediaType 160 | case st if st.isRedirection() ⇒ TextMediaType // this is a guess 161 | } 162 | 163 | if (resp.entity.contentType.mediaType == expected) { 164 | Future successful resp 165 | } else { 166 | Future failed ConsulException(resp.status, s"Unexpected content type: ${resp.entity.contentType}, expected $expectedMediaType") 167 | } 168 | } 169 | 170 | def parseBody(response: HttpResponse): Future[String] = { 171 | response.entity.dataBytes.runFold(ByteString(""))(_ ++ _).map(_.utf8String) 172 | } 173 | 174 | // make the call 175 | Http() 176 | .singleRequest(request) 177 | .flatMap(validStatus) 178 | .flatMap(validContenType) 179 | .flatMap { response: HttpResponse ⇒ 180 | parseBody(response).map { body: String ⇒ 181 | ConsulResponse(response.status, response.headers, body) 182 | } 183 | } 184 | } 185 | 186 | private def validIndex(response: ConsulResponse): Future[Long] = response.headers.find(_.name() == "X-Consul-Index") match { 187 | case None ⇒ Future failed ConsulException("X-Consul-Index header not found") 188 | case Some(hdr) ⇒ Try(hdr.value.toLong) match { 189 | case Success(idx) ⇒ Future successful idx 190 | case Failure(ex) ⇒ Future failed ConsulException("X-Consul-Index header was not numeric") 191 | } 192 | } 193 | } 194 | 195 | // 196 | // Internal Objects 197 | // ////////////////////////// 198 | case class ConsulResponse(status: StatusCode, headers: Seq[HttpHeader], body: String) 199 | 200 | case class ConsulException(message: String, response: HttpResponse, status: Option[StatusCode] = None) extends Exception(message) 201 | object ConsulException { 202 | def apply(status: StatusCode, msg: String) = new ConsulException(msg, null, Option(status)) // I feel dirty after this 203 | def apply(msg: String) = new ConsulException(msg, null) // I feel dirty after this 204 | 205 | } 206 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/discovery/ConnectionHolder.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.discovery 2 | 3 | import akka.actor.ActorRef 4 | 5 | import scala.concurrent.Future 6 | 7 | trait ConnectionHolder { 8 | def id: String 9 | def loadBalancer: ActorRef 10 | def connection: Future[Any] 11 | } 12 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/discovery/ConnectionProvider.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.discovery 2 | 3 | import akka.actor.ActorRef 4 | 5 | import scala.concurrent.Future 6 | import scala.concurrent.ExecutionContext.Implicits.global 7 | 8 | trait ConnectionProvider { 9 | def getConnection: Future[Any] 10 | def returnConnection(connectionHolder: ConnectionHolder): Unit = () 11 | def destroy(): Unit = () 12 | def getConnectionHolder(i: String, lb: ActorRef): Future[ConnectionHolder] = getConnection.map { connection ⇒ 13 | new ConnectionHolder { 14 | override def connection: Future[Any] = getConnection 15 | override val loadBalancer: ActorRef = lb 16 | override val id: String = i 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/discovery/ConnectionProviderFactory.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.discovery 2 | 3 | trait ConnectionProviderFactory { 4 | def create(host: String, port: Int): ConnectionProvider 5 | } 6 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/discovery/ConnectionStrategy.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.discovery 2 | 3 | import akka.actor.{ ActorRef, ActorRefFactory } 4 | import stormlantern.consul.client.loadbalancers.{ LoadBalancer, LoadBalancerActor, RoundRobinLoadBalancer } 5 | 6 | case class ServiceDefinition(key: String, serviceName: String, serviceTags: Set[String] = Set.empty, dataCenter: Option[String] = None) 7 | object ServiceDefinition { 8 | 9 | def apply(serviceName: String): ServiceDefinition = { 10 | ServiceDefinition(serviceName, serviceName) 11 | } 12 | 13 | def apply(serviceName: String, serviceTags: Set[String]): ServiceDefinition = { 14 | ServiceDefinition(serviceName, serviceName, serviceTags) 15 | } 16 | 17 | } 18 | 19 | case class ConnectionStrategy( 20 | serviceDefinition: ServiceDefinition, 21 | connectionProviderFactory: ConnectionProviderFactory, 22 | loadBalancerFactory: ActorRefFactory ⇒ ActorRef 23 | ) 24 | 25 | object ConnectionStrategy { 26 | 27 | def apply(serviceDefinition: ServiceDefinition, connectionProviderFactory: ConnectionProviderFactory, loadBalancer: LoadBalancer): ConnectionStrategy = 28 | ConnectionStrategy(serviceDefinition, connectionProviderFactory, ctx ⇒ ctx.actorOf(LoadBalancerActor.props(loadBalancer, serviceDefinition.key))) 29 | 30 | def apply(serviceDefinition: ServiceDefinition, connectionProviderFactory: (String, Int) ⇒ ConnectionProvider, loadBalancer: LoadBalancer): ConnectionStrategy = { 31 | val cpf = new ConnectionProviderFactory { 32 | override def create(host: String, port: Int): ConnectionProvider = connectionProviderFactory(host, port) 33 | } 34 | ConnectionStrategy(serviceDefinition, cpf, ctx ⇒ ctx.actorOf(LoadBalancerActor.props(loadBalancer, serviceDefinition.key))) 35 | } 36 | 37 | def apply(serviceName: String, connectionProviderFactory: (String, Int) ⇒ ConnectionProvider, loadBalancer: LoadBalancer): ConnectionStrategy = { 38 | ConnectionStrategy(ServiceDefinition(serviceName), connectionProviderFactory, loadBalancer) 39 | } 40 | 41 | def apply(serviceName: String, connectionProviderFactory: (String, Int) ⇒ ConnectionProvider): ConnectionStrategy = { 42 | ConnectionStrategy(serviceName, connectionProviderFactory, new RoundRobinLoadBalancer) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/discovery/ServiceAvailabilityActor.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client 2 | package discovery 3 | 4 | import scala.concurrent.{ ExecutionContext, Future } 5 | import akka.actor._ 6 | import akka.pattern.pipe 7 | import dao._ 8 | import ServiceAvailabilityActor._ 9 | 10 | class ServiceAvailabilityActor(httpClient: ConsulHttpClient, serviceDefinition: ServiceDefinition, listener: ActorRef) extends Actor { 11 | 12 | implicit val ec: ExecutionContext = context.dispatcher 13 | 14 | // Actor state 15 | var initialized = false 16 | var serviceAvailabilityState: IndexedServiceInstances = IndexedServiceInstances.empty 17 | 18 | def receive: Receive = { 19 | case Start ⇒ 20 | self ! UpdateServiceAvailability(None) 21 | case UpdateServiceAvailability(services: Option[IndexedServiceInstances]) ⇒ 22 | val (update, serviceChange) = updateServiceAvailability(services.getOrElse(IndexedServiceInstances.empty)) 23 | update.foreach(listener ! _) 24 | if (!initialized && services.isDefined) { 25 | initialized = true 26 | listener ! Started 27 | } 28 | serviceChange.map(changes ⇒ UpdateServiceAvailability(Some(changes))) pipeTo self 29 | } 30 | 31 | def updateServiceAvailability(services: IndexedServiceInstances): (Option[ServiceAvailabilityUpdate], Future[IndexedServiceInstances]) = { 32 | val update = if (serviceAvailabilityState.index != services.index) { 33 | val oldServices = serviceAvailabilityState 34 | serviceAvailabilityState = services.filterForTags(serviceDefinition.serviceTags) 35 | Some(createServiceAvailabilityUpdate(oldServices, serviceAvailabilityState)) 36 | } else { 37 | None 38 | } 39 | (update, httpClient.getService( 40 | serviceDefinition.serviceName, 41 | serviceDefinition.serviceTags.headOption, 42 | Some(services.index), 43 | Some("1s") 44 | )) 45 | } 46 | 47 | def createServiceAvailabilityUpdate(oldState: IndexedServiceInstances, newState: IndexedServiceInstances): ServiceAvailabilityUpdate = { 48 | val deleted = oldState.resource.diff(newState.resource) 49 | val added = newState.resource.diff(oldState.resource) 50 | ServiceAvailabilityUpdate(serviceDefinition.key, added, deleted) 51 | } 52 | 53 | } 54 | 55 | object ServiceAvailabilityActor { 56 | 57 | def props(httpClient: ConsulHttpClient, serviceDefinition: ServiceDefinition, listener: ActorRef): Props = Props(new ServiceAvailabilityActor(httpClient, serviceDefinition, listener)) 58 | 59 | // Messages 60 | case object Start 61 | case object Started 62 | case object Initialized 63 | private case class UpdateServiceAvailability(services: Option[IndexedServiceInstances]) 64 | private[client] case class ServiceAvailabilityUpdate(key: String, added: Set[ServiceInstance] = Set.empty, 65 | removed: Set[ServiceInstance] = Set.empty) 66 | } 67 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/election/LeaderFollowerActor.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.election 2 | 3 | import java.util.UUID 4 | 5 | import akka.actor.{ Actor, Props } 6 | import spray.json._ 7 | import stormlantern.consul.client.dao.{ AcquireSession, BinaryData, ConsulHttpClient, KeyData } 8 | import stormlantern.consul.client.election.LeaderFollowerActor._ 9 | 10 | class LeaderFollowerActor(httpClient: ConsulHttpClient, sessionId: UUID, key: String, host: String, port: Int) extends Actor with DefaultJsonProtocol { 11 | 12 | implicit val ec = context.dispatcher 13 | 14 | implicit val leaderInfoFormat = jsonFormat2(LeaderInfo) 15 | val leaderInfoBytes = LeaderInfo(host, port).toJson.compactPrint.getBytes("UTF-8") 16 | 17 | // Actor state 18 | var electionState: Option[ElectionState] = None 19 | 20 | // Behavior 21 | def receive = { 22 | case Participate ⇒ 23 | httpClient.putKeyValuePair(key, leaderInfoBytes, Some(AcquireSession(sessionId))).map { 24 | case true ⇒ 25 | self ! SetElectionState(Some(Leader)) 26 | self ! MonitorLock(0) 27 | case false ⇒ 28 | self ! MonitorLock(0) 29 | } 30 | case SetElectionState(state) ⇒ 31 | electionState = state 32 | case MonitorLock(index) ⇒ 33 | httpClient.getKeyValuePair(key, index = Some(index), wait = Some("1s")).map { 34 | case Seq(KeyData(_, _, newIndex, _, _, BinaryData(data), session)) ⇒ 35 | if (newIndex > index) { 36 | if (session.isEmpty) { 37 | self ! SetElectionState(None) 38 | self ! Participate 39 | } else if (session.get == sessionId) { 40 | self ! SetElectionState(Some(Leader)) 41 | self ! MonitorLock(newIndex) 42 | } else { 43 | val leaderInfo = new String(data, "UTF-8").parseJson.convertTo[LeaderInfo](leaderInfoFormat) 44 | self ! SetElectionState(Some(Follower(leaderInfo.host, leaderInfo.port))) 45 | self ! MonitorLock(newIndex) 46 | } 47 | } else { 48 | self ! MonitorLock(index) 49 | } 50 | } 51 | } 52 | } 53 | 54 | object LeaderFollowerActor { 55 | 56 | //Props 57 | def props(httpClient: ConsulHttpClient, sessionId: UUID, key: String, host: String, port: Int): Props = 58 | Props(new LeaderFollowerActor(httpClient, sessionId, key, host, port)) 59 | 60 | // Election state 61 | sealed trait ElectionState 62 | case object Leader extends ElectionState 63 | case class Follower(host: String, port: Int) extends ElectionState 64 | 65 | // Internal messages 66 | case object Participate 67 | case class SetElectionState(state: Option[ElectionState]) 68 | case class MonitorLock(lastIndex: Long) 69 | } -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/election/LeaderInfo.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.election 2 | 3 | case class LeaderInfo(host: String, port: Int) 4 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/loadbalancers/CircularLinkedHashSet.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.loadbalancers 2 | 3 | import scala.collection.AbstractIterator 4 | import scala.collection.mutable 5 | 6 | class CircularLinkedHashSet[A] extends mutable.LinkedHashSet[A] { 7 | override def iterator: Iterator[A] = new AbstractIterator[A] { 8 | private var cur = firstEntry 9 | def hasNext: Boolean = firstEntry ne null 10 | def next(): A = 11 | if (hasNext) { 12 | val res = cur.key 13 | if (cur.later == null) 14 | cur = firstEntry 15 | else 16 | cur = cur.later 17 | res 18 | } else Iterator.empty.next() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/loadbalancers/LoadBalancer.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.loadbalancers 2 | 3 | trait LoadBalancer { 4 | 5 | def selectConnection: Option[String] 6 | def connectionReturned(key: String): Unit = () 7 | def connectionProviderAdded(key: String): Unit = () 8 | def connectionProviderRemoved(key: String): Unit = () 9 | } 10 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/loadbalancers/LoadBalancerActor.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.loadbalancers 2 | 3 | import akka.actor.Status.Failure 4 | import akka.actor.{ Props, Actor, ActorLogging } 5 | import LoadBalancerActor._ 6 | import stormlantern.consul.client.discovery.{ ConnectionProvider, ConnectionHolder } 7 | import stormlantern.consul.client.ServiceUnavailableException 8 | import scala.concurrent.ExecutionContext.Implicits.global 9 | import scala.collection.mutable 10 | 11 | class LoadBalancerActor(loadBalancer: LoadBalancer, key: String) extends Actor with ActorLogging { 12 | 13 | import akka.pattern.pipe 14 | 15 | // Actor state 16 | val connectionProviders = mutable.Map.empty[String, ConnectionProvider] 17 | 18 | override def postStop(): Unit = { 19 | log.debug(s"LoadBalancerActor for $key stopped, destroying all connection providers") 20 | connectionProviders.values.foreach(_.destroy()) 21 | } 22 | 23 | def receive: PartialFunction[Any, Unit] = { 24 | 25 | case GetConnection ⇒ 26 | selectConnection match { 27 | case Some((id, connectionProvider)) ⇒ connectionProvider.getConnectionHolder(id, self) pipeTo sender 28 | case None ⇒ sender ! Failure(ServiceUnavailableException(key)) 29 | } 30 | case ReturnConnection(connection) ⇒ returnConnection(connection) 31 | case AddConnectionProvider(id, provider) ⇒ addConnectionProvider(id, provider) 32 | case RemoveConnectionProvider(id) ⇒ removeConnectionProvider(id) 33 | case HasAvailableConnectionProvider ⇒ sender ! connectionProviders.nonEmpty 34 | } 35 | 36 | def selectConnection: Option[(String, ConnectionProvider)] = 37 | loadBalancer.selectConnection.flatMap(id ⇒ connectionProviders.get(id).map(id → _)) 38 | 39 | def returnConnection(connection: ConnectionHolder): Unit = { 40 | connectionProviders.get(connection.id).foreach(_.returnConnection(connection)) 41 | loadBalancer.connectionReturned(connection.id) 42 | } 43 | 44 | def addConnectionProvider(id: String, provider: ConnectionProvider): Unit = { 45 | connectionProviders.put(id, provider) 46 | loadBalancer.connectionProviderAdded(id) 47 | } 48 | 49 | def removeConnectionProvider(id: String): Unit = { 50 | connectionProviders.remove(id).foreach(_.destroy()) 51 | loadBalancer.connectionProviderRemoved(id) 52 | } 53 | } 54 | 55 | object LoadBalancerActor { 56 | // Props 57 | def props(loadBalancer: LoadBalancer, key: String) = Props(new LoadBalancerActor(loadBalancer, key)) 58 | // Messsages 59 | case object GetConnection 60 | case class ReturnConnection(connection: ConnectionHolder) 61 | case class AddConnectionProvider(id: String, provider: ConnectionProvider) 62 | case class RemoveConnectionProvider(id: String) 63 | case object HasAvailableConnectionProvider 64 | } 65 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/loadbalancers/RoundRobinLoadBalancer.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.loadbalancers 2 | 3 | class RoundRobinLoadBalancer extends LoadBalancer { 4 | 5 | val list = new CircularLinkedHashSet[String] 6 | var iterator: Iterator[String] = list.iterator 7 | 8 | override def connectionProviderAdded(key: String): Unit = { 9 | list.add(key) 10 | iterator = list.iterator 11 | } 12 | 13 | override def connectionProviderRemoved(key: String): Unit = { 14 | list.remove(key) 15 | iterator = list.iterator 16 | } 17 | 18 | override def selectConnection: Option[String] = { 19 | if (iterator.hasNext) Some(iterator.next()) 20 | else None 21 | } 22 | } 23 | 24 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/session/SessionActor.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.session 2 | 3 | import java.util.UUID 4 | 5 | import akka.actor.{ ActorRef, Props, Actor } 6 | import stormlantern.consul.client.dao.ConsulHttpClient 7 | import stormlantern.consul.client.session.SessionActor.{ MonitorSession, SessionAcquired, StartSession } 8 | 9 | import scala.concurrent.Future 10 | 11 | class SessionActor(httpClient: ConsulHttpClient, listener: ActorRef) extends Actor { 12 | 13 | import scala.concurrent.ExecutionContext.Implicits.global 14 | 15 | // Actor state 16 | var sessionId: Option[UUID] = None 17 | 18 | def receive = { 19 | case StartSession ⇒ startSession().map { id ⇒ 20 | self ! SessionAcquired(id) 21 | } 22 | case SessionAcquired(id) ⇒ 23 | sessionId = Some(id) 24 | listener ! SessionAcquired(id) 25 | self ! MonitorSession(0) 26 | case MonitorSession(lastIndex) ⇒ 27 | 28 | } 29 | 30 | // Internal methods 31 | def startSession(): Future[UUID] = { 32 | httpClient.putSession().map { id ⇒ 33 | sessionId = Some(id) 34 | id 35 | } 36 | } 37 | } 38 | 39 | object SessionActor { 40 | // Constructors 41 | def props(httpClient: ConsulHttpClient, listener: ActorRef) = Props(new SessionActor(httpClient, listener)) 42 | // Public messages 43 | case object StartSession 44 | case class SessionAcquired(sessionId: UUID) 45 | // Private messages 46 | private case class MonitorSession(lastIndex: Long) 47 | } 48 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/util/Logging.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.util 2 | 3 | import org.slf4j.LoggerFactory 4 | 5 | trait Logging { 6 | val logger = LoggerFactory.getLogger(this.getClass) 7 | } 8 | -------------------------------------------------------------------------------- /client/src/main/scala/stormlantern/consul/client/util/RetryPolicy.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client 2 | package util 3 | 4 | import scala.concurrent._ 5 | import scala.concurrent.duration._ 6 | 7 | import akka.actor.Scheduler 8 | import akka.pattern.after 9 | 10 | trait RetryPolicy { 11 | def maxRetries = 4 12 | def retry[T]( 13 | delay: FiniteDuration = 500.milli, 14 | retries: Int = 4, 15 | backoff: Int = 2, 16 | predicate: T ⇒ Boolean = (r: T) ⇒ true 17 | )(f: ⇒ Future[T])(implicit ec: ExecutionContext, s: Scheduler): Future[T] = { 18 | f.map { 19 | case r if !predicate(r) ⇒ throw new IllegalStateException("Result does not satisfy the predicate specified") 20 | case r ⇒ r 21 | } recoverWith { case _ if retries > 0 ⇒ after(delay, s)(retry(delay * backoff, retries - 1, backoff, predicate)(f)) } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/test/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | loggers = ["akka.event.slf4j.Slf4jLogger"] 3 | loglevel = "INFO" 4 | logger-startup-timeout = 30s 5 | logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" 6 | log-dead-letters-during-shutdown = off 7 | log-dead-letters = off 8 | } 9 | -------------------------------------------------------------------------------- /client/src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/src/test/scala/stormlantern/consul/client/ServiceBrokerActorSpec.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client 2 | 3 | import akka.actor.Status.Failure 4 | import akka.actor._ 5 | import akka.testkit.{ ImplicitSender, TestActorRef, TestKit, TestProbe } 6 | import org.scalamock.scalatest.MockFactory 7 | import org.scalatest.{ BeforeAndAfterAll, FlatSpecLike, Matchers } 8 | import stormlantern.consul.client.dao.{ ConsulHttpClient, ServiceInstance } 9 | import stormlantern.consul.client.discovery.ServiceAvailabilityActor.{ Start, Started } 10 | import stormlantern.consul.client.discovery._ 11 | import stormlantern.consul.client.helpers.{ CallingThreadExecutionContext, ModelHelpers } 12 | import stormlantern.consul.client.loadbalancers.LoadBalancerActor 13 | import stormlantern.consul.client.util.Logging 14 | 15 | class ServiceBrokerActorSpec(_system: ActorSystem) extends TestKit(_system) with ImplicitSender with FlatSpecLike 16 | with Matchers with BeforeAndAfterAll with MockFactory with Logging { 17 | 18 | implicit val ec = CallingThreadExecutionContext() 19 | def this() = this(ActorSystem("ServiceBrokerActorSpec")) 20 | 21 | override def afterAll() { 22 | TestKit.shutdownActorSystem(system) 23 | } 24 | 25 | trait TestScope { 26 | val httpClient: ConsulHttpClient = mock[ConsulHttpClient] 27 | val serviceAvailabilityActorFactory: (ActorRefFactory, ServiceDefinition, ActorRef) ⇒ ActorRef = 28 | mock[(ActorRefFactory, ServiceDefinition, ActorRef) ⇒ ActorRef] 29 | val connectionProviderFactory: ConnectionProviderFactory = mock[ConnectionProviderFactory] 30 | val connectionProvider: ConnectionProvider = mock[ConnectionProvider] 31 | val connectionHolder: ConnectionHolder = mock[ConnectionHolder] 32 | val service1 = ServiceDefinition("service1Id", "service1") 33 | val service2 = ServiceDefinition("service2Key", "service2") 34 | val loadBalancerProbeForService1 = TestProbe("LoadBalancerActorForService1") 35 | val loadBalancerProbeForService2 = TestProbe("LoadBalancerActorForService2") 36 | val connectionStrategyForService1 = ConnectionStrategy(service1, connectionProviderFactory, ctx ⇒ loadBalancerProbeForService1.ref) 37 | val connectionStrategyForService2 = ConnectionStrategy(service2, connectionProviderFactory, ctx ⇒ loadBalancerProbeForService2.ref) 38 | } 39 | 40 | "The ServiceBrokerActor" should "create a child actor per service" in new TestScope { 41 | val serviceAvailabilityProbe = TestProbe("ServiceAvailabilityActor") 42 | (serviceAvailabilityActorFactory.apply _).expects(*, service1, *).returns(serviceAvailabilityProbe.ref) 43 | val sut: TestActorRef[ServiceBrokerActor] = TestActorRef[ServiceBrokerActor](ServiceBrokerActor.props( 44 | Set(connectionStrategyForService1), serviceAvailabilityActorFactory)) 45 | serviceAvailabilityProbe.expectMsg(Start) 46 | sut.underlyingActor.loadbalancers.keys should contain(service1.key) 47 | sut.stop() 48 | } 49 | 50 | it should "create a load balancer for each new service" in new TestScope { 51 | val serviceAvailabilityProbe = TestProbe("ServiceAvailabilityActor") 52 | (serviceAvailabilityActorFactory.apply _).expects(*, service1, *).returns(serviceAvailabilityProbe.ref) 53 | val sut: TestActorRef[ServiceBrokerActor] = TestActorRef[ServiceBrokerActor](ServiceBrokerActor.props( 54 | Set(connectionStrategyForService1), serviceAvailabilityActorFactory)) 55 | serviceAvailabilityProbe.expectMsg(Start) 56 | val service: ServiceInstance = ModelHelpers.createService("service1:Id", "service1") 57 | (connectionProviderFactory.create _).expects(service.serviceAddress, service.servicePort).returns(connectionProvider) 58 | serviceAvailabilityProbe.send( 59 | sut, ServiceAvailabilityActor.ServiceAvailabilityUpdate(service1.key, added = Set(service), removed = Set.empty)) 60 | loadBalancerProbeForService1.expectMsg(LoadBalancerActor.AddConnectionProvider(service.serviceId, connectionProvider)) 61 | sut.stop() 62 | } 63 | 64 | it should "remove the load balancer for each old service" in new TestScope { 65 | val serviceAvailabilityProbe = TestProbe("ServiceAvailabilityActor") 66 | (serviceAvailabilityActorFactory.apply _).expects(*, service1, *).returns(serviceAvailabilityProbe.ref) 67 | val sut: TestActorRef[ServiceBrokerActor] = TestActorRef[ServiceBrokerActor](ServiceBrokerActor.props( 68 | Set(connectionStrategyForService1), serviceAvailabilityActorFactory)) 69 | serviceAvailabilityProbe.expectMsg(Start) 70 | val service: ServiceInstance = ModelHelpers.createService("service1:Id", "service1") 71 | serviceAvailabilityProbe.send( 72 | sut, ServiceAvailabilityActor.ServiceAvailabilityUpdate(service1.key, added = Set.empty, removed = Set(service))) 73 | loadBalancerProbeForService1.expectMsg(LoadBalancerActor.RemoveConnectionProvider(service.serviceId)) 74 | sut.stop() 75 | } 76 | 77 | it should "initialize after all services have been seen" in new TestScope { 78 | val serviceAvailabilityProbe = TestProbe("ServiceAvailabilityActor") 79 | (serviceAvailabilityActorFactory.apply _).expects(*, service1, *).returns(serviceAvailabilityProbe.ref) 80 | val sut: TestActorRef[ServiceBrokerActor] = TestActorRef[ServiceBrokerActor](ServiceBrokerActor.props( 81 | Set(connectionStrategyForService1), serviceAvailabilityActorFactory)) 82 | serviceAvailabilityProbe.expectMsg(Start) 83 | val service: ServiceInstance = ModelHelpers.createService(service1) 84 | } 85 | 86 | it should "request a connection from a loadbalancer" in new TestScope { 87 | val serviceAvailabilityProbe = TestProbe("ServiceAvailabilityActor") 88 | (serviceAvailabilityActorFactory.apply _).expects(*, service1, *).returns(serviceAvailabilityProbe.ref) 89 | val sut: TestActorRef[ServiceBrokerActor] = TestActorRef[ServiceBrokerActor](ServiceBrokerActor.props( 90 | Set(connectionStrategyForService1), serviceAvailabilityActorFactory)) 91 | serviceAvailabilityProbe.expectMsg(Start) 92 | val service: ServiceInstance = ModelHelpers.createService(service1) 93 | sut ! ServiceBrokerActor.GetServiceConnection(service.serviceId) 94 | serviceAvailabilityProbe.send(sut, Started) 95 | loadBalancerProbeForService1.expectMsg(LoadBalancerActor.GetConnection) 96 | sut.stop() 97 | } 98 | 99 | it should "return a failure if a service name cannot be found" in new TestScope { 100 | val sut: TestActorRef[ServiceBrokerActor] = TestActorRef[ServiceBrokerActor](ServiceBrokerActor.props( 101 | Set.empty, serviceAvailabilityActorFactory)) 102 | val service: ServiceInstance = ModelHelpers.createService(service1) 103 | sut ! ServiceBrokerActor.GetServiceConnection(service.serviceId) 104 | expectMsg(Failure(ServiceUnavailableException(service.serviceId))) 105 | sut.stop() 106 | } 107 | 108 | it should "forward a query for connection provider availability" in new TestScope { 109 | val serviceAvailabilityProbe = TestProbe("ServiceAvailabilityActor") 110 | (serviceAvailabilityActorFactory.apply _).expects(*, service1, *).returns(serviceAvailabilityProbe.ref) 111 | val sut: TestActorRef[ServiceBrokerActor] = TestActorRef[ServiceBrokerActor](ServiceBrokerActor.props( 112 | Set(connectionStrategyForService1), serviceAvailabilityActorFactory)) 113 | serviceAvailabilityProbe.expectMsg(Start) 114 | sut ! ServiceBrokerActor.HasAvailableConnectionProviderFor(service1.key) 115 | loadBalancerProbeForService1.expectMsgPF() { 116 | case LoadBalancerActor.HasAvailableConnectionProvider ⇒ loadBalancerProbeForService1.sender() ! true 117 | } 118 | expectMsg(true) 119 | sut.stop() 120 | } 121 | 122 | it should "return false when every service doesn't have at least one connection provider available" in new TestScope { 123 | val service1AvailabilityProbe = TestProbe("Service1AvailabilityActor") 124 | val service2AvailabilityProbe = TestProbe("Service2AvailabilityActor") 125 | (serviceAvailabilityActorFactory.apply _).expects(*, service1, *).returns(service1AvailabilityProbe.ref) 126 | (serviceAvailabilityActorFactory.apply _).expects(*, service2, *).returns(service2AvailabilityProbe.ref) 127 | val sut: TestActorRef[ServiceBrokerActor] = TestActorRef[ServiceBrokerActor](ServiceBrokerActor.props( 128 | Set(connectionStrategyForService1, connectionStrategyForService2), serviceAvailabilityActorFactory)) 129 | service1AvailabilityProbe.expectMsg(Start) 130 | service2AvailabilityProbe.expectMsg(Start) 131 | sut ! ServiceBrokerActor.AllConnectionProvidersAvailable 132 | loadBalancerProbeForService1.expectMsgPF() { 133 | case LoadBalancerActor.HasAvailableConnectionProvider ⇒ loadBalancerProbeForService1.sender() ! true 134 | } 135 | loadBalancerProbeForService2.expectMsgPF() { 136 | case LoadBalancerActor.HasAvailableConnectionProvider ⇒ loadBalancerProbeForService2.sender() ! false 137 | } 138 | expectMsg(false) 139 | sut.stop() 140 | } 141 | 142 | it should "return true when every service has at least one connection provider avaiable" in new TestScope { 143 | val service1AvailabilityProbe = TestProbe("Service1AvailabilityActor") 144 | val service2AvailabilityProbe = TestProbe("Service2AvailabilityActor") 145 | (serviceAvailabilityActorFactory.apply _).expects(*, service1, *).returns(service1AvailabilityProbe.ref) 146 | (serviceAvailabilityActorFactory.apply _).expects(*, service2, *).returns(service2AvailabilityProbe.ref) 147 | val sut: TestActorRef[ServiceBrokerActor] = TestActorRef[ServiceBrokerActor](ServiceBrokerActor.props( 148 | Set(connectionStrategyForService1, connectionStrategyForService2), serviceAvailabilityActorFactory)) 149 | service1AvailabilityProbe.expectMsg(Start) 150 | service2AvailabilityProbe.expectMsg(Start) 151 | sut ! ServiceBrokerActor.AllConnectionProvidersAvailable 152 | loadBalancerProbeForService1.expectMsgPF() { 153 | case LoadBalancerActor.HasAvailableConnectionProvider ⇒ loadBalancerProbeForService1.sender() ! true 154 | } 155 | loadBalancerProbeForService2.expectMsgPF() { 156 | case LoadBalancerActor.HasAvailableConnectionProvider ⇒ loadBalancerProbeForService2.sender() ! true 157 | } 158 | expectMsg(true) 159 | sut.stop() 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /client/src/test/scala/stormlantern/consul/client/ServiceBrokerSpec.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client 2 | 3 | import akka.actor.{ ActorRef, ActorSystem } 4 | import akka.actor.Status.Failure 5 | import akka.testkit.{ ImplicitSender, TestKit } 6 | import org.scalamock.scalatest.MockFactory 7 | import org.scalatest.concurrent.ScalaFutures 8 | import org.scalatest.{ BeforeAndAfterAll, FlatSpecLike, Matchers } 9 | import stormlantern.consul.client.dao.ConsulHttpClient 10 | import stormlantern.consul.client.discovery.ConnectionHolder 11 | import stormlantern.consul.client.helpers.CallingThreadExecutionContext 12 | import stormlantern.consul.client.loadbalancers.LoadBalancerActor 13 | import stormlantern.consul.client.util.Logging 14 | 15 | import scala.concurrent.Future 16 | 17 | class ServiceBrokerSpec(_system: ActorSystem) extends TestKit(_system) with ImplicitSender with FlatSpecLike 18 | with Matchers with ScalaFutures with BeforeAndAfterAll with MockFactory with Logging { 19 | 20 | implicit val ec = CallingThreadExecutionContext() 21 | def this() = this(ActorSystem("ServiceBrokerSpec")) 22 | 23 | override def afterAll() { 24 | TestKit.shutdownActorSystem(system) 25 | } 26 | 27 | trait TestScope { 28 | val connectionHolder: ConnectionHolder = mock[ConnectionHolder] 29 | val httpClient: ConsulHttpClient = mock[ConsulHttpClient] 30 | val loadBalancer: ActorRef = self 31 | } 32 | 33 | "The ServiceBroker" should "return a service connection when requested" in new TestScope { 34 | (connectionHolder.connection _).expects().returns(Future.successful(true)) 35 | (connectionHolder.loadBalancer _).expects().returns(loadBalancer) 36 | val sut = new ServiceBroker(self, httpClient) 37 | val result: Future[Boolean] = sut.withService("service1") { service: Boolean ⇒ 38 | Future.successful(service) 39 | } 40 | expectMsgPF() { 41 | case ServiceBrokerActor.GetServiceConnection("service1") ⇒ 42 | lastSender ! connectionHolder 43 | result.map(_ shouldEqual true).futureValue 44 | } 45 | expectMsg(LoadBalancerActor.ReturnConnection(connectionHolder)) 46 | } 47 | 48 | it should "return the connection when an error occurs" in new TestScope { 49 | (connectionHolder.connection _).expects().returns(Future.successful(true)) 50 | (connectionHolder.loadBalancer _).expects().returns(loadBalancer) 51 | val sut = new ServiceBroker(self, httpClient) 52 | val result: Future[Boolean] = sut.withService[Boolean, Boolean]("service1") { service: Boolean ⇒ 53 | throw new RuntimeException() 54 | } 55 | expectMsgPF() { 56 | case ServiceBrokerActor.GetServiceConnection("service1") ⇒ 57 | lastSender ! connectionHolder 58 | an[RuntimeException] should be thrownBy result.futureValue 59 | } 60 | expectMsg(LoadBalancerActor.ReturnConnection(connectionHolder)) 61 | } 62 | 63 | it should "throw an error when an excpetion is returned" in new TestScope { 64 | val sut = new ServiceBroker(self, httpClient) 65 | val result: Future[Boolean] = sut.withService("service1") { service: Boolean ⇒ 66 | Future.successful(service) 67 | } 68 | expectMsgPF() { 69 | case ServiceBrokerActor.GetServiceConnection("service1") ⇒ 70 | lastSender ! Failure(new RuntimeException()) 71 | an[RuntimeException] should be thrownBy result.futureValue 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /client/src/test/scala/stormlantern/consul/client/discovery/ServiceAvailabilityActorSpec.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.discovery 2 | 3 | import akka.actor.ActorSystem 4 | import akka.testkit.{ ImplicitSender, TestActorRef, TestKit } 5 | import org.scalamock.scalatest.MockFactory 6 | import org.scalatest.{ BeforeAndAfterAll, FlatSpecLike, Matchers } 7 | import stormlantern.consul.client.dao.{ ConsulHttpClient, IndexedServiceInstances } 8 | import stormlantern.consul.client.discovery.ServiceAvailabilityActor.Start 9 | import stormlantern.consul.client.helpers.ModelHelpers 10 | import stormlantern.consul.client.util.Logging 11 | 12 | import scala.concurrent.Future 13 | import scala.concurrent.duration._ 14 | 15 | class ServiceAvailabilityActorSpec(_system: ActorSystem) extends TestKit(_system) with ImplicitSender with FlatSpecLike 16 | with Matchers with BeforeAndAfterAll with MockFactory with Logging { 17 | 18 | def this() = this(ActorSystem("ServiceAvailabilityActorSpec")) 19 | 20 | override def afterAll() { 21 | TestKit.shutdownActorSystem(system) 22 | } 23 | 24 | "The ServiceAvailabilityActor" should "receive one service update when there are no changes" in { 25 | val httpClient: ConsulHttpClient = mock[ConsulHttpClient] 26 | val sut = TestActorRef(ServiceAvailabilityActor.props(httpClient, ServiceDefinition("bogus123", "bogus"), self)) 27 | (httpClient.getService _).expects("bogus", None, Some(0L), Some("1s"), None).returns(Future.successful(IndexedServiceInstances(1, Set.empty))) 28 | (httpClient.getService _).expects("bogus", None, Some(1L), Some("1s"), None).onCall { p ⇒ 29 | sut.stop() 30 | Future.successful(IndexedServiceInstances(1, Set.empty)) 31 | } 32 | sut ! Start 33 | expectMsg(1.second, ServiceAvailabilityActor.ServiceAvailabilityUpdate("bogus123")) 34 | expectMsg(1.second, ServiceAvailabilityActor.Started) 35 | expectNoMsg(1.second) 36 | } 37 | 38 | it should "receive two service updates when there is a change" in { 39 | val httpClient: ConsulHttpClient = mock[ConsulHttpClient] 40 | lazy val sut = TestActorRef(ServiceAvailabilityActor.props(httpClient, ServiceDefinition("bogus123", "bogus"), self)) 41 | val service = ModelHelpers.createService("bogus123", "bogus") 42 | (httpClient.getService _).expects("bogus", None, Some(0L), Some("1s"), None).returns(Future.successful(IndexedServiceInstances(1, Set.empty))) 43 | (httpClient.getService _).expects("bogus", None, Some(1L), Some("1s"), None).returns(Future.successful(IndexedServiceInstances(2, Set(service)))) 44 | (httpClient.getService _).expects("bogus", None, Some(2L), Some("1s"), None).onCall { p ⇒ 45 | sut.stop() 46 | Future.successful(IndexedServiceInstances(2, Set(service))) 47 | } 48 | sut ! Start 49 | expectMsg(1.second, ServiceAvailabilityActor.ServiceAvailabilityUpdate("bogus123")) 50 | expectMsg(1.second, ServiceAvailabilityActor.Started) 51 | expectMsg(1.second, ServiceAvailabilityActor.ServiceAvailabilityUpdate("bogus123", Set(service), Set.empty)) 52 | expectNoMsg(1.second) 53 | } 54 | 55 | it should "receive one service update when there are two with different tags" in { 56 | val httpClient: ConsulHttpClient = mock[ConsulHttpClient] 57 | lazy val sut = TestActorRef(ServiceAvailabilityActor.props(httpClient, ServiceDefinition("bogus123", "bogus", Set("one", "two")), self)) 58 | val nonMatchingservice = ModelHelpers.createService("bogus123", "bogus", tags = Set("one")) 59 | val matchingService = nonMatchingservice.copy(serviceTags = Set("one", "two")) 60 | (httpClient.getService _).expects("bogus", Some("one"), Some(0L), Some("1s"), None).returns(Future.successful(IndexedServiceInstances(1, Set.empty))) 61 | (httpClient.getService _).expects("bogus", Some("one"), Some(1L), Some("1s"), None).returns(Future.successful(IndexedServiceInstances(2, Set(nonMatchingservice, matchingService)))) 62 | (httpClient.getService _).expects("bogus", Some("one"), Some(2L), Some("1s"), None).onCall { p ⇒ 63 | sut.stop() 64 | Future.successful(IndexedServiceInstances(2, Set(nonMatchingservice, matchingService))) 65 | } 66 | sut ! Start 67 | expectMsg(1.second, ServiceAvailabilityActor.ServiceAvailabilityUpdate("bogus123")) 68 | expectMsg(1.second, ServiceAvailabilityActor.Started) 69 | expectMsg(1.second, ServiceAvailabilityActor.ServiceAvailabilityUpdate("bogus123", Set(matchingService), Set.empty)) 70 | expectNoMsg(1.second) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /client/src/test/scala/stormlantern/consul/client/election/LeaderFollowerActorSpec.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.election 2 | 3 | import java.util 4 | import java.util.UUID 5 | 6 | import akka.actor.ActorSystem 7 | import akka.testkit.{ TestActorRef, ImplicitSender, TestKit } 8 | import org.scalamock.scalatest.MockFactory 9 | import org.scalatest.{ BeforeAndAfterAll, Matchers, FlatSpecLike } 10 | import stormlantern.consul.client.dao.{ BinaryData, KeyData, AcquireSession, ConsulHttpClient } 11 | import stormlantern.consul.client.election.LeaderFollowerActor.Participate 12 | 13 | import scala.concurrent.Future 14 | 15 | class LeaderFollowerActorSpec(_system: ActorSystem) extends TestKit(_system) with ImplicitSender with FlatSpecLike 16 | with Matchers with BeforeAndAfterAll with MockFactory { 17 | 18 | def this() = this(ActorSystem("LeaderFollowerActorSpec")) 19 | 20 | override def afterAll() { 21 | TestKit.shutdownActorSystem(system) 22 | } 23 | 24 | trait TestScope { 25 | val sessionId: UUID = UUID.fromString("9A3BB9C-E2E7-43DF-BFD5-845417146552") 26 | val key = "path/to/our/key" 27 | val host = "myhost.mynetwork.net" 28 | val port = 1337 29 | val consulHttpClient: ConsulHttpClient = mock[ConsulHttpClient] 30 | val leaderInfoBytes: Array[Byte] = s"""{"host":"$host","port":$port}""".getBytes("UTF-8") 31 | } 32 | 33 | "The LeaderFollowerActor" should "participate in an election, win, watch for changes and participate again when session is lost" in new TestScope { 34 | val sut = TestActorRef(LeaderFollowerActor.props(consulHttpClient, sessionId, key, host, port)) 35 | (consulHttpClient.putKeyValuePair _).expects(where { (k, lib, op) ⇒ 36 | k == key && util.Arrays.equals(lib, leaderInfoBytes) && op.contains(AcquireSession(sessionId)) 37 | }).returns(Future.successful(true)) 38 | (consulHttpClient.getKeyValuePair _).expects(key, Some(0L), Some("1s"), false, false).returns { 39 | Future.successful(Seq(KeyData(key, 1, 1, 1, 0, BinaryData(leaderInfoBytes), Some(sessionId)))) 40 | } 41 | (consulHttpClient.getKeyValuePair _).expects(key, Some(1L), Some("1s"), false, false).returns { 42 | Future.successful(Seq(KeyData(key, 1, 2, 1, 0, BinaryData(leaderInfoBytes), None))) 43 | } 44 | (consulHttpClient.putKeyValuePair _).expects(where { (k, lib, op) ⇒ 45 | k == key && util.Arrays.equals(lib, leaderInfoBytes) && op.contains(AcquireSession(sessionId)) 46 | }).onCall { p ⇒ 47 | sut.stop() 48 | Future.successful(false) 49 | } 50 | sut ! Participate 51 | } 52 | 53 | it should "participate in an election, lose, watch for changes and participate again when session is lost" in new TestScope { 54 | val otherSessionId: UUID = UUID.fromString("9A3BB9C-E2E7-43DF-BFD5-845417146553") 55 | val sut = TestActorRef(LeaderFollowerActor.props(consulHttpClient, sessionId, key, host, port)) 56 | (consulHttpClient.putKeyValuePair _).expects(where { (k, lib, op) ⇒ 57 | k == key && util.Arrays.equals(lib, leaderInfoBytes) && op.contains(AcquireSession(sessionId)) 58 | }).returns(Future.successful(false)) 59 | (consulHttpClient.getKeyValuePair _).expects(key, Some(0L), Some("1s"), false, false).returns { 60 | Future.successful(Seq(KeyData(key, 1, 1, 1, 0, BinaryData(leaderInfoBytes), Some(otherSessionId)))) 61 | } 62 | (consulHttpClient.getKeyValuePair _).expects(key, Some(1L), Some("1s"), false, false).returns { 63 | Future.successful(Seq(KeyData(key, 1, 2, 1, 0, BinaryData(leaderInfoBytes), None))) 64 | } 65 | (consulHttpClient.putKeyValuePair _).expects(where { (k, lib, op) ⇒ 66 | k == key && util.Arrays.equals(lib, leaderInfoBytes) && op.contains(AcquireSession(sessionId)) 67 | }).onCall { p ⇒ 68 | sut.stop() 69 | Future.successful(true) 70 | } 71 | sut ! Participate 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /client/src/test/scala/stormlantern/consul/client/helpers/CallingThreadExecutor.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.helpers 2 | 3 | import java.util.concurrent.Executor 4 | 5 | import scala.concurrent.ExecutionContext 6 | 7 | class CallingThreadExecutor extends Executor { 8 | override def execute(command: Runnable): Unit = command.run() 9 | } 10 | 11 | object CallingThreadExecutionContext { 12 | def apply(): ExecutionContext = ExecutionContext.fromExecutor(new CallingThreadExecutor) 13 | } 14 | -------------------------------------------------------------------------------- /client/src/test/scala/stormlantern/consul/client/helpers/ModelHelpers.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.helpers 2 | 3 | import stormlantern.consul.client.dao.ServiceInstance 4 | import stormlantern.consul.client.discovery.ServiceDefinition 5 | 6 | object ModelHelpers { 7 | def createService(id: String, name: String, port: Int = 666, node: String = "node", tags: Set[String] = Set.empty) = ServiceInstance( 8 | node = node, 9 | address = s"${node}Address", 10 | serviceId = id, 11 | serviceName = name, 12 | serviceTags = tags, 13 | serviceAddress = s"${name}Address", 14 | servicePort = port 15 | ) 16 | def createService(service: ServiceDefinition): ServiceInstance = createService(service.key, service.serviceName) 17 | } 18 | -------------------------------------------------------------------------------- /client/src/test/scala/stormlantern/consul/client/loadbalancers/LoadBalancerActorSpec.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.loadbalancers 2 | 3 | import akka.actor.ActorSystem 4 | import akka.actor.Status.Failure 5 | import akka.testkit.{ ImplicitSender, TestActorRef, TestKit } 6 | import org.scalamock.scalatest.MockFactory 7 | import org.scalatest.{ BeforeAndAfterAll, FlatSpecLike, Matchers } 8 | import stormlantern.consul.client.ServiceUnavailableException 9 | import stormlantern.consul.client.discovery.{ ConnectionHolder, ConnectionProvider } 10 | import stormlantern.consul.client.util.Logging 11 | 12 | import scala.concurrent.Future 13 | 14 | class LoadBalancerActorSpec(_system: ActorSystem) extends TestKit(_system) with ImplicitSender with FlatSpecLike 15 | with Matchers with BeforeAndAfterAll with MockFactory with Logging { 16 | 17 | def this() = this(ActorSystem("LoadBalancerActorSpec")) 18 | 19 | override def afterAll() { 20 | TestKit.shutdownActorSystem(system) 21 | } 22 | 23 | trait TestScope { 24 | val connectionHolder = mock[ConnectionHolder] 25 | val connectionProvider = mock[ConnectionProvider] 26 | val loadBalancer = mock[LoadBalancer] 27 | } 28 | 29 | "The LoadBalancerActor" should "hand out a connection holder when requested" in new TestScope { 30 | val instanceKey = "instanceKey" 31 | (loadBalancer.selectConnection _).expects().returns(Some(instanceKey)) 32 | val sut = TestActorRef(new LoadBalancerActor(loadBalancer, "service1")) 33 | (connectionProvider.getConnectionHolder _).expects(instanceKey, sut).returns(Future.successful(connectionHolder)) 34 | (connectionProvider.destroy _).expects() 35 | sut.underlyingActor.connectionProviders.put(instanceKey, connectionProvider) 36 | sut ! LoadBalancerActor.GetConnection 37 | expectMsg(connectionHolder) 38 | sut.stop() 39 | } 40 | 41 | it should "return an error when a connectionprovider fails to provide a connection" in new TestScope { 42 | val instanceKey = "instanceKey" 43 | val expectedException = new ServiceUnavailableException("service1") 44 | (loadBalancer.selectConnection _).expects().returns(Some(instanceKey)) 45 | val sut = TestActorRef(new LoadBalancerActor(loadBalancer, "service1")) 46 | (connectionProvider.getConnectionHolder _).expects(instanceKey, sut).returns(Future.failed(expectedException)) 47 | (connectionProvider.destroy _).expects() 48 | sut.underlyingActor.connectionProviders.put(instanceKey, connectionProvider) 49 | sut ! LoadBalancerActor.GetConnection 50 | expectMsg(Failure(expectedException)) 51 | sut.stop() 52 | } 53 | 54 | it should "return a connection holder when requested" in new TestScope { 55 | val instanceKey = "instanceKey" 56 | (connectionHolder.id _).expects().returns(instanceKey) 57 | (connectionProvider.returnConnection _).expects(connectionHolder) 58 | (connectionHolder.id _).expects().returns(instanceKey) 59 | (loadBalancer.connectionReturned _).expects(instanceKey) 60 | (connectionProvider.destroy _).expects() 61 | val sut = TestActorRef(new LoadBalancerActor(loadBalancer, "service1")) 62 | sut.underlyingActor.connectionProviders.put(instanceKey, connectionProvider) 63 | sut ! LoadBalancerActor.ReturnConnection(connectionHolder) 64 | sut.stop() 65 | } 66 | 67 | it should "add a connection provider when requested" in new TestScope { 68 | val instanceKey = "instanceKey" 69 | (loadBalancer.connectionProviderAdded _).expects(instanceKey) 70 | (connectionProvider.destroy _).expects() 71 | val sut = TestActorRef(new LoadBalancerActor(loadBalancer, "service1")) 72 | sut ! LoadBalancerActor.AddConnectionProvider(instanceKey, connectionProvider) 73 | sut.underlyingActor.connectionProviders should contain(instanceKey → connectionProvider) 74 | sut.stop() 75 | } 76 | 77 | it should "remove a connection provider when requested and tell it to destroy itself" in new TestScope { 78 | val instanceKey = "instanceKey" 79 | (connectionProvider.destroy _).expects() 80 | (loadBalancer.connectionProviderRemoved _).expects(instanceKey) 81 | val sut = TestActorRef(new LoadBalancerActor(loadBalancer, "service1")) 82 | sut.underlyingActor.connectionProviders.put(instanceKey, connectionProvider) 83 | sut ! LoadBalancerActor.RemoveConnectionProvider(instanceKey) 84 | sut.underlyingActor.connectionProviders should not contain (instanceKey → connectionProvider) 85 | } 86 | 87 | it should "return true when it has at least one available connection provider for the service" in new TestScope { 88 | (connectionProvider.destroy _).expects() 89 | val sut = TestActorRef(new LoadBalancerActor(loadBalancer, "service1")) 90 | sut.underlyingActor.connectionProviders.put("key", connectionProvider) 91 | sut ! LoadBalancerActor.HasAvailableConnectionProvider 92 | expectMsg(true) 93 | sut.stop() 94 | } 95 | 96 | it should "return false when it has no available connection providers for the service" in new TestScope { 97 | val sut = TestActorRef(new LoadBalancerActor(loadBalancer, "service1")) 98 | sut ! LoadBalancerActor.HasAvailableConnectionProvider 99 | expectMsg(false) 100 | sut.stop() 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /client/src/test/scala/stormlantern/consul/client/loadbalancers/RoundRobinLoadBalancerSpec.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client.loadbalancers 2 | 3 | import org.scalatest.{ Matchers, FlatSpecLike } 4 | 5 | class RoundRobinLoadBalancerSpec extends FlatSpecLike with Matchers { 6 | 7 | "The RoundRobinLoadBalancer" should "select a connection" in { 8 | val sut = new RoundRobinLoadBalancer 9 | sut.selectConnection shouldBe empty 10 | sut.connectionProviderAdded("one") 11 | sut.selectConnection should contain("one") 12 | sut.selectConnection should contain("one") 13 | sut.connectionProviderAdded("two") 14 | sut.connectionProviderAdded("three") 15 | sut.selectConnection should contain("one") 16 | sut.selectConnection should contain("two") 17 | sut.selectConnection should contain("three") 18 | sut.selectConnection should contain("one") 19 | sut.connectionProviderRemoved("two") 20 | sut.selectConnection should contain("one") 21 | sut.selectConnection should contain("three") 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /dns-helper/src/main/scala/stormlantern/consul/client/Dns.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.client 2 | 3 | import java.net.URL 4 | import com.spotify.dns.DnsSrvResolvers 5 | import collection.JavaConversions._ 6 | 7 | object DNS { 8 | def lookup(consulAddress: String): URL = { 9 | val resolver = DnsSrvResolvers.newBuilder().build() 10 | val lookupResult = resolver.resolve(consulAddress).headOption.getOrElse(throw new RuntimeException(s"No record found for ${consulAddress}")) 11 | new URL(s"http://${lookupResult.host()}:${lookupResult.port()}") 12 | } 13 | } -------------------------------------------------------------------------------- /docker-testkit/src/main/scala/stormlantern/dockertestkit/DockerClientProvider.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.dockertestkit 2 | 3 | import java.net.URI 4 | 5 | import com.spotify.docker.client.DockerClient.ListContainersParam 6 | import com.spotify.docker.client.{ DefaultDockerClient, DockerClient } 7 | 8 | import scala.collection.JavaConversions._ 9 | 10 | object DockerClientProvider { 11 | 12 | lazy val client: DockerClient = DefaultDockerClient.fromEnv().build() 13 | 14 | lazy val hostname: String = { 15 | val uri = new URI(sys.env.getOrElse("DOCKER_HOST", "unix:///var/run/docker.sock")) 16 | uri.getScheme match { 17 | case "tcp" ⇒ uri.getHost 18 | case "unix" ⇒ "localhost" 19 | } 20 | } 21 | 22 | def cleanUp(): Unit = { 23 | client.listContainers(ListContainersParam.allContainers()).foreach(c => client.removeContainer(c.id())) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /docker-testkit/src/main/scala/stormlantern/dockertestkit/DockerContainer.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.dockertestkit 2 | 3 | import com.spotify.docker.client.messages.ContainerConfig 4 | import org.scalatest.Suite 5 | 6 | trait DockerContainer extends DockerContainers { this: Suite => 7 | 8 | def containerConfig: ContainerConfig 9 | override def containerConfigs: Set[ContainerConfig] = Set(containerConfig) 10 | 11 | def withDockerHost[T](port: String)(f: (String, Int) => T): T = withDockerHosts(Set(port)) { hosts => 12 | val (h, p) = hosts(port) 13 | f(h, p) 14 | } 15 | 16 | } 17 | 18 | -------------------------------------------------------------------------------- /docker-testkit/src/main/scala/stormlantern/dockertestkit/DockerContainers.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.dockertestkit 2 | 3 | import com.spotify.docker.client.messages.{ ContainerConfig, HostConfig } 4 | import org.scalatest.{ BeforeAndAfterAll, Suite } 5 | import stormlantern.dockertestkit.client.Container 6 | 7 | trait DockerContainers extends BeforeAndAfterAll { this: Suite => 8 | 9 | def containerConfigs: Set[ContainerConfig] 10 | val hostConfig = HostConfig.builder() 11 | .publishAllPorts(true) 12 | .networkMode("bridge") 13 | .build() 14 | val containers = containerConfigs.map(new Container(_)) 15 | 16 | def withDockerHosts[T](ports: Set[String])(f: Map[String, (String, Int)] => T): T = { 17 | // Find the mapped available ports in the network settings 18 | f(ports.zip(ports.flatMap(p => containers.map(c => c.mappedPort(p).headOption))).map { 19 | case (port, Some(binding)) => port -> (DockerClientProvider.hostname, binding.hostPort().toInt) 20 | case (port, None) => throw new IndexOutOfBoundsException(s"Cannot find mapped port $port") 21 | }.toMap) 22 | } 23 | 24 | override def beforeAll(): Unit = containers.foreach(_.start()) 25 | 26 | override def afterAll(): Unit = containers.foreach { container => 27 | container.stop() 28 | container.remove() 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /docker-testkit/src/main/scala/stormlantern/dockertestkit/client/Container.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.dockertestkit.client 2 | 3 | import java.util 4 | 5 | import com.spotify.docker.client.messages._ 6 | import stormlantern.dockertestkit.DockerClientProvider 7 | 8 | import scala.collection.JavaConversions._ 9 | 10 | class Container(config: ContainerConfig) { 11 | 12 | private val docker = DockerClientProvider.client 13 | private lazy val container: ContainerCreation = docker.createContainer(config) 14 | private def id: String = container.id() 15 | 16 | def start(): Unit = { 17 | docker.startContainer(id) 18 | val info: ContainerInfo = docker.inspectContainer(id) 19 | if (!info.state().running()) { 20 | throw new IllegalStateException("Could not start Docker container") 21 | } 22 | } 23 | 24 | def stop(): Unit = { 25 | docker.killContainer(id) 26 | docker.waitContainer(id) 27 | } 28 | 29 | def remove(): Unit = { 30 | docker.removeContainer(id) 31 | } 32 | 33 | def mappedPort(port: String): Seq[PortBinding] = { 34 | val ports: util.Map[String, util.List[PortBinding]] = Option(docker.inspectContainer(id).networkSettings().ports()) 35 | .getOrElse(throw new IllegalStateException(s"No ports found for on container with id $id")) 36 | Option(ports.get(port)).getOrElse(throw new IllegalStateException(s"Port $port not found on caintainer with id $id")) 37 | } 38 | } 39 | 40 | -------------------------------------------------------------------------------- /docker-testkit/src/main/scala/stormlantern/dockertestkit/orchestration/Orchestration.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.dockertestkit.orchestration 2 | 3 | class Orchestration { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Running 2 | Build and publish to local Docker repository: 3 | 4 | sbt compile 5 | sbt stage 6 | sbt docker:publishLocal 7 | 8 | Run the examle by running `docker-compose up` in the example directory. Add more server instances by running: 9 | 10 | docker run --rm -e SERVICE_NAME= -p 8080 --dns 172.17.42.1 -e INSTANCE_NAME= reactive-consul-example:0.1-SNAPSHOT 11 | -------------------------------------------------------------------------------- /example/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Make sure you have built the example application before running 2 | # May have to run 'docker-compose rm' before 'docker-compose up' to make sure clean containers are used 3 | consulserver: 4 | image: "progrium/consul" 5 | ports: 6 | - "8400:8400" 7 | - "8500:8500" 8 | - "53:53/udp" 9 | command: -server -bootstrap -ui-dir /ui -advertise 192.168.59.103 10 | registrator: 11 | image: "progrium/registrator" 12 | volumes: 13 | - "/var/run/docker.sock:/tmp/docker.sock" 14 | hostname: 192.168.59.103 15 | command: consul://192.168.59.103:8500 16 | example1i1: 17 | image: "reactive-consul-example:0.1-SNAPSHOT" 18 | ports: 19 | - "8080" 20 | environment: 21 | - SERVICE_NAME=example-service-1 22 | - INSTANCE_NAME=Alvin 23 | dns: 24 | - 172.17.42.1 25 | example1i2: 26 | image: "reactive-consul-example:0.1-SNAPSHOT" 27 | ports: 28 | - "8080" 29 | environment: 30 | - SERVICE_NAME=example-service-1 31 | - INSTANCE_NAME=Simon 32 | dns: 33 | - 172.17.42.1 34 | example2i1: 35 | image: "reactive-consul-example:0.1-SNAPSHOT" 36 | ports: 37 | - "8080" 38 | environment: 39 | - SERVICE_NAME=example-service-2 40 | - INSTANCE_NAME=Theodore 41 | dns: 42 | - 172.17.42.1 43 | example2i2: 44 | image: "reactive-consul-example:0.1-SNAPSHOT" 45 | ports: 46 | - "8080" 47 | environment: 48 | - SERVICE_NAME=example-service-2 49 | - INSTANCE_NAME=Chip 50 | dns: 51 | - 172.17.42.1 -------------------------------------------------------------------------------- /example/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | loggers = ["akka.event.slf4j.Slf4jLogger"] 3 | loglevel = "INFO" 4 | logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" 5 | log-dead-letters-during-shutdown = off 6 | } 7 | 8 | spray.can.server { 9 | request-timeout = 1s 10 | } -------------------------------------------------------------------------------- /example/src/main/resources/assets/app.js: -------------------------------------------------------------------------------- 1 | 2 | var QuarterCircle = function(paper) { 3 | var start = new paper.Point(0, 0); 4 | var through = new paper.Point(Math.cos(Math.PI * 0.25), 1 - Math.sin(Math.PI * 0.25)); 5 | var end = new paper.Point(1, 1); 6 | var arc = new paper.Path.Arc(start, through, end); 7 | arc.strokeColor = 'black'; 8 | return arc; 9 | }; 10 | 11 | var Box = function(paper) { 12 | var topLeft = new paper.Point(0, 0); 13 | var bottomRight = new paper.Point(1, 1); 14 | var rect = new paper.Path.Rectangle(topLeft, bottomRight); 15 | rect.strokeColor = 'black'; 16 | rect.dashArray = [10, 4]; 17 | return rect; 18 | } 19 | 20 | var Scene = function(paper) { 21 | var circle = QuarterCircle(paper); 22 | var box = Box(paper); 23 | return circle.join(box); 24 | } 25 | 26 | var scene = Scene(paper); 27 | var height = view.size.height - (view.size.height * 0.20); 28 | scene.scale(height); 29 | scene.position = view.center; 30 | // Draw the view now: 31 | var text = new PointText({ 32 | point: [50, 50], 33 | content: 'The contents of the point text', 34 | fillColor: 'black', 35 | fontFamily: 'Courier New', 36 | fontWeight: 'bold', 37 | fontSize: 25 38 | }); 39 | text.content = 'And now something different'; 40 | view.draw(); 41 | 42 | -------------------------------------------------------------------------------- /example/src/main/resources/assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | Reactive Consul Demo 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /example/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/src/main/scala/stormlantern/consul/example/Boot.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.example 2 | 3 | import java.net.URL 4 | 5 | import akka.actor.ActorSystem 6 | import akka.io.IO 7 | import akka.pattern._ 8 | import akka.util.Timeout 9 | import spray.can.Http 10 | import spray.json.{ JsString, JsObject } 11 | import stormlantern.consul.client.discovery.{ ConnectionStrategy, ServiceDefinition, ConnectionProvider } 12 | import stormlantern.consul.client.loadbalancers.RoundRobinLoadBalancer 13 | import stormlantern.consul.client.ServiceBroker 14 | import stormlantern.consul.client.DNS 15 | 16 | import scala.concurrent.Future 17 | import scala.concurrent.duration._ 18 | 19 | object Boot extends App { 20 | implicit val system = ActorSystem("reactive-consul") 21 | implicit val executionContext = system.dispatcher 22 | 23 | val service = system.actorOf(ReactiveConsulHttpServiceActor.props(), "webservice") 24 | 25 | implicit val timeout = Timeout(5.seconds) 26 | 27 | IO(Http) ? Http.Bind(service, interface = "0.0.0.0", port = 8080) 28 | 29 | def connectionProviderFactory = (host: String, port: Int) ⇒ new ConnectionProvider { 30 | val client = new SprayExampleServiceClient(new URL(s"http://$host:$port")) 31 | override def getConnection: Future[Any] = Future.successful(client) 32 | } 33 | val connectionStrategy1 = ConnectionStrategy("example-service-1", connectionProviderFactory) 34 | val connectionStrategy2 = ConnectionStrategy("example-service-2", connectionProviderFactory) 35 | 36 | val services = Set(connectionStrategy1, connectionStrategy2) 37 | val serviceBroker = ServiceBroker(DNS.lookup("consul-8500.service.consul"), services) 38 | 39 | system.scheduler.schedule(5.seconds, 5.seconds) { 40 | serviceBroker.withService("example-service-1") { client: SprayExampleServiceClient ⇒ 41 | client.identify 42 | }.foreach(println) 43 | serviceBroker.withService("example-service-2") { client: SprayExampleServiceClient ⇒ 44 | client.identify 45 | }.foreach(println) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /example/src/main/scala/stormlantern/consul/example/ReactiveConsulHttpServiceActor.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.example 2 | 3 | import akka.actor.{ Actor, Props } 4 | import spray.routing.HttpService 5 | 6 | import scala.concurrent.ExecutionContext 7 | 8 | class ReactiveConsulHttpServiceActor extends Actor with ReactiveConsulHttpService { 9 | 10 | def actorRefFactory = context 11 | 12 | def receive = runRoute(reactiveConsulRoute) 13 | } 14 | 15 | object ReactiveConsulHttpServiceActor { 16 | def props() = Props(classOf[ReactiveConsulHttpServiceActor]) 17 | } 18 | 19 | trait ReactiveConsulHttpService extends HttpService { 20 | implicit def executionContext: ExecutionContext = actorRefFactory.dispatcher 21 | 22 | val reactiveConsulRoute = 23 | pathPrefix("api") { 24 | path("identify") { 25 | get { 26 | complete(s"Hi, I'm a ${System.getenv("SERVICE_NAME")} called ${System.getenv("INSTANCE_NAME")}") 27 | } 28 | } ~ 29 | path("talk") { 30 | get { 31 | complete("pong") 32 | } 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /example/src/main/scala/stormlantern/consul/example/SprayExampleServiceClient.scala: -------------------------------------------------------------------------------- 1 | package stormlantern.consul.example 2 | 3 | import java.net.URL 4 | 5 | import akka.actor.ActorSystem 6 | import spray.client.pipelining._ 7 | import spray.http.{ HttpResponse, HttpRequest } 8 | 9 | import scala.concurrent.Future 10 | 11 | class SprayExampleServiceClient(host: URL)(implicit actorSystem: ActorSystem) { 12 | 13 | implicit val executionContext = actorSystem.dispatcher 14 | 15 | val pipeline: HttpRequest => Future[HttpResponse] = sendReceive 16 | def stringFromResponse: HttpResponse => String = (response) => response.entity.asString 17 | 18 | def identify: Future[String] = { 19 | val myPipeline: HttpRequest => Future[String] = pipeline ~> stringFromResponse 20 | myPipeline(Get(s"$host/api/identify")) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /manifest.md: -------------------------------------------------------------------------------- 1 | # Solid Scala testing manifest 2 | 3 | 1. Most application systems should contain three type of tests: _unit tests_, _integration tests_ and _end-to-end tests_. 4 | 2. Developers should strive to be able to run all three types of tests locally in full isolation. 5 | 3. Developers should strive to be able to run all three types of tests on a build server, in parallel and in isolation. 6 | 4. Developers should strive to generate deliverables that behave as consistent as possible in different environments. 7 | 8 | ## Test Types 9 | 10 | First I will define a few test types to create a clear context for this document. 11 | 12 | ### Unit Tests 13 | 14 | _Unit tests_ are tests that are independent of external systems and are generally small in scope e.g. test a single 15 | class. 16 | 17 | ### Integration Tests 18 | 19 | _Integration tests_ test the integration of two parts of the internal system or a part of the internal system and the 20 | interaction with an external system. For the purpose of this manifest I will mainly be referring to the latter and 21 | refer to them as _system integration tests_. 22 | 23 | ### End-To-End Tests 24 | 25 | _End to end tests_ test the functioning of the system as a whole, including the interaction with external systems. These 26 | are generally hardest to run in isolation. These tests can contain failover scenarios if they are part of the system 27 | requirements. 28 | 29 | ### Test devision 30 | There should a clear partitioning between the different kinds of tests in order to be able to run them in different 31 | environments. Travis CI can, for instance, not run all integration or end-to-end tests when there are external 32 | systems involved. In the example SBT project there are three test folders: _test_, _it_ and _e2e_, for _unit_, _external system integration_ and _end-to-end_ testing respectively. 33 | 34 | ## Running tests locally 35 | All types of tests should be able to run locally. It is evident how this is done for _unit tests_ and _intra system 36 | integration tests_. For tests interacting with external systems like a database or mail server, tests should set up 37 | their own dependencies in isolation. Virtualization and and especially containerization make this feasible. 38 | 39 | The example project uses _Docker_ to setup its dependencies, either through reusing public images or creating its own. 40 | Please note that each project that produces an application should also provide the means to deploy and interact with 41 | it. In this case we found it easiest to containerize it using Docker and publish the resulting image. 42 | 43 | Orchestration tools like _docker-compose_ or _vagrant_ can be used to setup a multi-system environment for _end-to-end 44 | testing_. 45 | 46 | ## Running tests on a build server 47 | Running tests on a build server like Jenkins or Bamboo is very similar to running them locally. However, these servers 48 | usually have to run multiple of these tests at the same time. In order to support this, no ports should be exposed to 49 | a fixed IP address, but rather bound to any available port, in order to be run multiple test scenarios in conjunction. 50 | 51 | ## Consistent deliverables 52 | Just commiting a finished story to version control isn't enough nowadays. The way an application is deployed and on 53 | what system plays a part in determining wether it will function correctly. Applications should come with automated and 54 | tested means to deploy on the production environment. 55 | 56 | This can be achieved with all manner of tools, like _ansible_, _puppet_ or _chef_. 57 | -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object Dependencies { 4 | val resolutionRepos = Seq( 5 | "spray repo" at "http://repo.spray.io", 6 | "scalaz-bintray" at "http://dl.bintray.com/scalaz/releases", 7 | "softprops-maven" at "http://dl.bintray.com/content/softprops/maven" 8 | ) 9 | 10 | val sprayVersion = "1.3.4" 11 | val akkaVersion = "2.4.14" 12 | 13 | val sprayClient = "io.spray" %% "spray-client" % sprayVersion 14 | val akkaHttp = "com.typesafe.akka" %% "akka-http-core" % "10.0.3" 15 | val sprayRouting = "io.spray" %% "spray-routing" % sprayVersion 16 | val sprayJson = "io.spray" %% "spray-json" % "1.3.2" 17 | val akkaActor = "com.typesafe.akka" %% "akka-actor" % akkaVersion 18 | val akkaSlf4j = "com.typesafe.akka" %% "akka-slf4j" % akkaVersion 19 | val slf4j = "org.slf4j" % "slf4j-api" % "1.7.21" 20 | val logback = "ch.qos.logback" % "logback-classic" % "1.1.7" 21 | val spotifyDocker = "com.spotify" % "docker-client" % "3.6.8" 22 | val spotifyDns = "com.spotify" % "dns" % "3.1.4" 23 | val scalaTest = "org.scalatest" %% "scalatest" % "3.0.1" 24 | val scalaMock = "org.scalamock" %% "scalamock-scalatest-support" % "3.6.0" 25 | val akkaTestKit = "com.typesafe.akka" %% "akka-testkit" % akkaVersion 26 | } 27 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.15 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.7.2") 2 | addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.6.0") 3 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.0.0-RC2") 4 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0") 5 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.3.0") 6 | --------------------------------------------------------------------------------