From 2daa94c733fc42cfc8e212655c01e20bf8e005bc Mon Sep 17 00:00:00 2001 From: willcl-ark Date: Wed, 21 Feb 2024 14:05:37 +0000 Subject: [PATCH 1/9] schema: add node graph schema --- src/templates/node_schema.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/templates/node_schema.json diff --git a/src/templates/node_schema.json b/src/templates/node_schema.json new file mode 100644 index 000000000..3217b82f7 --- /dev/null +++ b/src/templates/node_schema.json @@ -0,0 +1,22 @@ +{ + "type": "object", + "properties": { + "degree": {"type": "number"}, + "x": {"type": "number"}, + "y": {"type": "number"}, + "version": {"type": "string"}, + "image": {"type": "string"}, + "bitcoin_config": {"type": "string", "default": ""}, + "tc_netem": {"type": "string"}, + "exporter": {"type": "boolean", "default": false}, + "collect_logs": {"type": "boolean", "default": false}, + "build_args": {"type": "string"}, + "ln": {"type": "string"} + }, + "additionalProperties": false, + "oneOf": [ + {"required": ["version"]}, + {"required": ["image"]} + ], + "required": [] +} From 2ab0de722a6e238c7a0b2346231b0c55854dfdca Mon Sep 17 00:00:00 2001 From: willcl-ark Date: Wed, 21 Feb 2024 14:06:59 +0000 Subject: [PATCH 2/9] util: add validate_graph_schema func --- src/warnet/utils.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/warnet/utils.py b/src/warnet/utils.py index 92bd8f02c..21f82ce40 100644 --- a/src/warnet/utils.py +++ b/src/warnet/utils.py @@ -13,6 +13,8 @@ from pathlib import Path import networkx as nx +from jsonschema import validate +from templates import TEMPLATES from test_framework.messages import ser_uint256 from test_framework.p2p import MESSAGEMAP @@ -30,6 +32,7 @@ WEIGHTED_TAGS = [ tag for index, tag in enumerate(reversed(SUPPORTED_TAGS)) for _ in range(index + 1) ] +NODE_SCHEMA_PATH = TEMPLATES / "node_schema.json" def exponential_backoff(max_retries=5, base_delay=1, max_delay=32): @@ -479,3 +482,17 @@ def convert_unsupported_attributes(graph): continue else: edge_data[key] = str(value) + + +def load_schema(): + with open(NODE_SCHEMA_PATH) as schema_file: + return json.load(schema_file) + + +def validate_graph_schema(node_schema: dict, graph: nx.Graph): + """ + Validate a networkx.Graph against the node schema + """ + for i in list(graph.nodes): + validate(instance=graph.nodes[i], schema=node_schema) + From d668022c5f6e548922f7b62e4287853c479cee17 Mon Sep 17 00:00:00 2001 From: willcl-ark Date: Wed, 21 Feb 2024 14:07:55 +0000 Subject: [PATCH 3/9] warnet: load and validate graph schema --- src/warnet/warnet.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/warnet/warnet.py b/src/warnet/warnet.py index 10e2c7be9..8c38e7f80 100644 --- a/src/warnet/warnet.py +++ b/src/warnet/warnet.py @@ -13,7 +13,7 @@ from backends import ComposeBackend, KubernetesBackend from templates import TEMPLATES from warnet.tank import Tank -from warnet.utils import gen_config_dir +from warnet.utils import gen_config_dir, load_schema, validate_graph_schema logger = logging.getLogger("warnet") FO_CONF_NAME = "fork_observer_config.toml" @@ -36,6 +36,7 @@ def __init__(self, config_dir, backend, network_name: str): self.tanks: list[Tank] = [] self.deployment_file: Path | None = None self.backend = backend + self.node_schema = load_schema() def __str__(self) -> str: # TODO: bitcoin_conf and tc_netem can be added back in to this table @@ -119,6 +120,7 @@ def from_graph_file( f.write(graph_file) self.network_name = network self.graph = networkx.parse_graphml(graph_file.decode("utf-8"), node_type=int) + validate_graph_schema(self.node_schema, self.graph) self.tanks_from_graph() logger.info(f"Created Warnet using directory {self.config_dir}") return self @@ -127,6 +129,7 @@ def from_graph_file( def from_graph(cls, graph, backend="compose", network="warnet"): self = cls(Path(), backend, network) self.graph = graph + validate_graph_schema(self.node_schema, self.graph) self.tanks_from_graph() logger.info(f"Created Warnet using directory {self.config_dir}") return self @@ -139,6 +142,7 @@ def from_network(cls, network_name, backend="compose"): self.container_interface.warnet_from_deployment(self) # Get network graph edges from graph file (required for network restarts) self.graph = networkx.read_graphml(Path(self.config_dir / self.graph_name), node_type=int) + validate_graph_schema(self.node_schema, self.graph) if self.tanks == []: self.tanks_from_graph() return self From 9693f96a5442ac63268d34755b45e9435e335139 Mon Sep 17 00:00:00 2001 From: willcl-ark Date: Wed, 21 Feb 2024 14:08:45 +0000 Subject: [PATCH 4/9] tank: refactor tank.from_graph_node parser --- src/warnet/tank.py | 64 ++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 34 deletions(-) diff --git a/src/warnet/tank.py b/src/warnet/tank.py index ca4086c85..cf48f5cbb 100644 --- a/src/warnet/tank.py +++ b/src/warnet/tank.py @@ -18,7 +18,6 @@ CONTAINER_PREFIX_PROMETHEUS = "prometheus_exporter" - logger = logging.getLogger("tank") @@ -38,38 +37,47 @@ def __init__(self, index: int, config_dir: Path, warnet): self.netem = None self.exporter = False self.collect_logs = False + self.extra_build_args = "" + self.lnnode: LNNode | None = None self.rpc_port = 18443 self.rpc_user = "warnet_user" self.rpc_password = "2themoon" + self.zmqblockport = 28332 + self.zmqtxport = 28333 self._suffix = None self._ipv4 = None self._exporter_name = None - self.extra_build_args = "" - self.lnnode: LNNode | None = None - self.zmqblockport = 28332 - self.zmqtxport = 28333 def __str__(self) -> str: return f"Tank(index: {self.index}, version: {self.version}, conf: {self.conf}, conf file: {self.conf_file}, netem: {self.netem}, IPv4: {self._ipv4})" - def parse_version(self, node): - version = node.get("version", "") - image = node.get("image", "") - logger.debug(f"{version=:}") - logger.debug(f"{image=:}") - if version and image: + def _parse_version(self): + if self.version not in SUPPORTED_TAGS or ("/" in self.version and "#" in self.version): + raise Exception( + f"Unsupported version: can't be generated from Docker images: {self.version}" + ) + + def parse_graph_node(self, node): + # Dynamically parse properties based on the schema + for property, specs in self.warnet.node_schema["properties"].items(): + value = node.get(property, specs.get("default")) + if property == "version": + self._parse_version() + setattr(self, property, value) + logger.debug(f"{property}={value}") + + if self.version and self.image: raise Exception( - f"Tank has {version=:} and {image=:} supplied and can't be built. Provide one or the other." + f"Tank has {self.version=:} and {self.image=:} supplied and can't be built. Provide one or the other." ) - if image: - self.image = image - else: - if (version in SUPPORTED_TAGS) or ("/" in version and "#" in version): - self.version = version - else: - raise Exception( - f"Unsupported version: can't be generated from Docker images: {version}" - ) + + # Special handling for complex properties + if "ln" in node: + self.lnnode = LNNode(self.warnet, self, node["ln"], self.warnet.container_interface) + + self.config_dir = self.warnet.config_dir / str(self.suffix) + self.config_dir.mkdir(parents=True, exist_ok=True) + logger.debug(f"{self=:}") @classmethod def from_graph_node(cls, index, warnet, tank=None): @@ -77,23 +85,11 @@ def from_graph_node(cls, index, warnet, tank=None): index = int(index) config_dir = warnet.config_dir / str(f"{index:06}") config_dir.mkdir(parents=True, exist_ok=True) - self = tank if self is None: self = cls(index, config_dir, warnet) node = warnet.graph.nodes[index] - self.parse_version(node) - self.conf = node.get("bitcoin_config", self.conf) - self.netem = node.get("tc_netem", self.netem) - self.exporter = node.get("exporter", self.exporter) - self.collect_logs = node.get("collect_logs", self.collect_logs) - self.extra_build_args = node.get("build_args", self.extra_build_args) - - if "ln" in node: - self.lnnode = LNNode(self.warnet, self, node["ln"], self.warnet.container_interface) - - self.config_dir = self.warnet.config_dir / str(self.suffix) - self.config_dir.mkdir(parents=True, exist_ok=True) + self.parse_graph_node(node) return self @property From 8374e32a9548c4b1abf4415fdb1125227490f092 Mon Sep 17 00:00:00 2001 From: willcl-ark Date: Wed, 21 Feb 2024 14:10:09 +0000 Subject: [PATCH 5/9] util: fixups for new schema parser --- src/warnet/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/warnet/utils.py b/src/warnet/utils.py index 21f82ce40..3e2d4cd80 100644 --- a/src/warnet/utils.py +++ b/src/warnet/utils.py @@ -381,7 +381,7 @@ def set_execute_permission(file_path): def default_bitcoin_conf_args() -> str: - default_conf: Path = Path.cwd() / "src" / "templates" / "bitcoin.conf" + default_conf: Path = TEMPLATES / "bitcoin.conf" with default_conf.open("r") as f: defaults = parse_bitcoin_conf(f.read()) @@ -462,7 +462,7 @@ def create_cycle_graph( return graph -def convert_unsupported_attributes(graph): +def convert_unsupported_attributes(graph: nx.Graph): # Sometimes networkx complains about invalid types when writing the graph # (it just generated itself!). Try to convert them here just in case. for _, node_data in graph.nodes(data=True): From e7e758a50790961082138d1ec2cdfe14782cf9ab Mon Sep 17 00:00:00 2001 From: willcl-ark Date: Wed, 21 Feb 2024 14:10:29 +0000 Subject: [PATCH 6/9] compose: permit empty tank.version --- src/backends/compose/compose_backend.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/backends/compose/compose_backend.py b/src/backends/compose/compose_backend.py index 60cff6a08..daea0f122 100644 --- a/src/backends/compose/compose_backend.py +++ b/src/backends/compose/compose_backend.py @@ -359,10 +359,9 @@ def add_services(self, tank: Tank, services): assert tank.index is not None container_name = self.get_container_name(tank.index, ServiceType.BITCOIN) services[container_name] = {} - logger.debug(f"{tank.version=}") # Setup bitcoind, either release binary, pre-built image or built from source on demand - if "/" and "#" in tank.version: + if tank.version and ("/" and "#" in tank.version): # it's a git branch, building step is necessary repo, branch = tank.version.split("#") services[container_name]["image"] = f"{LOCAL_REGISTRY}:{branch}" From 84a9b97c19ae767d9f4e664efe2a5a7de6da2587 Mon Sep 17 00:00:00 2001 From: willcl-ark Date: Wed, 21 Feb 2024 13:59:31 +0000 Subject: [PATCH 7/9] server: debug traceback of exceptions --- src/warnet/server.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/warnet/server.py b/src/warnet/server.py index 5e259cde6..c76048596 100644 --- a/src/warnet/server.py +++ b/src/warnet/server.py @@ -9,6 +9,7 @@ import sys import threading import time +import traceback from datetime import datetime from io import BytesIO from logging import StreamHandler @@ -17,7 +18,7 @@ import networkx as nx import scenarios -from flask import Flask, request +from flask import Flask, jsonify, request from flask_jsonrpc.app import JSONRPC from flask_jsonrpc.exceptions import ServerError from warnet.utils import ( @@ -56,6 +57,7 @@ def __init__(self, backend): self.log_file_path = os.path.join(self.basedir, "warnet.log") self.logger: logging.Logger + self.setup_global_exception_handler() self.setup_logging() self.setup_rpc() self.logger.info(f"Started server version {SERVER_VERSION}") @@ -72,6 +74,25 @@ def __init__(self, backend): # before the config dir is populated with the deployment info self.image_build_lock = threading.Lock() + def setup_global_exception_handler(self): + """ + Use flask to log traceback of unhandled excpetions + """ + @self.app.errorhandler(Exception) + def handle_exception(e): + trace = traceback.format_exc() + self.logger.error(f"Unhandled exception: {e}\n{trace}") + response = { + "jsonrpc": "2.0", + "error": { + "code": -32603, + "message": "Internal server error", + "data": str(e), + }, + "id": request.json.get("id", None) if request.json else None, + } + return jsonify(response), 500 + def healthy(self): return "warnet is healthy" @@ -357,8 +378,8 @@ def thread_start(wn): f"Resumed warnet named '{network}' from config dir {wn.config_dir}" ) except Exception as e: - msg = f"Error starting network: {e}" - self.logger.error(msg) + trace = traceback.format_exc() + self.logger.error(f"Unhandled exception bringing network up: {e}\n{trace}") try: wn = Warnet.from_network(network, self.backend) @@ -389,8 +410,8 @@ def thread_start(wn, lock: threading.Lock): wn.apply_network_conditions() wn.connect_edges() except Exception as e: - msg = f"Error starting warnet: {e}" - self.logger.error(msg) + trace = traceback.format_exc() + self.logger.error(f"Unhandled exception starting warnet: {e}\n{trace}") config_dir = gen_config_dir(network) if config_dir.exists(): From a187a781aacbeae0a2c1d666859f43add524a68d Mon Sep 17 00:00:00 2001 From: willcl-ark Date: Wed, 21 Feb 2024 14:50:08 +0000 Subject: [PATCH 8/9] backend: use new build_args tank property name This matches the graph --- src/backends/compose/compose_backend.py | 8 ++++---- src/backends/kubernetes/kubernetes_backend.py | 6 +++--- src/warnet/tank.py | 6 +++--- src/warnet/warnet.py | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/backends/compose/compose_backend.py b/src/backends/compose/compose_backend.py index daea0f122..9ca3aa5da 100644 --- a/src/backends/compose/compose_backend.py +++ b/src/backends/compose/compose_backend.py @@ -332,10 +332,10 @@ def generate_deployment_file(self, warnet): dirs_exist_ok=True, ) - def config_args(self, tank): + def config_args(self, tank: Tank): args = self.default_config_args(tank) - if tank.conf is not None: - args = f"{args} -{tank.conf.replace(',', ' -')}" + if tank.bitcoin_config is not None: + args = f"{args} -{tank.bitcoin_config.replace(',', ' -')}" return args def default_config_args(self, tank): @@ -365,7 +365,7 @@ def add_services(self, tank: Tank, services): # it's a git branch, building step is necessary repo, branch = tank.version.split("#") services[container_name]["image"] = f"{LOCAL_REGISTRY}:{branch}" - build_image(repo, branch, LOCAL_REGISTRY, branch, tank.DEFAULT_BUILD_ARGS + tank.extra_build_args, arches="amd64") + build_image(repo, branch, LOCAL_REGISTRY, branch, tank.DEFAULT_BUILD_ARGS + tank.build_args, arches="amd64") self.copy_configs(tank) elif tank.image: # Pre-built custom image diff --git a/src/backends/kubernetes/kubernetes_backend.py b/src/backends/kubernetes/kubernetes_backend.py index b506457cb..a3a1fcf5b 100644 --- a/src/backends/kubernetes/kubernetes_backend.py +++ b/src/backends/kubernetes/kubernetes_backend.py @@ -364,7 +364,7 @@ def default_bitcoind_config_args(self, tank): defaults += f" -zmqpubrawtx=tcp://0.0.0.0:{tank.zmqtxport}" return defaults - def create_bitcoind_container(self, tank) -> client.V1Container: + def create_bitcoind_container(self, tank: Tank) -> client.V1Container: self.log.debug(f"Creating bitcoind container for tank {tank.index}") container_name = BITCOIN_CONTAINER_NAME container_image = None @@ -388,7 +388,7 @@ def create_bitcoind_container(self, tank) -> client.V1Container: branch, LOCAL_REGISTRY, branch, - tank.DEFAULT_BUILD_ARGS + tank.extra_build_args, + tank.DEFAULT_BUILD_ARGS + tank.build_args, arches="amd64", ) # Prebuilt major version @@ -396,7 +396,7 @@ def create_bitcoind_container(self, tank) -> client.V1Container: container_image = f"{DOCKER_REGISTRY_CORE}:{tank.version}" bitcoind_options = self.default_bitcoind_config_args(tank) - bitcoind_options += f" {tank.conf}" + bitcoind_options += f" {tank.bitcoin_config}" container_env = [client.V1EnvVar(name="BITCOIN_ARGS", value=bitcoind_options)] bitcoind_container = client.V1Container( diff --git a/src/warnet/tank.py b/src/warnet/tank.py index cf48f5cbb..f05f11258 100644 --- a/src/warnet/tank.py +++ b/src/warnet/tank.py @@ -32,12 +32,12 @@ def __init__(self, index: int, config_dir: Path, warnet): self.bitcoin_network = warnet.bitcoin_network self.version = "25.1" self.image: str = "" - self.conf = "" + self.bitcoin_config = "" self.conf_file = None self.netem = None self.exporter = False self.collect_logs = False - self.extra_build_args = "" + self.build_args = "" self.lnnode: LNNode | None = None self.rpc_port = 18443 self.rpc_user = "warnet_user" @@ -49,7 +49,7 @@ def __init__(self, index: int, config_dir: Path, warnet): self._exporter_name = None def __str__(self) -> str: - return f"Tank(index: {self.index}, version: {self.version}, conf: {self.conf}, conf file: {self.conf_file}, netem: {self.netem}, IPv4: {self._ipv4})" + return f"Tank(index: {self.index}, version: {self.version}, conf: {self.bitcoin_config}, conf file: {self.conf_file}, netem: {self.netem}, IPv4: {self._ipv4})" def _parse_version(self): if self.version not in SUPPORTED_TAGS or ("/" in self.version and "#" in self.version): diff --git a/src/warnet/warnet.py b/src/warnet/warnet.py index 8c38e7f80..7ba608492 100644 --- a/src/warnet/warnet.py +++ b/src/warnet/warnet.py @@ -86,7 +86,7 @@ def _warnet_dict_representation(self) -> dict: has_ln = any(tank.lnnode and tank.lnnode.impl for tank in self.tanks) tanks = [] for tank in self.tanks: - tank_data = [tank.index, tank.version, tank.ipv4, tank.conf, tank.netem] + tank_data = [tank.index, tank.version, tank.ipv4, tank.bitcoin_config, tank.netem] if has_ln: tank_data.extend( [ From 1c10aadb6d335b3e7f67052ed9ccdbca8825ecff Mon Sep 17 00:00:00 2001 From: willcl-ark Date: Thu, 22 Feb 2024 22:18:10 +0000 Subject: [PATCH 9/9] rpc: add `graph valiate` subcommand --- src/cli/graph.py | 8 ++++++++ src/warnet/server.py | 15 +++++++++++++++ test/graph_test.py | 4 ++++ 3 files changed, 27 insertions(+) diff --git a/src/cli/graph.py b/src/cli/graph.py index df0b2f560..547fd3dc5 100644 --- a/src/cli/graph.py +++ b/src/cli/graph.py @@ -36,3 +36,11 @@ def create( }, ) ) + +@graph.command() +@click.argument("graph", type=Path) +def validate(graph: Path): + """ + Validate a graph file against the schema. + """ + print(rpc_call("graph_validate", {"graph_path": graph.as_posix()})) diff --git a/src/warnet/server.py b/src/warnet/server.py index c76048596..71734df64 100644 --- a/src/warnet/server.py +++ b/src/warnet/server.py @@ -16,6 +16,7 @@ from logging.handlers import RotatingFileHandler from pathlib import Path +import jsonschema import networkx as nx import scenarios from flask import Flask, jsonify, request @@ -24,6 +25,8 @@ from warnet.utils import ( create_cycle_graph, gen_config_dir, + load_schema, + validate_graph_schema, ) from warnet.warnet import Warnet @@ -169,6 +172,7 @@ def setup_rpc(self): self.jsonrpc.register(self.network_export) # Graph self.jsonrpc.register(self.graph_generate) + self.jsonrpc.register(self.graph_validate) # Debug self.jsonrpc.register(self.generate_deployment) # Server @@ -457,6 +461,17 @@ def graph_generate( self.logger.error(msg) raise ServerError(message=msg) from e + def graph_validate(self, graph_path: str) -> str: + + schema = load_schema() + with open(graph_path) as f: + graph = nx.parse_graphml(f.read(), node_type=int) + try: + validate_graph_schema(schema, graph) + except (jsonschema.ValidationError, jsonschema.SchemaError) as e: + raise ServerError(message=f"Schema of {graph_path} is invalid: {e}") from e + return f"Schema of {graph_path} is valid" + def network_down(self, network: str = "warnet") -> str: """ Stop all containers in . diff --git a/test/graph_test.py b/test/graph_test.py index 02ab58409..6bd9a19dd 100755 --- a/test/graph_test.py +++ b/test/graph_test.py @@ -23,6 +23,10 @@ with open(tf, "w") as file: file.write(xml) + # Validate the graph schema + assert "invalid" not in base.warcli(f"graph validate {Path(tf)}") + print(f"Graph at {tf} validated successfully") + # Test that the graph actually works print(base.warcli(f"network start {Path(tf)}")) base.wait_for_all_tanks_status(target="running")