Skip to content

Commit

Permalink
[PM-3144, PM-3106]: Checkpointing app main (#75)
Browse files Browse the repository at this point in the history
* PM-3144: Skeleton for the main entry point.

* PM-3144: Configuration and parser.

* PM-3144: Default application config.

* PM-3144: Local network config.

* PM-3144: Test parsing the config.

* PM-3144: ECDSA key generator entry point.

* PM-3144: Example of running key key generation and the service.

* PM-3144: Remove unused imports.

* PM-3144: Allow private-key to be a path.

* PM-3144: Composition of network components.

* PM-3144: Checkpointing tracers.

* PM-3144: Network splitter.

* PM-3144: Database connection.

* PM-3144: Remove the -n CLI option.

* PM-3144: Storages.

* PM-3144: Block pruning.

* PM-3144: Checkpointing Service.

* PM-3144: Merge network tracers.

* PM-3144: Wire in the HotStuffService.

* PM-3144: Signing that accepts a genesis block.

* PM-3144: Better comments in BlockPruning.

* PM-3144: Update spec docstring.

* PM-3144: Explicit return values for composition functions.

* PM-3144: Alternative way of picking the new root.

* PM-3144: Fix configuration: keys need to be different.
  • Loading branch information
aakoshh committed Sep 14, 2021
1 parent 22794f6 commit 33e7696
Show file tree
Hide file tree
Showing 33 changed files with 1,848 additions and 116 deletions.
69 changes: 68 additions & 1 deletion README.md
Expand Up @@ -58,7 +58,7 @@ To run tests, use the wild cards again and the `.test` postix:

```console
mill __.test
mill --watch metronome[2.13.4].rocksdb.test
mill --watch metronome[2.13.4].rocksdb.props.test
```

To run a single test class, use the `.single` method with the full path to the spec. Note that `ScalaTest` tests are in the `specs` subdirectories while `ScalaCheck` ones are in `props`.
Expand Down Expand Up @@ -123,3 +123,70 @@ $ tail -f ~/.metronome/examples/robot/logs/node-0.log
```

To clear out everything before a restart, just run `rm -rf ~/.metronome/examples/robot`.


## Running the Checkpointing Service

First generate some ECDSA keys to be used by the federation, as well as one to be
used by the PoW interpreter (it has to be different from the key used by the service):

```console
$ mill metronome[2.13.4].checkpointing.app.run keygen > service-keys.json
[424/424] metronome[2.13.4].checkpointing.app.run
$ cat service-keys.json
{
"publicKey" : "ab5944b35a12f87133b5cf525b7a2ecc698a059b4d46898c4f58970e73069aeebeb55765ade41d781120c27ef8a88ae1cb2ff5c2e70345373b524dcfcb6636d5",
"privateKey" : "057b39a793c06683b4ebec95456f576be4c44e4404e126f0a46689d259209a75"
}
$ mill metronome[2.13.4].checkpointing.app.run keygen > interpreter-keys.json
[424/424] metronome[2.13.4].checkpointing.app.run
```

The results can be parsed for example with [jq](https://stedolan.github.io/jq/), as seen in the example below.

Create a config file to provide the necessary settings which the default `application.conf` doesn't have. For example:

```shell
cat <<EOF >example.conf
include "/application.conf"
metronome {
checkpointing {
federation {
self {
host = $(dig +short myip.opendns.com @resolver4.opendns.com)
port = 40000
private-key = $(jq -r ".privateKey" service-keys.json)
}
# Append here other the other nodes you create.
others = [
]
}
local {
interpreter {
public-key = $(jq -r ".publicKey" interpreter-keys.json)
}
}
}
}
EOF
```

Build the service into a fat JAR so we can pass system properties when we run it:

```shell
SCALA_VER=2.13.4
ASSEMBLY_JAR=${PWD}/out/metronome/${SCALA_VER}/checkpointing/app/assembly/dest/out.jar
mill metoronme[$SCALA_VER].checkpointing.app.assembly
```

Start the service by pointing it at the example configuration:

```console
$ java -cp $ASSEMBLY_JAR -Dconfig.file=example.conf io.iohk.metronome.checkpointing.app.CheckpointingApp service
13:22:02.853 WARN i.i.m.h.s.tracing.ConsensusEvent Timeout {"viewNumber":7,"messageCounter":{"past":0,"present":0,"future":0}}
13:22:02.895 WARN i.i.m.c.s.tracing.CheckpointingEvent InterpreterUnavailable {"messageType":"CreateBlockBodyRequest"}
```

Detailed logs should appear in `~/.metronome/checkpointing/logs/service.log`.
5 changes: 4 additions & 1 deletion build.sc
Expand Up @@ -389,8 +389,11 @@ class MetronomeModule(val crossScalaVersion: String) extends CrossScalaModule {

override def ivyDeps = super.ivyDeps() ++ Agg(
ivy"ch.qos.logback:logback-classic:${VersionOf.logback}",
ivy"io.iohk::scalanet-discovery:${VersionOf.scalanet}"
ivy"io.iohk::scalanet-discovery:${VersionOf.scalanet}",
ivy"com.github.scopt::scopt:${VersionOf.scopt}"
)

object specs extends SpecsModule
}
}

Expand Down
80 changes: 80 additions & 0 deletions metronome/checkpointing/app/resources/application.conf
@@ -0,0 +1,80 @@
metronome {
checkpointing {
# A name for the node that we can use to distinguish
# if we run multiple instances on the same machine.
name = service

federation {
# Public address of this federation member; required.
self {
host = null
port = 9080
# Private ECDSA key of this federation member in hexadecimal format; required.
# It can either the the key itself, or a path to a file which contains the key.
# The public key will be derived from the private key.
private-key = null
}

# List of other federation members; records of {host, port, public-key}.
others = []

# The maximum number of tolerated Byzantine nodes; optional.
# At most (n-1)/3, but can be lower to require smaller quorum.
maxFaulty = null
}

consensus {
# Minimum time to allow for a HotStuff round.
min-timeout = 5s
# Maximum time to allow for a HotStuff round, after numerous timeouts.
max-timeout = 15s
# Increment factor to apply on the timeout after a failed round.
timeout-factor = 1.2
}

# Network configuration to accept connections from remote federation nodes.
remote {
# Bind address for the checkpointing service remote interface.
listen {
host = 0.0.0.0
port = ${metronome.checkpointing.federation.self.port}
}
# Request roundtrip timeout.
timeout = 3s
}

# Network configuration to accept connection from the local interpreter.
local {
# Bind address for the checkpointing service local interface.
listen {
host = 127.0.0.1
port = 9081
}
# Node of the PoW Interpreter.
interpreter {
host = 127.0.0.1
port = 9082
# ECDSA key used by the interpreter to secure the connection; required.
public-key = null
}
# Request roundtrip timeout.
timeout = 3s
# Whether we should expect the Interpreter to send us notifications about
# the arrival of a checkpoint height, or check in every time we have to
# create a block. Depends on how the Interpreter is implemented, it's an
# optimisation to save unnecessary round trips.
expect-checkpoint-candidate-notifications = false
}

database {
# Storage location for RocksDB.
path = ${user.home}"/.metronome/checkpointing/db/"${metronome.checkpointing.name}
# Size of the ring buffer for the checkpointing ledger.
state-history-size = 100
# Number of blocks to keep before pruning.
block-history-size = 100
# Time to wait before pruning a block from history.
prune-interval = 60s
}
}
}
42 changes: 42 additions & 0 deletions metronome/checkpointing/app/resources/logback.xml
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>

<property name="encoder.pattern" value="%d{HH:mm:ss.SSS} %-5level %logger{36} %msg%n" />
<property name="log.file.dir" value="${user.home}/.metronome/checkpointing/logs" />

<!-- Properties `log.file.name` and `log.console.level` are expected to be set programmatically at startup using `System.setProperty`. -->

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${log.console.level}</level>
</filter>
<encoder>
<pattern>${encoder.pattern}</pattern>
</encoder>
</appender>

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.file.dir}/${log.file.name}.log</file>
<append>true</append>
<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
<fileNamePattern>${log.file.dir}/${log.file.name}.%i.log.zip</fileNamePattern>
<minIndex>1</minIndex>
<maxIndex>10</maxIndex>
</rollingPolicy>
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<maxFileSize>10MB</maxFileSize>
</triggeringPolicy>
<encoder>
<pattern>${encoder.pattern}</pattern>
</encoder>
</appender>

<logger name="io.netty" level="OFF"/>
<logger name="io.iohk.scalanet.peergroup" level="OFF"/>

<root level="DEBUG">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>

</configuration>
28 changes: 28 additions & 0 deletions metronome/checkpointing/app/specs/resources/test.conf
@@ -0,0 +1,28 @@
# Extend the defaults.
# The leading "/"" works when a file refers to the default module included in the resources.
# Here we could use "application" or "application.conf" without it, but when executed with
# `java -Dconfig.file=example.conf` only the one that begins with "/" seems to work.
include "/application"

metronome {
checkpointing {
federation {
self {
host = localhost
port = 40000
private-key = cd2a249a76d8e9fd0e538e651b9e97c3fc5efcceeb10fc98dd57fbdd156457e6
}

others = [
{host = localhost, port = 40001, public-key = ff7849206b7faef9557cf53333739ecd947698d76ba11ffabf2587435322b9a8b4f063faf97e5aace2a75b8f6714e5bd3d483cad6e830ae3036afcc4ff1b5369, private-key = 15cc92810f61bc705f939432197fee100bcc1a99d6cc66c7c28fa158d4144f84}
{host = localhost, port = 40002, public-key = cb020251d396614a35038dd2ff88fd2f1a5fd74c8bcad4b353fa605405c8b1b8c80ee12d2a10b1fca59424b16890c8115fbc94a68026369acc3c2603595e6387, private-key = a4769d076bb7eefeb1aba8aa97520d8f7f8bcd65049a128c3040f9dd5d3eeae6}
{host = localhost, port = 40003, public-key = 23fcab42e8f1078880b27aab4849092489bfa8d3e3b0faa54c9db89e89223c783ec7a3b2f8e6461b27778f78cea261a2272abe31c5601173b2964ef14af897dc, private-key = 9441f3e96104a11405cb0e03ceb693f889770dd2c155dab7573023e00e878ace}
]
}
local {
interpreter {
public-key = 65e2f6da1bb1e7f0b07f5b892c568acb5429833e30af3974eedd2137ebc9f1fb8b0c462d4ca558dda64c5da8cf10280a1f579556ac8a611bd2fa7199f5a2c69a
}
}
}
}
@@ -0,0 +1,43 @@
package io.iohk.metronome.checkpointing.app.config

import com.typesafe.config.ConfigFactory
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.Inside
import org.scalatest.matchers.should.Matchers
import java.nio.file.Path

class CheckpointingConfigParserSpec
extends AnyFlatSpec
with Inside
with Matchers {

behavior of "CheckpointingConfigParser"

it should "not parse the default configuration due to missing data" in {
inside(CheckpointingConfigParser.parse(ConfigFactory.load())) {
case Left(_) =>
succeed
}
}

it should "parse the with the test overrides" in {
inside(
CheckpointingConfigParser.parse(ConfigFactory.load("test.conf"))
) { case Right(config) =>
config.remote.listen.port shouldBe config.federation.self.port
config.federation.self.privateKey.isLeft shouldBe true
}
}

it should "parse when the private key is a path" in {
inside(
CheckpointingConfigParser.parse {
ConfigFactory.parseString(
"metronome.checkpointing.federation.self.private-key=./node.key"
) withFallback ConfigFactory.load("test.conf")
}
) { case Right(config) =>
config.federation.self.privateKey shouldBe Right(Path.of("./node.key"))
}
}
}
@@ -0,0 +1,54 @@
package io.iohk.metronome.checkpointing.app

import cats.effect.ExitCode
import com.typesafe.config.ConfigFactory
import monix.eval.{Task, TaskApp}
import io.iohk.metronome.checkpointing.app.config.{
CheckpointingConfigParser,
CheckpointingOptions
}

object CheckpointingApp extends TaskApp {
override def run(args: List[String]): Task[ExitCode] = {
CheckpointingOptions.parse(args) match {
case None =>
Task.pure(ExitCode.Error)

case Some(opts) =>
run(opts)
}
}

def run(opts: CheckpointingOptions): Task[ExitCode] =
opts.mode match {
case CheckpointingOptions.KeyGen =>
setLogProperties(opts, "keygen") >>
// Not parsing the configuration for this as it may be incomplete without the keys.
CheckpointingKeyGen.generateAndPrint.as(ExitCode.Success)

case CheckpointingOptions.Service =>
CheckpointingConfigParser.parse(ConfigFactory.load()) match {
case Left(error) =>
Task
.delay(println(s"Error parsing configuration: $error"))
.as(ExitCode.Error)

case Right(config) =>
setLogProperties(opts, config.name) >>
CheckpointingComposition
.compose(config)
.use(_ => Task.never.as(ExitCode.Success))
}
}

/** Set dynamic system properties expected by `logback.xml` before any logging module is loaded. */
def setLogProperties(
opts: CheckpointingOptions,
name: String
): Task[Unit] = Task {
// Separate log file for each node.
System.setProperty("log.file.name", name)
// Control how much logging goes on the console.
System.setProperty("log.console.level", opts.logLevel.toString)
}.void
}

0 comments on commit 33e7696

Please sign in to comment.