diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 799a1b996..d26a10fa5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -91,3 +91,18 @@ jobs: run: | source .venv/bin/activate ./test/${{matrix.test}} + test-without-mk: + runs-on: ubuntu-latest + strategy: + matrix: + test: + - graph_test.py + steps: + - uses: actions/checkout@v4 + - uses: eifinger/setup-uv@v1 + - name: Install project + run: uv sync --all-extras --dev + - name: Run tests + run: | + source .venv/bin/activate + ./test/${{matrix.test}} diff --git a/pyproject.toml b/pyproject.toml index cb9f39384..2b1849084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "rich==13.7.1", "tabulate==0.9.0", "PyYAML==6.0.2", + "pexpect==4.9.0", ] [project.scripts] diff --git a/src/warnet/graph.py b/src/warnet/graph.py index 1c91e1db2..34b98960d 100644 --- a/src/warnet/graph.py +++ b/src/warnet/graph.py @@ -1,6 +1,13 @@ +import os +import random +from importlib.resources import files from pathlib import Path import click +import inquirer +import yaml + +from .constants import DEFAULT_TAG, SUPPORTED_TAGS @click.group(name="graph", hidden=True) @@ -17,3 +24,188 @@ def import_json(infile: Path, outfile: Path, cb: str, ln_image: str): Returns XML file as string with or without --outfile option. """ raise Exception("Not Implemented") + + +def custom_graph( + num_nodes: int, + num_connections: int, + version: str, + datadir: Path, + fork_observer: bool, + fork_obs_query_interval: int, +): + datadir.mkdir(parents=False, exist_ok=False) + # Generate network.yaml + nodes = [] + connections = set() + + for i in range(num_nodes): + node = {"name": f"tank-{i:04d}", "connect": [], "image": {"tag": version}} + + # Add round-robin connection + next_node = (i + 1) % num_nodes + node["connect"].append(f"tank-{next_node:04d}") + connections.add((i, next_node)) + + # Add random connections + available_nodes = list(range(num_nodes)) + available_nodes.remove(i) + if next_node in available_nodes: + available_nodes.remove(next_node) + + for _ in range(min(num_connections - 1, len(available_nodes))): + random_node = random.choice(available_nodes) + # Avoid circular loops of A -> B -> A + if (random_node, i) not in connections: + node["connect"].append(f"tank-{random_node:04d}") + connections.add((i, random_node)) + available_nodes.remove(random_node) + + nodes.append(node) + + network_yaml_data = {"nodes": nodes} + network_yaml_data["fork_observer"] = { + "enabled": fork_observer, + "configQueryInterval": fork_obs_query_interval, + } + + with open(os.path.join(datadir, "network.yaml"), "w") as f: + yaml.dump(network_yaml_data, f, default_flow_style=False) + + # Generate node-defaults.yaml + default_yaml_path = files("resources.networks").joinpath("node-defaults.yaml") + with open(str(default_yaml_path)) as f: + defaults_yaml_content = f.read() + + with open(os.path.join(datadir, "node-defaults.yaml"), "w") as f: + f.write(defaults_yaml_content) + + click.echo( + f"Project '{datadir}' has been created with 'network.yaml' and 'node-defaults.yaml'." + ) + + +def inquirer_create_network(project_path: Path): + # Custom network configuration + questions = [ + inquirer.Text( + "network_name", + message=click.style("Enter your network name", fg="blue", bold=True), + validate=lambda _, x: len(x) > 0, + ), + inquirer.List( + "nodes", + message=click.style("How many nodes would you like?", fg="blue", bold=True), + choices=["8", "12", "20", "50", "other"], + default="12", + ), + inquirer.List( + "connections", + message=click.style( + "How many connections would you like each node to have?", + fg="blue", + bold=True, + ), + choices=["0", "1", "2", "8", "12", "other"], + default="8", + ), + inquirer.List( + "version", + message=click.style( + "Which version would you like nodes to run by default?", fg="blue", bold=True + ), + choices=SUPPORTED_TAGS, + default=DEFAULT_TAG, + ), + ] + + net_answers = inquirer.prompt(questions) + if net_answers is None: + click.secho("Setup cancelled by user.", fg="yellow") + return False + + if net_answers["nodes"] == "other": + custom_nodes = inquirer.prompt( + [ + inquirer.Text( + "nodes", + message=click.style("Enter the number of nodes", fg="blue", bold=True), + validate=lambda _, x: int(x) > 0, + ) + ] + ) + if custom_nodes is None: + click.secho("Setup cancelled by user.", fg="yellow") + return False + net_answers["nodes"] = custom_nodes["nodes"] + + if net_answers["connections"] == "other": + custom_connections = inquirer.prompt( + [ + inquirer.Text( + "connections", + message=click.style("Enter the number of connections", fg="blue", bold=True), + validate=lambda _, x: int(x) >= 0, + ) + ] + ) + if custom_connections is None: + click.secho("Setup cancelled by user.", fg="yellow") + return False + net_answers["connections"] = custom_connections["connections"] + fork_observer = click.prompt( + click.style( + "\nWould you like to enable fork-observer on the network?", fg="blue", bold=True + ), + type=bool, + default=True, + ) + fork_observer_query_interval = 20 + if fork_observer: + fork_observer_query_interval = click.prompt( + click.style( + "\nHow often would you like fork-observer to query node status (seconds)?", + fg="blue", + bold=True, + ), + type=int, + default=20, + ) + custom_network_path = project_path / "networks" / net_answers["network_name"] + click.secho("\nGenerating custom network...", fg="yellow", bold=True) + custom_graph( + int(net_answers["nodes"]), + int(net_answers["connections"]), + net_answers["version"], + custom_network_path, + fork_observer, + fork_observer_query_interval, + ) + return custom_network_path + + +@click.command() +def create(): + """Create a new warnet network""" + try: + project_path = Path(os.getcwd()) + # Check if the project has a networks directory + if not (project_path / "networks").exists(): + click.secho( + "The current directory does not have a 'networks' directory. Please run 'warnet init' or 'warnet create' first.", + fg="red", + bold=True, + ) + return False + custom_network_path = inquirer_create_network(project_path) + click.secho("\nNew network created successfully!", fg="green", bold=True) + click.echo("\nRun the following command to deploy this network:") + click.echo(f"warnet deploy {custom_network_path}") + except Exception as e: + click.echo(f"{e}\n\n") + click.secho(f"An error occurred while creating a new network:\n\n{e}\n\n", fg="red") + click.secho( + "Please report the above context to https://github.com/bitcoin-dev-project/warnet/issues", + fg="yellow", + ) + return False diff --git a/src/warnet/main.py b/src/warnet/main.py index e39876adc..28b933ef2 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -4,7 +4,7 @@ from .bitcoin import bitcoin from .control import down, logs, run, stop from .deploy import deploy -from .graph import graph +from .graph import create, graph from .image import image from .project import init, new, setup from .status import status @@ -30,6 +30,7 @@ def cli(): cli.add_command(setup) cli.add_command(status) cli.add_command(stop) +cli.add_command(create) if __name__ == "__main__": diff --git a/src/warnet/project.py b/src/warnet/project.py index f30ef2721..fb5e03aab 100644 --- a/src/warnet/project.py +++ b/src/warnet/project.py @@ -13,7 +13,7 @@ import inquirer import yaml -from .constants import DEFAULT_TAG, SUPPORTED_TAGS +from .graph import inquirer_create_network from .network import copy_network_defaults, copy_scenario_defaults @@ -198,7 +198,7 @@ def check_installation(tool_info: ToolInfo) -> ToolStatus: print("") print(" ╭───────────────────────────────────╮") - print(" │ Welcome to the Warnet Quickstart │") + print(" │ Welcome to Warnet setup │") print(" ╰───────────────────────────────────╯") print("") print(" Let's find out if your system has what it takes to run Warnet...") @@ -229,12 +229,12 @@ def check_installation(tool_info: ToolInfo) -> ToolStatus: check_results.append(check_installation(kubectl_info)) check_results.append(check_installation(helm_info)) else: - click.secho("Please re-run Quickstart.", fg="yellow") + click.secho("Please re-run setup.", fg="yellow") sys.exit(1) if ToolStatus.Unsatisfied in check_results: click.secho( - "Please fix the installation issues above and try quickstart again.", fg="yellow" + "Please fix the installation issues above and try setup again.", fg="yellow" ) sys.exit(1) else: @@ -281,154 +281,36 @@ def new_internal(directory: Path, from_init=False): click.secho(f"Error: Directory {directory} already exists", fg="red") return - answers = {} - - # Network name - network_name = inquirer.prompt( - [ - inquirer.Text( - "network_name", - message=click.style("Choose a network name", fg="blue", bold=True), - validate=lambda _, x: len(x) > 0, - ) - ] - ) - if network_name is None: - click.secho("Setup cancelled by user.", fg="yellow") - return False - answers.update(network_name) - - # Number of nodes - nodes_question = inquirer.prompt( - [ - inquirer.List( - "nodes", - message=click.style( - "How many nodes would you like in the network?", fg="blue", bold=True - ), - choices=["8", "12", "20", "50", "other"], - default="12", - ) - ] - ) - if nodes_question is None: - click.secho("Setup cancelled by user.", fg="yellow") - return False - - if nodes_question["nodes"] == "other": - custom_nodes = inquirer.prompt( - [ - inquirer.Text( - "nodes", - message=click.style("Enter the number of nodes", fg="blue", bold=True), - validate=lambda _, x: int(x) > 0, - ) - ] - ) - if custom_nodes is None: - click.secho("Setup cancelled by user.", fg="yellow") - return False - answers["nodes"] = custom_nodes["nodes"] - else: - answers["nodes"] = nodes_question["nodes"] - - # Number of connections - connections_question = inquirer.prompt( - [ - inquirer.List( - "connections", - message=click.style( - "How many connections would you like each node to have?", - fg="blue", - bold=True, - ), - choices=["0", "1", "2", "8", "12", "other"], - default="8", - ) - ] - ) - if connections_question is None: - click.secho("Setup cancelled by user.", fg="yellow") - return False + click.secho("\nCreating project structure...", fg="yellow", bold=True) + project_path = Path(os.path.expanduser(directory)) + create_warnet_project(project_path) - if connections_question["connections"] == "other": - custom_connections = inquirer.prompt( - [ - inquirer.Text( - "connections", - message=click.style("Enter the number of connections", fg="blue", bold=True), - validate=lambda _, x: int(x) >= 0, - ) - ] - ) - if custom_connections is None: - click.secho("Setup cancelled by user.", fg="yellow") - return False - answers["connections"] = custom_connections["connections"] - else: - answers["connections"] = connections_question["connections"] - - # Version - version_question = inquirer.prompt( + proj_answers = inquirer.prompt( [ - inquirer.List( - "version", + inquirer.Confirm( + "custom_network", message=click.style( - "Which version would you like nodes to run by default?", fg="blue", bold=True + "Do you want to create a custom network?", fg="blue", bold=True ), - choices=SUPPORTED_TAGS, - default=DEFAULT_TAG, - ) + default=True, + ), ] ) - if version_question is None: + if proj_answers is None: click.secho("Setup cancelled by user.", fg="yellow") return False - answers.update(version_question) - fork_observer = click.prompt( - click.style( - "\nWould you like to enable fork-observer on the network?", fg="blue", bold=True - ), - type=bool, - default=True, - ) - fork_observer_query_interval = 20 - if fork_observer: - fork_observer_query_interval = click.prompt( - click.style( - "\nHow often would you like fork-observer to query node status (seconds)?", - fg="blue", - bold=True, - ), - type=int, - default=20, - ) - - click.secho("\nCreating project structure...", fg="yellow", bold=True) - project_path = Path(os.path.expanduser(directory)) - create_warnet_project(project_path) - - click.secho("\nGenerating custom network...", fg="yellow", bold=True) - custom_network_path = project_path / "networks" / answers["network_name"] - custom_graph( - int(answers["nodes"]), - int(answers["connections"]), - answers["version"], - custom_network_path, - fork_observer, - fork_observer_query_interval, - ) - click.secho("\nSetup completed successfully!", fg="green", bold=True) + if proj_answers["custom_network"]: + click.secho("\nGenerating custom network...", fg="yellow", bold=True) + custom_network_path = inquirer_create_network(directory) click.echo( f"\nEdit the network files found in {custom_network_path} before deployment if you want to customise the network." ) - if fork_observer: - click.echo( - "If you enabled fork-observer you must forward the port from the cluster to your local machine:\n" - "`kubectl port-forward fork-observer 2323`\n" - "fork-observer will then be available at web address: localhost:2323" - ) + click.echo( + "If you enabled fork-observer you must forward the port from the cluster to your local machine:\n" + "`kubectl port-forward fork-observer 2323`\n" + "fork-observer will then be available at web address: localhost:2323" + ) click.echo("\nWhen you're ready, run the following command to deploy this network:") click.echo(f" warnet deploy {custom_network_path}") @@ -438,17 +320,7 @@ def new_internal(directory: Path, from_init=False): def init(): """Initialize a warnet project in the current directory""" current_dir = Path.cwd() - - custom_project = click.prompt( - click.style("\nWould you like to create a custom network?", fg="blue", bold=True), - type=bool, - default=True, - ) - if not custom_project: - create_warnet_project(current_dir, check_empty=True) - return 0 - else: - new_internal(directory=current_dir, from_init=True) + new_internal(directory=current_dir, from_init=True) def custom_graph( diff --git a/test/conf_test.py b/test/conf_test.py index c6495ada6..bc717a732 100755 --- a/test/conf_test.py +++ b/test/conf_test.py @@ -20,7 +20,7 @@ def run_test(self): self.setup_network() self.check_uacomment() finally: - self.stop_server() + self.cleanup() def setup_network(self): self.log.info("Setting up network") diff --git a/test/dag_connection_test.py b/test/dag_connection_test.py index 195ae0e7e..258052fc4 100755 --- a/test/dag_connection_test.py +++ b/test/dag_connection_test.py @@ -16,7 +16,7 @@ def run_test(self): self.setup_network() self.run_connect_dag_scenario() finally: - self.stop_server() + self.cleanup() def setup_network(self): self.log.info("Setting up network") diff --git a/test/graph_test.py b/test/graph_test.py new file mode 100755 index 000000000..5cc3200a6 --- /dev/null +++ b/test/graph_test.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 + +import os +import shutil + +import pexpect +from test_base import TestBase + +NETWORKS_DIR = "networks" + + +class GraphTest(TestBase): + def __init__(self): + super().__init__() + + def run_test(self): + try: + self.directory_not_exist() + os.mkdir(NETWORKS_DIR) + self.directory_exists() + + finally: + shutil.rmtree(NETWORKS_DIR) if os.path.exists(NETWORKS_DIR) else None + + def directory_not_exist(self): + self.sut = pexpect.spawn("warnet create") + self.sut.expect("init", timeout=50) + + def directory_exists(self): + self.sut = pexpect.spawn("warnet create") + self.sut.expect("name", timeout=10) + self.sut.sendline("ANewNetwork") + self.sut.expect("many", timeout=10) + self.sut.sendline("") + self.sut.expect("connections", timeout=10) + self.sut.sendline("") + self.sut.expect("version", timeout=10) + self.sut.sendline("") + self.sut.expect("enable fork-observer", timeout=10) + self.sut.sendline("") + self.sut.expect("seconds", timeout=10) + self.sut.sendline("") + self.sut.expect("successfully", timeout=50) + + +if __name__ == "__main__": + test = GraphTest() + test.run_test() diff --git a/test/ln_test.py b/test/ln_test.py index 22a86d1ba..576846b6b 100755 --- a/test/ln_test.py +++ b/test/ln_test.py @@ -24,7 +24,7 @@ def run_test(self): self.test_ln_payment_2_to_0() self.test_simln() finally: - self.stop_server() + self.cleanup() def setup_network(self): self.log.info("Setting up network") diff --git a/test/logging_test.py b/test/logging_test.py index 5f547fb53..9b8c0f9f3 100755 --- a/test/logging_test.py +++ b/test/logging_test.py @@ -29,7 +29,7 @@ def run_test(self): if self.connect_logging_process is not None: self.log.info("Terminating background connect_logging.sh process...") self.connect_logging_process.terminate() - self.stop_server() + self.cleanup() def start_logging(self): self.log.info("Running install_logging.sh") diff --git a/test/rpc_test.py b/test/rpc_test.py index 9cc0ee336..ba466ea53 100755 --- a/test/rpc_test.py +++ b/test/rpc_test.py @@ -20,7 +20,7 @@ def run_test(self): self.test_message_exchange() self.test_address_manager() finally: - self.stop_server() + self.cleanup() def setup_network(self): self.log.info("Setting up network") diff --git a/test/scenarios_test.py b/test/scenarios_test.py index 30d9a3f7e..01521ff92 100755 --- a/test/scenarios_test.py +++ b/test/scenarios_test.py @@ -20,7 +20,7 @@ def run_test(self): self.setup_network() self.test_scenarios() finally: - self.stop_server() + self.cleanup() def setup_network(self): self.log.info("Setting up network") diff --git a/test/services_test.py b/test/services_test.py index ec6422765..59048d3ce 100755 --- a/test/services_test.py +++ b/test/services_test.py @@ -18,7 +18,7 @@ def run_test(self): self.setup_network() self.check_fork_observer() finally: - self.stop_server() + self.cleanup() def setup_network(self): self.log.info("Setting up network") diff --git a/test/signet_test.py b/test/signet_test.py index b550596fd..5307ec9d9 100755 --- a/test/signet_test.py +++ b/test/signet_test.py @@ -20,7 +20,7 @@ def run_test(self): self.setup_network() self.check_signet_miner() finally: - self.stop_server() + self.cleanup() def setup_network(self): self.log.info("Setting up network") diff --git a/test/test_base.py b/test/test_base.py index 582ca5c8f..3250ecd95 100644 --- a/test/test_base.py +++ b/test/test_base.py @@ -1,4 +1,3 @@ -import atexit import json import logging import logging.config @@ -21,7 +20,6 @@ class TestBase: def __init__(self): self.setup_environment() self.setup_logging() - atexit.register(self.cleanup) self.log_expected_msgs: None | [str] = None self.log_unexpected_msgs: None | [str] = None self.log_msg_assertions_passed = False @@ -80,9 +78,6 @@ def output_reader(self, pipe, func): if line: func(line) - def stop_server(self): - self.cleanup() - def wait_for_predicate(self, predicate, timeout=5 * 60, interval=5): self.log.debug(f"Waiting for predicate with timeout {timeout}s and interval {interval}s") while timeout > 0: