Skip to content

Commit

Permalink
feat: Add option to specify the voting node's role (#526)
Browse files Browse the repository at this point in the history
This PR adds the option to specify the voting node's role (instead of
only parsing it from hostname). This is to allow for the voting node to
work as, for example, leader0 and use a different hostname/ip address in
its configuration.

Co-authored-by: Steven Johnson <stevenj@users.noreply.github.com>
  • Loading branch information
FelipeRosa and stevenj authored Aug 22, 2023
1 parent 40c5961 commit 122f83e
Show file tree
Hide file tree
Showing 7 changed files with 72 additions and 39 deletions.
3 changes: 2 additions & 1 deletion services/voting-node/voting_node/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ async def fetch_sorted_leaders_host_info(self, event_row_id: int) -> list[Leader
case [*leaders]:

def extract_leader_info(leader):
host_info = LeaderHostInfo(*leader["row"])
row = leader["row"]
host_info = LeaderHostInfo(hostname=row[0], consensus_leader_id=row[1], role=None)
logger.debug(f"{host_info.hostname}")
return host_info

Expand Down
3 changes: 3 additions & 0 deletions services/voting-node/voting_node/envvar.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
VOTING_NODE_STORAGE: Final = "VOTING_NODE_STORAGE"
"""Path to the voting node storage."""

VOTING_NODE_ROLE: Final = "VOTING_NODE_ROLE"
"""Role which this node will assume (e.g. leader0)."""

IS_NODE_RELOADABLE: Final = "IS_NODE_RELOADABLE"
"""Set the voting node mode to 'reloadable' if set to True."""

Expand Down
10 changes: 10 additions & 0 deletions services/voting-node/voting_node/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
VOTING_HOST,
VOTING_LOG_LEVEL,
VOTING_LOG_FORMAT,
VOTING_NODE_ROLE,
VOTING_NODE_STORAGE,
VOTING_PORT,
)
Expand Down Expand Up @@ -94,6 +95,13 @@ def voting_node_cli():
If left unset, it will look for envvar `JORM_PATH`.""",
)
@click.option(
"--node-role",
envvar=VOTING_NODE_ROLE,
help="""Role which the node will assume (e.g. leader0).
if let unset, it will look for envvar `VOTING_NODE_ROLE`.""",
)
@click.option(
"--jorm-path",
envvar=JORM_PATH,
Expand Down Expand Up @@ -142,6 +150,7 @@ def start(
log_format,
database_url,
node_storage,
node_role,
jorm_path,
jcli_path,
jorm_port_rest,
Expand All @@ -161,6 +170,7 @@ def start(
jorm_path,
database_url,
reloadable,
node_role,
)

voting = service.VotingService(api_config, settings)
Expand Down
7 changes: 6 additions & 1 deletion services/voting-node/voting_node/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Self
from typing import Any, Optional, Self

import yaml
from aiofile import async_open
Expand Down Expand Up @@ -69,6 +69,9 @@ class ServiceSettings:
# has changed.
reloadable: bool = False
"""Enable resetting and reloading the node service during runtime."""
role: Optional[str] = None
"""Specify which role the voting node should assume (e.g. leader0).
If not specified, the role will be defined by the node's hostname."""


@dataclass
Expand Down Expand Up @@ -144,6 +147,7 @@ class LeaderHostInfo:

hostname: str
consensus_leader_id: str
role: Optional[str]


@dataclass
Expand Down Expand Up @@ -386,6 +390,7 @@ class Voter:
# The voting power associated with this key
voting_power: int


@dataclass
class Objective:
row_id: int
Expand Down
13 changes: 8 additions & 5 deletions services/voting-node/voting_node/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,16 @@ def get_schedule(self):
# checks the hostname and returns the schedule
# according to its leadership role.
# raises exception is something goes wrong with the hostname
host_name: str = utils.get_hostname().lower()
match utils.get_hostname_role_n_digits(host_name):
case ("leader", "0"):
role_str = self.settings.role
if role_str is None:
role_str = utils.get_hostname().lower()

match utils.parse_node_role(role_str):
case utils.NodeRole("leader", 0):
return tasks.Leader0Schedule(self.settings)
case ("leader", _):
case utils.NodeRole("leader", _):
return tasks.LeaderSchedule(self.settings)
case ("follower", _):
case utils.NodeRole("follower", _):
return tasks.FollowerSchedule(self.settings)
case _:
return None
42 changes: 24 additions & 18 deletions services/voting-node/voting_node/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,13 @@ async def node_set_config(self):

# modify node config for all nodes
host_name = utils.get_hostname()

role_str = self.settings.role
if role_str is None:
role_str = host_name

host_ip = utils.get_hostname_addr()
role_n_digits = utils.get_hostname_role_n_digits(host_name)
role: utils.NodeRole = utils.parse_node_role(role_str)
p2p_port = self.settings.p2p_port

listen_rest = f"{host_ip}:{self.settings.rest_port}"
Expand All @@ -334,28 +339,29 @@ async def node_set_config(self):
trusted_peers = []

for peer in self.node.leaders:
match role_n_digits:
case ("leader", "0"):
# this node does not trust peers
pass
case ("leader", host_digits):
match utils.get_hostname_role_n_digits(peer.hostname):
# only append if peer digits are smaller than host digits
# This is to say that a example node "leader000" will
# append "leader00", but not "leader0000".
case ("leader", peer_digits) if peer_digits < host_digits:
peer_addr = f"/dns4/{peer.hostname}/tcp/{p2p_port}"
trusted_peers.append({"address": peer_addr})
case _:
pass
case ("follower", _):
# append all leaders
if role.name == "leader" and role.n == 0:
# this node does not trust peers
pass
elif role.name == "leader":
peer_role_str = peer.role
if peer_role_str is None:
peer_role_str = peer.hostname

peer_role = utils.parse_node_role(peer_role_str)
# only append if peer digits are smaller than host digits
# This is to say that a example node "leader000" will
# append "leader00", but not "leader0000".
if peer_role == "leader" and peer_role.n < role.n:
peer_addr = f"/dns4/{peer.hostname}/tcp/{p2p_port}"
trusted_peers.append({"address": peer_addr})
elif role.name == "follower":
# append all leaders
peer_addr = f"/dns4/{peer.hostname}/tcp/{p2p_port}"
trusted_peers.append({"address": peer_addr})

# node config from default template
config = utils.make_node_config(
role_n_digits,
role,
listen_rest,
listen_jrpc,
listen_p2p,
Expand Down
33 changes: 19 additions & 14 deletions services/voting-node/voting_node/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from loguru import logger
from pydantic.dataclasses import dataclass

from .jcli import JCli
from .models import Event, Genesis, LeaderHostInfo, NodeConfig
Expand Down Expand Up @@ -73,23 +74,27 @@ async def get_network_secret(secret_file: Path, jcli_path: str) -> str:
raise e


def get_hostname_role_n_digits(
host_name: str,
) -> tuple[Literal["leader", "follower"], str]:
"""."""
@dataclass
class NodeRole:
"""Represents the role a node is assuming."""

def match_hostname_leadership_pattern(host_name: str) -> Match[str] | None:
return re.match(LEADERSHIP_REGEX, host_name)
name: Literal["leader", "follower"]
n: int

res = match_hostname_leadership_pattern(host_name)
exc = Exception(f"hostname {host_name} must conform to '{LEADERSHIP_REGEX}'")

def parse_node_role(
s: str,
) -> NodeRole:
"""Parse a node role from a string."""
res = re.match(LEADERSHIP_REGEX, s)
exc = Exception(f"Role string '{s}' must conform to '{LEADERSHIP_REGEX}'")
if res is None:
raise exc
match res.groups():
case ("leader", n):
return ("leader", n)
return NodeRole("leader", int(n))
case ("follower", n):
return ("follower", n)
return NodeRole("follower", int(n))
case _:
raise exc

Expand Down Expand Up @@ -177,7 +182,7 @@ def follower_node_config(


def make_node_config(
leadership: tuple[Literal["leader", "follower"], str],
leadership: NodeRole,
listen_rest: str,
listen_jrpc: str,
listen_p2p: str,
Expand All @@ -187,7 +192,7 @@ def make_node_config(
) -> NodeConfig:
"""Configure a node from template, depending on its leadership and number."""
match leadership:
case ("leader", "0"):
case NodeRole("leader", 0):
return leader0_node_config(
listen_rest,
listen_jrpc,
Expand All @@ -196,7 +201,7 @@ def make_node_config(
storage,
topology_key,
)
case ("leader", _):
case NodeRole("leader", _):
return leader_node_config(
listen_rest,
listen_jrpc,
Expand All @@ -205,7 +210,7 @@ def make_node_config(
storage,
topology_key,
)
case ("follower", _):
case NodeRole("follower", _):
return follower_node_config(
listen_rest,
listen_jrpc,
Expand Down

0 comments on commit 122f83e

Please sign in to comment.