Skip to content

Commit 2fdff9a

Browse files
committed
[ETCM-48] Add json rpc http healthcheck
1 parent c9e10f5 commit 2fdff9a

13 files changed

+286
-66
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package io.iohk.ethereum.healthcheck
2+
3+
import scala.concurrent.{ExecutionContext, Future}
4+
import scala.util.{Failure, Success}
5+
6+
/**
7+
* Represents a health check, runs it and interprets the outcome.
8+
* The outcome can be either a normal result, an application error, or
9+
* an (unexpected) exception.
10+
*
11+
* @param description An one-word description of the health check.
12+
* @param f The function that runs the health check.
13+
* @param mapResultToError A function that interprets the result.
14+
* @param mapErrorToError A function that interprets the application error.
15+
* @param mapExceptionToError A function that interprets the (unexpected) exception.
16+
* @tparam Error The type of the application error.
17+
* @tparam Result The type of the actual value expected by normal termination of `f`.
18+
*/
19+
case class Healthcheck[Error, Result](
20+
description: String,
21+
f: () Future[Either[Error, Result]],
22+
mapResultToError: Result Option[String] = (_: Result) None,
23+
mapErrorToError: Error Option[String] = (error: Error) Some(String.valueOf(error)),
24+
mapExceptionToError: Throwable Option[String] = (t: Throwable) Some(String.valueOf(t))
25+
) {
26+
27+
def apply()(implicit ec: ExecutionContext): Future[HealthcheckResult] = {
28+
f().transform {
29+
case Success(Left(error))
30+
Success(HealthcheckResult(description, mapErrorToError(error)))
31+
case Success(Right(result))
32+
Success(HealthcheckResult(description, mapResultToError(result)))
33+
case Failure(t)
34+
Success(HealthcheckResult(description, mapExceptionToError(t)))
35+
}
36+
}
37+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package io.iohk.ethereum.healthcheck
2+
3+
final case class HealthcheckResponse(checks: List[HealthcheckResult]) {
4+
lazy val isOK: Boolean = checks.forall(_.isOK)
5+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package io.iohk.ethereum.healthcheck
2+
3+
final case class HealthcheckResult private (description: String, status: String, error: Option[String]) {
4+
assert(
5+
status == HealthcheckStatus.OK && error.isEmpty || status == HealthcheckStatus.ERROR && error.isDefined
6+
)
7+
8+
def isOK: Boolean = status == HealthcheckStatus.OK
9+
}
10+
11+
object HealthcheckResult {
12+
def apply(description: String, error: Option[String]): HealthcheckResult =
13+
new HealthcheckResult(
14+
description = description,
15+
status = error.fold(HealthcheckStatus.OK)(_ HealthcheckStatus.ERROR),
16+
error = error
17+
)
18+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package io.iohk.ethereum.healthcheck
2+
3+
object HealthcheckStatus {
4+
final val OK = "OK"
5+
final val ERROR = "ERROR"
6+
}

src/main/scala/io/iohk/ethereum/jsonrpc/JsonRpcControllerMetrics.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ case object JsonRpcControllerMetrics extends MetricsContainer {
1313
final val MethodsSuccessCounter = metrics.counter("json.rpc.methods.success.counter")
1414
final val MethodsExceptionCounter = metrics.counter("json.rpc.methods.exception.counter")
1515
final val MethodsErrorCounter = metrics.counter("json.rpc.methods.error.counter")
16+
17+
final val HealhcheckErrorCounter = metrics.counter("json.rpc.healthcheck.error.counter")
1618
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package io.iohk.ethereum.jsonrpc
2+
3+
import io.iohk.ethereum.healthcheck.HealthcheckResponse
4+
5+
import scala.concurrent.Future
6+
import scala.util.{Failure, Success}
7+
import scala.concurrent.ExecutionContext.Implicits.global
8+
9+
trait JsonRpcHealthChecker {
10+
def healthCheck(): Future[HealthcheckResponse]
11+
12+
def handleResponse(responseF: Future[HealthcheckResponse]): Future[HealthcheckResponse] = {
13+
responseF.andThen {
14+
case Success(response) if (!response.isOK) =>
15+
JsonRpcControllerMetrics.HealhcheckErrorCounter.increment()
16+
case Failure(t) =>
17+
JsonRpcControllerMetrics.HealhcheckErrorCounter.increment()
18+
}
19+
}
20+
21+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package io.iohk.ethereum.jsonrpc
2+
3+
import io.iohk.ethereum.healthcheck.Healthcheck
4+
5+
object JsonRpcHealthcheck {
6+
type T[R] = Healthcheck[JsonRpcError, R]
7+
8+
def apply[R](description: String, f: () ServiceResponse[R]): T[R] = Healthcheck(description, f)
9+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package io.iohk.ethereum.jsonrpc
2+
3+
import io.iohk.ethereum.healthcheck.HealthcheckResponse
4+
import io.iohk.ethereum.jsonrpc.EthService._
5+
import io.iohk.ethereum.jsonrpc.NetService._
6+
7+
import scala.concurrent.ExecutionContext.Implicits.global
8+
import scala.concurrent.Future
9+
10+
class NodeJsonRpcHealthChecker(
11+
netService: NetService,
12+
ethService: EthService
13+
) extends JsonRpcHealthChecker {
14+
15+
protected def mainService: String = "node health"
16+
17+
final val listeningHC = JsonRpcHealthcheck("listening", () netService.listening(NetService.ListeningRequest()))
18+
final val peerCountHC = JsonRpcHealthcheck("peerCount", () netService.peerCount(PeerCountRequest()))
19+
final val earliestBlockHC = JsonRpcHealthcheck(
20+
"earliestBlock",
21+
() ethService.getBlockByNumber(BlockByNumberRequest(BlockParam.Earliest, true))
22+
)
23+
final val latestBlockHC = JsonRpcHealthcheck(
24+
"latestBlock",
25+
() ethService.getBlockByNumber(BlockByNumberRequest(BlockParam.Latest, true))
26+
)
27+
final val pendingBlockHC = JsonRpcHealthcheck(
28+
"pendingBlock",
29+
() ethService.getBlockByNumber(BlockByNumberRequest(BlockParam.Pending, true))
30+
)
31+
32+
override def healthCheck(): Future[HealthcheckResponse] = {
33+
val listeningF = listeningHC()
34+
val peerCountF = peerCountHC()
35+
val earliestBlockF = earliestBlockHC()
36+
val latestBlockF = latestBlockHC()
37+
val pendingBlockF = pendingBlockHC()
38+
39+
val allChecksF = List(listeningF, peerCountF, earliestBlockF, latestBlockF, pendingBlockF)
40+
val responseF = Future.sequence(allChecksF).map(HealthcheckResponse)
41+
42+
handleResponse(responseF)
43+
}
44+
}

src/main/scala/io/iohk/ethereum/jsonrpc/server/http/BasicJsonRpcHttpServer.scala

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ import io.iohk.ethereum.utils.Logger
99
import scala.concurrent.ExecutionContext.Implicits.global
1010
import scala.util.{Failure, Success}
1111

12-
class BasicJsonRpcHttpServer(val jsonRpcController: JsonRpcController, config: JsonRpcHttpServerConfig)
13-
(implicit val actorSystem: ActorSystem)
14-
extends JsonRpcHttpServer with Logger {
12+
class BasicJsonRpcHttpServer(
13+
val jsonRpcController: JsonRpcController,
14+
val jsonRpcHealthChecker: JsonRpcHealthChecker,
15+
config: JsonRpcHttpServerConfig
16+
)(implicit val actorSystem: ActorSystem)
17+
extends JsonRpcHttpServer
18+
with Logger {
1519

1620
def run(): Unit = {
1721
val bindingResultF = Http(actorSystem).newServerAt(config.interface, config.port).bind(route)

src/main/scala/io/iohk/ethereum/jsonrpc/server/http/JsonRpcHttpServer.scala

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
package io.iohk.ethereum.jsonrpc.server.http
22

33
import akka.actor.ActorSystem
4-
import akka.http.scaladsl.model.StatusCodes
4+
import akka.http.scaladsl.model._
55
import akka.http.scaladsl.server.Directives._
6-
import akka.http.scaladsl.server.{MalformedRequestContentRejection, RejectionHandler, Route}
6+
import akka.http.scaladsl.server._
77
import ch.megard.akka.http.cors.javadsl.CorsRejection
88
import ch.megard.akka.http.cors.scaladsl.CorsDirectives._
99
import ch.megard.akka.http.cors.scaladsl.model.HttpOriginMatcher
1010
import ch.megard.akka.http.cors.scaladsl.settings.CorsSettings
1111
import de.heikoseeberger.akkahttpjson4s.Json4sSupport
12-
import io.iohk.ethereum.jsonrpc.{JsonRpcController, JsonRpcErrors, JsonRpcRequest, JsonRpcResponse}
12+
import io.iohk.ethereum.jsonrpc._
1313
import io.iohk.ethereum.utils.{ConfigUtils, Logger}
1414
import java.security.SecureRandom
1515
import org.json4s.JsonAST.JInt
@@ -20,6 +20,7 @@ import scala.util.Try
2020

2121
trait JsonRpcHttpServer extends Json4sSupport {
2222
val jsonRpcController: JsonRpcController
23+
val jsonRpcHealthChecker: JsonRpcHealthChecker
2324

2425
implicit val serialization = native.Serialization
2526

@@ -32,7 +33,8 @@ trait JsonRpcHttpServer extends Json4sSupport {
3233
.withAllowedOrigins(corsAllowedOrigins)
3334

3435
implicit def myRejectionHandler: RejectionHandler =
35-
RejectionHandler.newBuilder()
36+
RejectionHandler
37+
.newBuilder()
3638
.handle {
3739
case _: MalformedRequestContentRejection =>
3840
complete((StatusCodes.BadRequest, JsonRpcResponse("2.0", None, Some(JsonRpcErrors.ParseError), JInt(0))))
@@ -42,7 +44,9 @@ trait JsonRpcHttpServer extends Json4sSupport {
4244
.result()
4345

4446
val route: Route = cors(corsSettings) {
45-
(pathEndOrSingleSlash & post) {
47+
(path("healthcheck") & pathEndOrSingleSlash & get) {
48+
handleHealthcheck()
49+
} ~ (pathEndOrSingleSlash & post) {
4650
entity(as[JsonRpcRequest]) { request =>
4751
handleRequest(request)
4852
} ~ entity(as[Seq[JsonRpcRequest]]) { request =>
@@ -56,6 +60,24 @@ trait JsonRpcHttpServer extends Json4sSupport {
5660
*/
5761
def run(): Unit
5862

63+
private def handleHealthcheck(): StandardRoute = {
64+
val responseF = jsonRpcHealthChecker.healthCheck()
65+
val httpResponseF =
66+
responseF.map {
67+
case response if response.isOK
68+
HttpResponse(
69+
status = StatusCodes.OK,
70+
entity = HttpEntity(ContentTypes.`application/json`, serialization.writePretty(response))
71+
)
72+
case response
73+
HttpResponse(
74+
status = StatusCodes.InternalServerError,
75+
entity = HttpEntity(ContentTypes.`application/json`, serialization.writePretty(response))
76+
)
77+
}
78+
complete(httpResponseF)
79+
}
80+
5981
private def handleRequest(request: JsonRpcRequest) = {
6082
complete(jsonRpcController.handleRequest(request))
6183
}
@@ -67,12 +89,18 @@ trait JsonRpcHttpServer extends Json4sSupport {
6789

6890
object JsonRpcHttpServer extends Logger {
6991

70-
def apply(jsonRpcController: JsonRpcController, config: JsonRpcHttpServerConfig, secureRandom: SecureRandom)
71-
(implicit actorSystem: ActorSystem): Either[String, JsonRpcHttpServer] = config.mode match {
72-
case "http" => Right(new BasicJsonRpcHttpServer(jsonRpcController, config)(actorSystem))
73-
case "https" => Right(new JsonRpcHttpsServer(jsonRpcController, config, secureRandom)(actorSystem))
74-
case _ => Left(s"Cannot start JSON RPC server: Invalid mode ${config.mode} selected")
75-
}
92+
def apply(
93+
jsonRpcController: JsonRpcController,
94+
jsonRpcHealthchecker: JsonRpcHealthChecker,
95+
config: JsonRpcHttpServerConfig,
96+
secureRandom: SecureRandom
97+
)(implicit actorSystem: ActorSystem): Either[String, JsonRpcHttpServer] =
98+
config.mode match {
99+
case "http" => Right(new BasicJsonRpcHttpServer(jsonRpcController, jsonRpcHealthchecker, config)(actorSystem))
100+
case "https" =>
101+
Right(new JsonRpcHttpsServer(jsonRpcController, jsonRpcHealthchecker, config, secureRandom)(actorSystem))
102+
case _ => Left(s"Cannot start JSON RPC server: Invalid mode ${config.mode} selected")
103+
}
76104

77105
trait JsonRpcHttpServerConfig {
78106
val mode: String
@@ -99,9 +127,15 @@ object JsonRpcHttpServer extends Logger {
99127

100128
override val corsAllowedOrigins = ConfigUtils.parseCorsAllowedOrigins(rpcHttpConfig, "cors-allowed-origins")
101129

102-
override val certificateKeyStorePath: Option[String] = Try(rpcHttpConfig.getString("certificate-keystore-path")).toOption
103-
override val certificateKeyStoreType: Option[String] = Try(rpcHttpConfig.getString("certificate-keystore-type")).toOption
104-
override val certificatePasswordFile: Option[String] = Try(rpcHttpConfig.getString("certificate-password-file")).toOption
130+
override val certificateKeyStorePath: Option[String] = Try(
131+
rpcHttpConfig.getString("certificate-keystore-path")
132+
).toOption
133+
override val certificateKeyStoreType: Option[String] = Try(
134+
rpcHttpConfig.getString("certificate-keystore-type")
135+
).toOption
136+
override val certificatePasswordFile: Option[String] = Try(
137+
rpcHttpConfig.getString("certificate-password-file")
138+
).toOption
105139
}
106140
}
107141
}

src/main/scala/io/iohk/ethereum/jsonrpc/server/http/JsonRpcHttpsServer.scala

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package io.iohk.ethereum.jsonrpc.server.http
33
import akka.actor.ActorSystem
44
import akka.http.scaladsl.{ConnectionContext, Http}
55
import ch.megard.akka.http.cors.scaladsl.model.HttpOriginMatcher
6-
import io.iohk.ethereum.jsonrpc.JsonRpcController
6+
import io.iohk.ethereum.jsonrpc.{JsonRpcController, JsonRpcHealthChecker}
77
import io.iohk.ethereum.jsonrpc.server.http.JsonRpcHttpServer.JsonRpcHttpServerConfig
88
import io.iohk.ethereum.jsonrpc.server.http.JsonRpcHttpsServer.HttpsSetupResult
99
import io.iohk.ethereum.utils.Logger
@@ -14,20 +14,28 @@ import scala.concurrent.ExecutionContext.Implicits.global
1414
import scala.io.Source
1515
import scala.util.{Failure, Success, Try}
1616

17-
class JsonRpcHttpsServer(val jsonRpcController: JsonRpcController, config: JsonRpcHttpServerConfig,
18-
secureRandom: SecureRandom)(implicit val actorSystem: ActorSystem)
19-
extends JsonRpcHttpServer with Logger {
17+
class JsonRpcHttpsServer(
18+
val jsonRpcController: JsonRpcController,
19+
val jsonRpcHealthChecker: JsonRpcHealthChecker,
20+
config: JsonRpcHttpServerConfig,
21+
secureRandom: SecureRandom
22+
)(implicit val actorSystem: ActorSystem)
23+
extends JsonRpcHttpServer
24+
with Logger {
2025

2126
def run(): Unit = {
22-
val maybeSslContext = validateCertificateFiles(config.certificateKeyStorePath, config.certificateKeyStoreType, config.certificatePasswordFile).flatMap{
23-
case (keystorePath, keystoreType, passwordFile) =>
24-
val passwordReader = Source.fromFile(passwordFile)
25-
try {
26-
val password = passwordReader.getLines().mkString
27-
obtainSSLContext(keystorePath, keystoreType, password)
28-
} finally {
29-
passwordReader.close()
30-
}
27+
val maybeSslContext = validateCertificateFiles(
28+
config.certificateKeyStorePath,
29+
config.certificateKeyStoreType,
30+
config.certificatePasswordFile
31+
).flatMap { case (keystorePath, keystoreType, passwordFile) =>
32+
val passwordReader = Source.fromFile(passwordFile)
33+
try {
34+
val password = passwordReader.getLines().mkString
35+
obtainSSLContext(keystorePath, keystoreType, password)
36+
} finally {
37+
passwordReader.close()
38+
}
3139
}
3240

3341
val maybeHttpsContext = maybeSslContext.map(sslContext => ConnectionContext.httpsServer(sslContext))
@@ -51,12 +59,16 @@ class JsonRpcHttpsServer(val jsonRpcController: JsonRpcController, config: JsonR
5159
* @param password for accessing the keystore with the certificate
5260
* @return the SSL context with the obtained certificate or an error if any happened
5361
*/
54-
private def obtainSSLContext(certificateKeyStorePath: String, certificateKeyStoreType: String, password: String): HttpsSetupResult[SSLContext] = {
62+
private def obtainSSLContext(
63+
certificateKeyStorePath: String,
64+
certificateKeyStoreType: String,
65+
password: String
66+
): HttpsSetupResult[SSLContext] = {
5567
val passwordCharArray: Array[Char] = password.toCharArray
5668

57-
val maybeKeyStore: HttpsSetupResult[KeyStore] = Try(KeyStore.getInstance(certificateKeyStoreType))
58-
.toOption.toRight(s"Certificate keystore invalid type set: $certificateKeyStoreType")
59-
val keyStoreInitResult: HttpsSetupResult[KeyStore] = maybeKeyStore.flatMap{ keyStore =>
69+
val maybeKeyStore: HttpsSetupResult[KeyStore] = Try(KeyStore.getInstance(certificateKeyStoreType)).toOption
70+
.toRight(s"Certificate keystore invalid type set: $certificateKeyStoreType")
71+
val keyStoreInitResult: HttpsSetupResult[KeyStore] = maybeKeyStore.flatMap { keyStore =>
6072
val keyStoreFileCreationResult = Option(new FileInputStream(certificateKeyStorePath))
6173
.toRight("Certificate keystore file creation failed")
6274
keyStoreFileCreationResult.flatMap { keyStoreFile =>
@@ -88,23 +100,27 @@ class JsonRpcHttpsServer(val jsonRpcController: JsonRpcController, config: JsonR
88100
* @param maybePasswordFile, with the path to the password file if it was configured
89101
* @return the certificate path and password file or the error detected
90102
*/
91-
private def validateCertificateFiles(maybeKeystorePath: Option[String],
92-
maybeKeystoreType: Option[String],
93-
maybePasswordFile: Option[String]): HttpsSetupResult[(String, String, String)] =
103+
private def validateCertificateFiles(
104+
maybeKeystorePath: Option[String],
105+
maybeKeystoreType: Option[String],
106+
maybePasswordFile: Option[String]
107+
): HttpsSetupResult[(String, String, String)] =
94108
(maybeKeystorePath, maybeKeystoreType, maybePasswordFile) match {
95109
case (Some(keystorePath), Some(keystoreType), Some(passwordFile)) =>
96110
val keystoreDirMissing = !new File(keystorePath).isFile
97111
val passwordFileMissing = !new File(passwordFile).isFile
98-
if(keystoreDirMissing && passwordFileMissing)
112+
if (keystoreDirMissing && passwordFileMissing)
99113
Left("Certificate keystore path and password file configured but files are missing")
100-
else if(keystoreDirMissing)
114+
else if (keystoreDirMissing)
101115
Left("Certificate keystore path configured but file is missing")
102-
else if(passwordFileMissing)
116+
else if (passwordFileMissing)
103117
Left("Certificate password file configured but file is missing")
104118
else
105119
Right((keystorePath, keystoreType, passwordFile))
106120
case _ =>
107-
Left("HTTPS requires: certificate-keystore-path, certificate-keystore-type and certificate-password-file to be configured")
121+
Left(
122+
"HTTPS requires: certificate-keystore-path, certificate-keystore-type and certificate-password-file to be configured"
123+
)
108124
}
109125

110126
override def corsAllowedOrigins: HttpOriginMatcher = config.corsAllowedOrigins

0 commit comments

Comments
 (0)