diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 637c12b..affee76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,9 @@ name: CI on: pull_request: branches: ["master", "release/**"] + paths-ignore: + - "**/*.md" + - "docs/**" concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/nodectl-e2e.yml b/.github/workflows/nodectl-e2e.yml new file mode 100644 index 0000000..cad0511 --- /dev/null +++ b/.github/workflows/nodectl-e2e.yml @@ -0,0 +1,75 @@ +name: nodectl-e2e + +on: + # Auto trigger: PR approved targeting release/nodectl/* + pull_request_review: + types: [submitted] + + # Manual trigger: label "run-e2e" on PR targeting release/nodectl/* + pull_request: + types: [labeled] + branches: + - 'release/nodectl/**' + + # Manual dispatch + workflow_dispatch: + +concurrency: + group: nodectl-e2e-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + if: > + ( + github.event_name == 'pull_request_review' && + github.event.review.state == 'approved' && + startsWith(github.event.pull_request.base.ref, 'release/nodectl/') + ) || + ( + github.event_name == 'pull_request' && + github.event.label.name == 'run-e2e' + ) || + github.event_name == 'workflow_dispatch' + defaults: + run: + working-directory: src + steps: + - uses: actions/checkout@v5 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config clang libssl-dev libzstd-dev \ + libgoogle-perftools-dev build-essential xxd jq python3 python3-yaml + + - name: Install bun + uses: oven-sh/setup-bun@v2 + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src -> target + shared-key: nodectl-e2e + + - name: Install bun deps + run: bun install + working-directory: src/node/tests/test_load_net + + - name: Run nodectl e2e test + env: + MASTER_WALLET_KEY: ${{ secrets.MASTER_WALLET_KEY }} + run: bash node/tests/test_run_net_py/test_nodectl_ci.sh + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: nodectl-e2e-logs + path: | + src/node/tests/test_run_net_py/tmp/nodectl-service.log + src/node/tests/test_run_net_py/tmp/singlehost-bootstrap.log diff --git a/src/node-control/Makefile b/src/node-control/Makefile index e91faf5..8385465 100644 --- a/src/node-control/Makefile +++ b/src/node-control/Makefile @@ -22,3 +22,43 @@ CONFIG_IDS := 15 16 17 32 34 36 $(CONFIG_IDS): cargo run -p nodectl -- config-param --config="$(CONFIG)" $@ + +# ── Singlehost integration test ─────────────────────────────────────────────── +# Boots a local 6-node TON network and runs nodectl against it end-to-end. +# +# Variables (all optional): +# NOBUILD=1 skip cargo build (use pre-built binary) +# NODE_CNT=6 number of validator nodes +# PARTICIPANTS_WAIT=600 seconds to wait for election participants +# +# Examples: +# make singlehost +# make singlehost NOBUILD=1 +# make singlehost NODE_CNT=4 PARTICIPANTS_WAIT=900 + +SINGLEHOST_DIR ?= ../node/tests/test_run_net_py +NOBUILD ?= 0 +NODE_CNT ?= 6 +PARTICIPANTS_WAIT ?= 600 + +# File target: rebuilt only when requirements.txt changes (or venv is missing). +# Calling `make singlehost-venv` installs deps; subsequent calls are instant. +$(SINGLEHOST_DIR)/.venv/pyvenv.cfg: $(SINGLEHOST_DIR)/requirements.txt + python3 -m venv $(SINGLEHOST_DIR)/.venv + $(SINGLEHOST_DIR)/.venv/bin/pip install -q -r $(SINGLEHOST_DIR)/requirements.txt + +singlehost-venv: $(SINGLEHOST_DIR)/.venv/pyvenv.cfg + +test-e2e: $(SINGLEHOST_DIR)/.venv/pyvenv.cfg + cd $(SINGLEHOST_DIR) && \ + NOBUILD=$(NOBUILD) \ + NODE_CNT=$(NODE_CNT) \ + PARTICIPANTS_WAIT_SECONDS=$(PARTICIPANTS_WAIT) \ + .venv/bin/python3 run_singlehost_nodectl.py + +stop-e2e: + cd $(SINGLEHOST_DIR) && \ + .venv/bin/python3 test_run_net.py --stop + pkill -9 nodectl + +.PHONY: test-e2e stop-e2e singlehost-venv diff --git a/src/node-control/commands/src/commands/nodectl/auth_cmd.rs b/src/node-control/commands/src/commands/nodectl/auth_cmd.rs index 52e3ed0..50046fd 100644 --- a/src/node-control/commands/src/commands/nodectl/auth_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/auth_cmd.rs @@ -15,7 +15,7 @@ use common::{ }; use secrets_vault::{types::secret_id::SecretId, vault_builder::SecretVaultBuilder}; use service::auth::user_store::{store_password_blob, user_secret_id, validate_username}; -use std::path::Path; +use std::{io::Read, path::Path}; #[derive(clap::Args, Clone)] #[command(about = "Manage authentication users")] @@ -103,6 +103,11 @@ pub struct AddUserCmd { help = "User role [possible values: operator, nominator]" )] role: Role, + #[arg( + long = "password-stdin", + help = "Read password from stdin instead of interactive prompt (no confirmation)" + )] + password_stdin: bool, } #[derive(clap::Args, Clone)] @@ -151,8 +156,22 @@ impl AddUserCmd { } let min_len = auth.min_password_length; - let password = - rpassword::prompt_password("Enter password: ").context("failed to read password")?; + let password = if self.password_stdin { + let mut input = String::new(); + std::io::stdin() + .read_to_string(&mut input) + .context("failed to read password from stdin")?; + input.trim_end_matches(['\n', '\r']).to_owned() + } else { + let pw = rpassword::prompt_password("Enter password: ") + .context("failed to read password")?; + let confirm = rpassword::prompt_password("Confirm password: ") + .context("failed to read password confirmation")?; + if pw != confirm { + anyhow::bail!("passwords do not match"); + } + pw + }; if password.is_empty() { anyhow::bail!("password cannot be empty"); @@ -161,13 +180,6 @@ impl AddUserCmd { anyhow::bail!("password must be at least {min_len} characters"); } - let confirm = rpassword::prompt_password("Confirm password: ") - .context("failed to read password confirmation")?; - - if password != confirm { - anyhow::bail!("passwords do not match"); - } - let hash = hash_password(&password).context("failed to hash password")?; let vault = SecretVaultBuilder::from_env().await.context("failed to open vault")?; let secret_id = user_secret_id(&self.username); diff --git a/src/node/tests/test_run_net_py/requirements.txt b/src/node/tests/test_run_net_py/requirements.txt new file mode 100644 index 0000000..cf5e9cc --- /dev/null +++ b/src/node/tests/test_run_net_py/requirements.txt @@ -0,0 +1 @@ +pyyaml>=6.0,<7 diff --git a/src/node/tests/test_run_net_py/run_singlehost_nodectl.py b/src/node/tests/test_run_net_py/run_singlehost_nodectl.py new file mode 100644 index 0000000..74a756c --- /dev/null +++ b/src/node/tests/test_run_net_py/run_singlehost_nodectl.py @@ -0,0 +1,815 @@ +#!/usr/bin/env python3 +""" +One-button bootstrap for local singlehost TON network + nodectl service. +Python equivalent of run_singlehost_nodectl.sh + +Phases: + 1. Build nodectl (skip with NOBUILD=1) + 2. Generate nodectl config + create shared control-client key + 3. Start singlehost network (--elections --control-client-public-key ), + stop, restart all nodes once + 4. Wait for blockchain progress + 5. Complete nodectl config via CLI (import per-node keys, wallets, nodes, + tick intervals, pools, bindings, enable elections) + 6. Top up master wallet + 7. Start nodectl service in background + 8. Wait for validator wallets/pools to open, top them up + 9. Wait for election participants + 10. Validate REST API: compare nodectl stake data with on-chain elector data + 11. Summary and exit assertions + +Required env var: + MASTER_WALLET_KEY — 64-byte hex private key of the funded zerostate faucet wallet + (or place it in node/tests/test_load_net/.env) + +Optional env vars: + HTTP_API_URL, NODE_CNT, MASTER_TOPUP_TON, WALLET_TOPUP_TON, POOL_TOPUP_TON, + PARTICIPANTS_WAIT_SECONDS, NOBUILD, KEEP_NODECTL_ON_SUCCESS, NODECTL_LOG, SCRIPT_LOG +""" + +from __future__ import annotations + +import base64 +import dataclasses +import datetime +import json +import os +import re +import secrets +import shutil +import signal +import subprocess +import sys +import time +import urllib.request +from pathlib import Path +from typing import Optional + +# ── Constants ────────────────────────────────────────────────────────────────── +ELECTOR_ADDR = "-1:3333333333333333333333333333333333333333333333333333333333333333" +TOTAL_PHASES = 11 +WALLET_VERSIONS = ["V1R3", "V3R2", "V4R2", "V5R1", "V3R2", "V3R2"] + + +# ══════════════════════════════════════════════════════════════════════════════ +# Configuration & paths +# ══════════════════════════════════════════════════════════════════════════════ + +@dataclasses.dataclass +class Config: + http_api_url: str = "http://127.0.0.1:3301" + node_cnt: int = 6 + master_topup: str = "1000" + wallet_topup: str = "100" + pool_topup: str = "100000" + participants_wait: int = 600 + nobuild: bool = False + keep_on_success: bool = True + wallet_versions: list = dataclasses.field(default_factory=lambda: list(WALLET_VERSIONS)) + + @classmethod + def from_env(cls) -> Config: + return cls( + http_api_url = os.environ.get("HTTP_API_URL", "http://127.0.0.1:3301"), + node_cnt = int(os.environ.get("NODE_CNT", "6")), + master_topup = os.environ.get("MASTER_TOPUP_TON", "1000"), + wallet_topup = os.environ.get("WALLET_TOPUP_TON", "100"), + pool_topup = os.environ.get("POOL_TOPUP_TON", "100000"), + participants_wait = int(os.environ.get("PARTICIPANTS_WAIT_SECONDS", "600")), + nobuild = os.environ.get("NOBUILD", "0") in ("1", "true"), + keep_on_success = os.environ.get("KEEP_NODECTL_ON_SUCCESS", "1") not in ("0", "false"), + ) + + +@dataclasses.dataclass +class Paths: + repo_root: Path + run_net_dir: Path + load_net_dir: Path + tmp_dir: Path + nodectl_src_bin: Path # built binary at target/release/nodectl + nodectl_bin: Path # working copy placed in tmp/ during phase 1 + nodectl_config: Path + vault_file: Path + nodectl_log: Path + script_log: Path + + @classmethod + def from_script_dir(cls, script_dir: Path) -> Paths: + repo_root = script_dir.parents[2] # …/test_run_net_py → src/ + tmp_dir = script_dir / "tmp" + return cls( + repo_root = repo_root, + run_net_dir = script_dir, + load_net_dir = repo_root / "node" / "tests" / "test_load_net", + tmp_dir = tmp_dir, + nodectl_src_bin = repo_root / "target" / "release" / "nodectl", + nodectl_bin = tmp_dir / "nodectl", + nodectl_config = tmp_dir / "nodectl-config.json", + vault_file = script_dir / "vault.json", + nodectl_log = tmp_dir / _log_name("NODECTL_LOG", "nodectl-service.log"), + script_log = script_dir / _log_name("SCRIPT_LOG", "singlehost-bootstrap.log"), + ) + + +def _log_name(env_key: str, default: str) -> str: + """Return a normalised *.log filename from an env var.""" + name = Path(os.environ.get(env_key, default)).name + return name if name.endswith(".log") else name + ".log" + + +# ══════════════════════════════════════════════════════════════════════════════ +# Logger +# ══════════════════════════════════════════════════════════════════════════════ + +class Logger: + """Writes coloured output to stdout and plain text to a log file.""" + + _ANSI = re.compile(r"\033\[[0-9;]*m") + + def __init__(self, log_path: Path) -> None: + log_path.parent.mkdir(parents=True, exist_ok=True) + self._file = open(log_path, "w", buffering=1) + + def _emit(self, msg: str) -> None: + print(msg, flush=True) + self._file.write(self._ANSI.sub("", msg) + "\n") + self._file.flush() + + def info(self, msg: str) -> None: self._emit(f"\033[32m[INFO]\033[0m {msg}") + def warn(self, msg: str) -> None: self._emit(f"\033[33m[WARN]\033[0m {msg}") + def error(self, msg: str) -> None: self._emit(f"\033[31m[ERROR]\033[0m {msg}") + + def close(self) -> None: + self._file.close() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Bootstrap +# ══════════════════════════════════════════════════════════════════════════════ + +class BootstrapError(Exception): + """Raised by phases on fatal errors; caught and logged in main().""" + + +class Bootstrap: + def __init__(self, cfg: Config, paths: Paths, log: Logger) -> None: + self.cfg = cfg + self.paths = paths + self.log = log + # Runtime state — populated during phase 7 (start service) + self._proc: Optional[subprocess.Popen] = None + self._nodectl_log: Optional[Path] = None + self._service_log_fh: Optional[object] = None + + # ── Orchestration ───────────────────────────────────────────────────────── + + def run(self) -> None: + pub_key = self.phase2_generate_config() + self.phase3_start_network(pub_key) + self.phase4_wait_progress() + master_addr = self.phase5_complete_config() + self._ensure_bun_deps() + self.phase6_topup_master(master_addr) + self.phase7_start_service() + wallet_addrs, pool_addrs = self.phase8_wait_and_topup() + last_count = self.phase9_wait_participants() + if last_count > 0: + self.phase10_validate_api() + else: + self.log.warn("Skipping API validation — no participants found") + self.phase11_summary(master_addr, wallet_addrs, pool_addrs, last_count) + + def shutdown(self, *, force: bool = False) -> None: + """Terminate the nodectl service and network nodes if needed. + force=True ignores keep_on_success.""" + if self._proc and self._proc.poll() is None: + if force or not self.cfg.keep_on_success: + self.log.info(f"Stopping nodectl (pid {self._proc.pid})") + self._proc.terminate() + try: + self._proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait() + if self._service_log_fh: + self._service_log_fh.close() + self._service_log_fh = None + if force: + self._stop_network() + + def _stop_network(self) -> None: + """Stop singlehost network nodes via test_run_net.py --stop.""" + try: + py = self.paths.run_net_dir / ".venv" / "bin" / "python3" + if not py.exists(): + py = Path(sys.executable) + subprocess.run( + [str(py), "test_run_net.py", "--stop"], + cwd=self.paths.run_net_dir, check=False, timeout=15, + stdin=subprocess.DEVNULL, + ) + except Exception: + pass + + # ── Internal helpers ────────────────────────────────────────────────────── + + def _phase(self, n: int, title: str) -> None: + self.log.info(f"[{n}/{TOTAL_PHASES}] {title}") + + def _fail(self, msg: str) -> None: + """Log an error and raise BootstrapError.""" + self.log.error(msg) + raise BootstrapError(msg) + + def _nctl(self, *args: str, timeout: int = 30) -> None: + """Run nodectl and let its output stream to the terminal.""" + result = subprocess.run( + [str(self.paths.nodectl_bin), *args], + capture_output=True, text=True, + stdin=subprocess.DEVNULL, timeout=timeout, + ) + if result.stdout: + print(result.stdout, end="") + if result.returncode != 0: + self._fail( + f"nodectl {' '.join(args)} failed (exit {result.returncode})" + + (f": {result.stderr.strip()}" if result.stderr.strip() else "") + ) + + def _nctl_output(self, *args: str, check: bool = True, timeout: int = 30) -> str: + """Run nodectl and return captured stdout.""" + result = subprocess.run( + [str(self.paths.nodectl_bin), *args], + capture_output=True, text=True, check=check, + stdin=subprocess.DEVNULL, timeout=timeout, + ) + return result.stdout + + def _json_rpc(self, method: str, params: Optional[dict] = None) -> dict: + url = self.cfg.http_api_url.rstrip("/") + "/jsonRPC" + payload = json.dumps({"id": "1", "jsonrpc": "2.0", + "method": method, "params": params or {}}).encode() + req = urllib.request.Request(url, data=payload, + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read()) + + def _seqno(self) -> Optional[int]: + try: + return int(self._json_rpc("getMasterchainInfo")["result"]["last"]["seqno"]) + except Exception: + return None + + def _participant_count(self) -> int: + try: + r = self._json_rpc("runGetMethod", { + "address": ELECTOR_ADDR, + "method": "participant_list_extended", + "stack": [], + }) + return len(r["result"]["stack"][4][1].get("elements", [])) + except Exception: + return 0 + + def _wait_log(self, pattern: str, timeout: int) -> bool: + """Poll nodectl log for pattern. Returns False on timeout or service death.""" + for _ in range(timeout): + if self._proc and self._proc.poll() is not None: + self.log.error(f"nodectl died while waiting for: {pattern!r}") + return False + try: + if pattern in self._nodectl_log.read_text(): # type: ignore[union-attr] + return True + except Exception: + pass + time.sleep(1) + return False + + def _log_tail(self, n: int = 40) -> str: + try: + return "\n".join(self._nodectl_log.read_text().splitlines()[-n:]) # type: ignore[union-attr] + except Exception: + return "" + + def _bun_topup(self, address: str, amount: str) -> None: + subprocess.run(["bun", "run", "topup", address, amount], + cwd=self.paths.load_net_dir, check=True, + stdin=subprocess.DEVNULL, timeout=30) + + def _node_console(self, i: int) -> dict: + path = self.paths.tmp_dir / f"node_{i}" / "console.json" + return json.loads(path.read_text()) + + def _venv_python(self) -> str: + """Return path to venv's python3, creating the venv if needed.""" + venv_py = self.paths.run_net_dir / ".venv" / "bin" / "python3" + if not venv_py.exists(): + subprocess.run([sys.executable, "-m", "venv", + str(self.paths.run_net_dir / ".venv")], check=True, + stdin=subprocess.DEVNULL, timeout=30) + subprocess.run([str(venv_py), "-m", "pip", "install", "-q", "pyyaml"], check=True, + stdin=subprocess.DEVNULL, timeout=60) + return str(venv_py) + + # ── Phase 1: Build ──────────────────────────────────────────────────────── + + def phase1_build(self) -> None: + self._phase(1, "Building nodectl...") + if self.cfg.nobuild: + self.log.info(" NOBUILD set, skipping build") + if not self.paths.nodectl_src_bin.exists(): + self._fail(f"NOBUILD=1 but binary not found: {self.paths.nodectl_src_bin}") + else: + subprocess.run(["cargo", "build", "--release", "-p", "nodectl"], + cwd=self.paths.repo_root, check=True, + stdin=subprocess.DEVNULL) + # Copy to tmp/ so all invocations run from a self-contained working directory + shutil.copy2(self.paths.nodectl_src_bin, self.paths.nodectl_bin) + self.log.info(f" Copied binary → {self.paths.nodectl_bin}") + ver = subprocess.run([str(self.paths.nodectl_bin), "--version"], + capture_output=True, text=True, + stdin=subprocess.DEVNULL, timeout=10) + + self.log.info(f" {(ver.stdout or ver.stderr).strip() or 'version unknown'}") + # ── Phase 2: Generate config ────────────────────────────────────────────── + + def phase2_generate_config(self) -> str: + """Generate nodectl config and create shared control-client key. + Returns the base64 public key of that key.""" + self._phase(2, "Pre-generating nodectl config and shared control-client key...") + + self.paths.nodectl_config.unlink(missing_ok=True) + self.paths.vault_file.unlink(missing_ok=True) + + self.log.info(" config generate...") + self._nctl("config", "generate", "--output", str(self.paths.nodectl_config), "--force") + self.log.info(" config ton-http-api set...") + self._nctl("config", "ton-http-api", "set", "--url", self.cfg.http_api_url) + + # Patch global tick_interval — no CLI command exists for this field + cfg_json = json.loads(self.paths.nodectl_config.read_text()) + cfg_json["tick_interval"] = 20 + self.paths.nodectl_config.write_text(json.dumps(cfg_json, indent=2)) + self.log.info(" global tick_interval → 20") + + # Create the key used by nodes 3+ (nodes 1-2 get per-node keys in phase 5) + self.log.info(" key add control-client-secret...") + self._nctl("key", "add", "-n", "control-client-secret", "-e") + + # Extract its public key from the `key ls` tabular output + for line in self._nctl_output("key", "ls").splitlines(): + parts = line.split() + if parts and parts[0] == "control-client-secret": + self.log.info(f" shared control-client pub key: {parts[-1]}") + return parts[-1] + + self._fail("Failed to extract pub key for control-client-secret") + + # ── Phase 3: Start network ──────────────────────────────────────────────── + + def _ensure_test_run_net_config(self) -> None: + """Generate test_run_net.json with correct node counts if it doesn't exist.""" + cfg_path = self.paths.run_net_dir / "test_run_net.json" + if cfg_path.exists(): + cfg = json.loads(cfg_path.read_text()) + if cfg.get("rust_nodes_count") == self.cfg.node_cnt and cfg.get("cpp_nodes_count") == 0: + return + self.log.info(f" Updating test_run_net.json: rust={self.cfg.node_cnt}, cpp=0") + else: + # Run test_run_net.py once to generate defaults, then patch + py = self._venv_python() + subprocess.run([py, "test_run_net.py"], cwd=self.paths.run_net_dir, + check=False, stdin=subprocess.DEVNULL, timeout=30) + self.log.info(f" Generated test_run_net.json: rust={self.cfg.node_cnt}, cpp=0") + + cfg = json.loads(cfg_path.read_text()) + cfg["rust_nodes_count"] = self.cfg.node_cnt + cfg["cpp_nodes_count"] = 0 + cfg_path.write_text(json.dumps(cfg, indent=2)) + + def phase3_start_network(self, pub_key_shared: str) -> None: + """Start the singlehost network with the shared key pre-injected into every + node's control_server.clients.list so no second restart is needed.""" + self._phase(3, "Starting singlehost network (--elections)...") + py = self._venv_python() + rnd = self.paths.run_net_dir + + self._ensure_test_run_net_config() + + subprocess.run([py, "test_run_net.py", "--stop"], cwd=rnd, check=False, + stdin=subprocess.DEVNULL, timeout=30) + + net_args = ["--elections", "--control-client-public-key", pub_key_shared] + if self.cfg.nobuild: + net_args.append("--nobuild") + subprocess.run([py, "test_run_net.py"] + net_args, cwd=rnd, check=True, + stdin=subprocess.DEVNULL, + env={**os.environ, "PYTHONUNBUFFERED": "1"}) + time.sleep(5) + + # ── Phase 4: Wait for progress ──────────────────────────────────────────── + + def phase4_wait_progress(self) -> None: + self._phase(4, "Waiting for blockchain progress...") + seq_a = None + for _ in range(60): + seq_a = self._seqno() + if seq_a is not None: + break + time.sleep(2) + if seq_a is None: + self._fail(f"Failed to read masterchain seqno from {self.cfg.http_api_url}") + + time.sleep(8) + seq_b = self._seqno() + if seq_b is None or seq_b <= seq_a: + self._fail(f"Masterchain seqno not growing ({seq_a} → {seq_b})") + self.log.info(f" seqno: {seq_a} → {seq_b}") + + # ── Phase 5: Complete config ────────────────────────────────────────────── + + def phase5_complete_config(self) -> str: + """Complete nodectl config via CLI. Returns the master wallet address.""" + self._phase(5, "Completing nodectl config via CLI...") + + self._add_keys() + self._add_wallets() + self._wait_http_api() + master_addr = self._resolve_master_wallet() + self._add_nodes() + self._configure_elections(master_addr) + + return master_addr + + def _add_keys(self) -> None: + self.log.info(" Creating remaining keys...") + self._nctl("key", "add", "-n", "master-wallet-secret") + for i in range(1, self.cfg.node_cnt + 1): + self._nctl("key", "add", "-n", f"wallet{i}-secret") + # Nodes 1-2 use their own per-node keys (imported from console.json) + for i in range(1, min(3, self.cfg.node_cnt + 1)): + pvt = self._node_console(i)["config"]["client_key"]["pvt_key"] + self._nctl("key", "import", "-n", f"control-client-secret-{i}", "-e", "-k", pvt) + self._nctl("key", "ls") + + def _add_wallets(self) -> None: + self.log.info(" Adding wallets (different versions to exercise all wallet types)...") + for i in range(1, self.cfg.node_cnt + 1): + version = self.cfg.wallet_versions[i - 1] if i - 1 < len(self.cfg.wallet_versions) else "V3R2" + self._nctl("config", "wallet", "add", + "-n", f"wallet{i}", "-s", f"wallet{i}-secret", "-v", version) + self.log.info(f" wallet{i} → {version}") + + def _wait_http_api(self, timeout: int = 120) -> None: + self.log.info(f" Waiting for HTTP API ({self.cfg.http_api_url}, timeout {timeout}s)...") + deadline = time.time() + timeout + while time.time() < deadline: + try: + urllib.request.urlopen(self.cfg.http_api_url, timeout=2) + self.log.info(" HTTP API available") + return + except Exception: + time.sleep(2) + self._fail(f"HTTP API not available after {timeout}s") + + def _resolve_master_wallet(self) -> str: + self.log.info(" Resolving master wallet address...") + for _ in range(30): + out = self._nctl_output("config", "master-wallet", "info", "--format=json", check=False) + try: + addr = json.loads(out).get("address") or "" + if addr and addr not in ("unknown", "null"): + self.log.info(f" Master wallet: {addr}") + return addr + except Exception: + pass + time.sleep(3) + self._fail("Could not resolve master wallet address") + + def _add_nodes(self) -> None: + self.log.info(" Adding nodes...") + for i in range(1, self.cfg.node_cnt + 1): + console = self._node_console(i) + # Nodes 1-2 have their own per-node keys; nodes 3+ use the shared key + secret = f"control-client-secret-{i}" if i <= 2 else "control-client-secret" + self._nctl("config", "node", "add", + "-n", f"node{i}", + "-e", console["config"]["server_address"], + "-p", console["config"]["server_key"]["pub_key"], + "-s", secret) + self._nctl("config", "node", "ls") + + def _configure_elections(self, master_addr: str) -> None: + self.log.info(" Setting elections tick interval → 20") + self._nctl("config", "elections", "tick-interval", "20") + + self.log.info(" Adding pools...") + for i in range(1, self.cfg.node_cnt + 1): + self._nctl("config", "pool", "add", "-n", f"pool{i}", "-o", master_addr) + + self.log.info(" Adding bindings...") + for i in range(1, self.cfg.node_cnt + 1): + self._nctl("config", "bind", "add", + "-n", f"node{i}", "-w", f"wallet{i}", "-p", f"pool{i}") + + self.log.info(" Enabling elections...") + self._nctl("config", "elections", "enable", + *[f"node{i}" for i in range(1, self.cfg.node_cnt + 1)]) + self._nctl("config", "bind", "ls") + + # ── Phase 6: Top up master wallet ───────────────────────────────────────── + + def phase6_topup_master(self, master_addr: str) -> None: + self._phase(6, f"Topping up master wallet ({self.cfg.master_topup} TON)...") + self._bun_topup(master_addr, self.cfg.master_topup) + + # ── Phase 7: Start nodectl service ──────────────────────────────────────── + + def phase7_start_service(self) -> None: + self._phase(7, "Starting nodectl service...") + self._nodectl_log = self.paths.nodectl_log + self._service_log_fh = open(self._nodectl_log, "w") # truncates previous run + self._proc = subprocess.Popen( + [str(self.paths.nodectl_bin), "service", + "--config", str(self.paths.nodectl_config)], + stdout=self._service_log_fh, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + env={**os.environ, "RUST_LOG": "info"}, + ) + time.sleep(2) + if self._proc.poll() is not None: + self.log.error("nodectl service failed to start; last log lines:") + print(self._log_tail(120), file=sys.stderr) + raise BootstrapError("nodectl service failed to start") + self.log.info(f" nodectl service running (pid {self._proc.pid})") + self.log.info(f" log: {self._nodectl_log}") + + # ── Phase 8: Wait for wallets/pools, top them up ────────────────────────── + + def phase8_wait_and_topup(self) -> tuple: + """Returns (wallet_addrs, pool_addrs) after opening and topping up.""" + self._phase(8, "Waiting for master wallet to open (up to 90s)...") + if not self._wait_log("master wallet opened: address=", 90): + self.log.error("No 'master wallet opened' after 90s") + print(self._log_tail(120), file=sys.stderr) + raise BootstrapError("master wallet did not open") + + self.log.info(" Waiting for validator wallets to open (up to 180s)...") + if not self._wait_log("opened wallet: address=", 180): + self.log.warn("No 'opened wallet' in log yet; continuing") + + self.log.info(" Waiting for nominator pools to open (up to 300s)...") + if not self._wait_log("opened nominator pool: address=", 300): + self.log.warn("No 'opened nominator pool' in log yet; continuing") + + self.log.info(" Waiting for all contracts to be deployed (up to 300s)...") + if not self._wait_log("all contracts are ready", 300): + self._fail("Contracts not ready after 300s") + + log_text = self._nodectl_log.read_text() # type: ignore[union-attr] + wallet_addrs = sorted(set(re.findall(r"opened wallet: address=(\S+)", log_text))) + pool_addrs = sorted(set(re.findall(r"opened nominator pool: address=(\S+)", log_text))) + self.log.info(f" Wallets opened: {len(wallet_addrs)}, pools opened: {len(pool_addrs)}") + + for addr in pool_addrs: + self.log.info(f" Top up pool {addr} ({self.cfg.pool_topup} TON)") + self._bun_topup(addr, self.cfg.pool_topup) + time.sleep(5) # wait for the pool to be topped up + self._nctl("config", "pool", "ls") + + return wallet_addrs, pool_addrs + + # ── Phase 9: Wait for election participants ──────────────────────────────── + + def phase9_wait_participants(self) -> int: + expected = self.cfg.node_cnt + self._phase(9, f"Waiting for {expected} election participants (up to {self.cfg.participants_wait}s)...") + deadline = time.time() + self.cfg.participants_wait + while time.time() < deadline: + cnt = self._participant_count() + if cnt >= expected: + return cnt + self.log.info(f" participants: {cnt}/{expected}") + time.sleep(5) + cnt = self._participant_count() + if cnt < expected: + self._fail(f"Expected {expected} participants but got {cnt} after {self.cfg.participants_wait}s") + return cnt + + # ── Phase 10: Auth + REST API stake validation ────────────────────────── + + def phase10_validate_api(self) -> None: + self._phase(10, "Setting up auth and validating REST API stakes...") + + # Create API user and obtain JWT token + password = secrets.token_hex(16) + result = subprocess.run( + [str(self.paths.nodectl_bin), "auth", "add", + "--username", "admin", "--role", "operator", "--password-stdin"], + input=password, text=True, capture_output=True, timeout=15, + ) + if result.returncode != 0: + self._fail(f"auth add failed (exit {result.returncode}): {result.stderr.strip()}") + self.log.info(" Created auth user 'admin' (operator)") + + # Service reloads config from disk every 10s — wait for it to pick up the new user + time.sleep(12) + + result = subprocess.run( + [str(self.paths.nodectl_bin), "api", "login", "admin", "--password-stdin"], + input=password, capture_output=True, text=True, check=True, timeout=15, + ) + os.environ["NODECTL_API_TOKEN"] = json.loads(result.stdout)["token"] + self.log.info(" Logged in and exported NODECTL_API_TOKEN") + + elections = self._fetch_nodectl_elections() + if elections is None: + return + + elector_map = self._fetch_elector_stake_map() + if elector_map is None: + return + + self._compare_stakes(elections, elector_map) + + def _fetch_nodectl_elections(self) -> Optional[dict]: + result = subprocess.run( + [str(self.paths.nodectl_bin), "api", "elections", "--format=json"], + capture_output=True, text=True, + stdin=subprocess.DEVNULL, timeout=15, + ) + stderr = result.stderr.strip() + if result.returncode != 0: + self.log.warn(f" nodectl api elections failed (exit {result.returncode})" + + (f": {stderr}" if stderr else "")) + return None + try: + return json.loads(result.stdout) + except Exception as e: + self.log.warn(f" Could not parse elections response: {e}; skipping") + return None + + def _fetch_elector_stake_map(self) -> Optional[dict]: + """Returns {pubkey_bytes: stake_str} for every current elector participant.""" + try: + resp = self._json_rpc("runGetMethod", { + "address": ELECTOR_ADDR, + "method": "participant_list_extended", + "stack": [], + }) + except Exception as e: + self.log.warn(f" Could not fetch participant_list_extended: {e}; skipping") + return None + + result = {} + try: + # Each element is a StackEntryJson dict: {"@type": "tvm.stackEntry*", ...} + # Numbers: {"@type": "tvm.stackEntryNumber", "number": {"@type": "tvm.numberDecimal", "number": ""}} + # Tuples: {"@type": "tvm.stackEntryTuple", "tuple": {"@type": "tvm.tuple", "elements": [...]}} + for entry in resp["result"]["stack"][4][1].get("elements", []): + inner = entry["tuple"]["elements"] + pubkey_str = inner[0]["number"]["number"] + stake_str = inner[1]["tuple"]["elements"][0]["number"]["number"] + n = int(pubkey_str, 16) if pubkey_str.lower().startswith("0x") else int(pubkey_str) + result[n.to_bytes(32, "big")] = stake_str + except (KeyError, IndexError, TypeError, ValueError) as e: + self.log.warn(f" Could not parse elector participant list: {type(e).__name__}: {e}; skipping") + return None + + return result + + def _compare_stakes(self, elections: dict, elector_map: dict) -> None: + mismatches, accepted = 0, 0 + for p in elections.get("our_participants", []): + if not p.get("stake_accepted"): + continue + pubkey_b64 = p.get("pubkey") + accepted_stake = p.get("accepted_stake") + if not pubkey_b64 or not accepted_stake: + continue + accepted += 1 + key_bytes = base64.b64decode(pubkey_b64) + elector_stake = elector_map.get(bytes(key_bytes)) + node_id = p.get("node_id", "?") + if elector_stake is None: + self.log.warn(f" [MISMATCH] {node_id}: pubkey not found in elector list") + mismatches += 1 + elif elector_stake != accepted_stake: + self.log.warn( + f" [MISMATCH] {node_id}: " + f"nodectl={accepted_stake} != elector={elector_stake} nanotons" + ) + mismatches += 1 + else: + self.log.info(f" [OK] {node_id}: accepted_stake={accepted_stake} nanotons") + + self.log.info(f" Participants with accepted stake: {accepted}, mismatches: {mismatches}") + if accepted == 0: + self.log.warn(" No accepted stakes in nodectl API response; skipping comparison") + return + if mismatches: + self._fail("Stake mismatch between nodectl REST API and elector contract") + self.log.info(" REST API stake comparison: OK") + + # ── Phase 11: Summary ───────────────────────────────────────────────────── + + def phase11_summary( + self, master_addr: str, wallet_addrs: list, pool_addrs: list, last_count: int + ) -> None: + self._phase(11, "Summary") + rows = [ + ("nodectl pid", str(self._proc.pid) if self._proc else "N/A"), + ("nodectl log", str(self._nodectl_log)), + ("master wallet", master_addr), + ("opened wallets", str(len(wallet_addrs))), + ("opened pools", str(len(pool_addrs))), + ("participants", str(last_count)), + ] + for key, val in rows: + print(f" {key + ':':<18} {val}") + + if last_count == 0: + self._fail(f"No election participants found after {self.cfg.participants_wait}s") + self.log.info(f" elections: OK ({last_count} participant(s))") + + print() + self.log.info("Bootstrap complete. nodectl service running in background.") + if self._proc: + print(f"Stop command: kill {self._proc.pid}") + + # ── Misc helpers ────────────────────────────────────────────────────────── + + def _ensure_bun_deps(self) -> None: + if not (self.paths.load_net_dir / "node_modules").exists(): + subprocess.run(["bun", "install", "--silent"], cwd=self.paths.load_net_dir, check=True, + stdin=subprocess.DEVNULL, timeout=60) + +# ══════════════════════════════════════════════════════════════════════════════ +# Entry point +# ══════════════════════════════════════════════════════════════════════════════ + +def main() -> None: + try: + paths = Paths.from_script_dir(Path(__file__).resolve().parent) + cfg = Config.from_env() + paths.tmp_dir.mkdir(parents=True, exist_ok=True) + log = Logger(paths.script_log) + except Exception as e: + print(f"\033[31m[FATAL]\033[0m Failed during early init: {e}", file=sys.stderr) + sys.exit(1) + + ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + log.info(f"=== {ts} run_singlehost_nodectl.py started ===") + log.info(f"Script log: {paths.script_log}") + + bootstrap = Bootstrap(cfg, paths, log) + + # Signal handling — needs a bootstrap reference for clean shutdown + def on_signal(_sig: int, _frame: object) -> None: + bootstrap.shutdown(force=True) + log.close() + sys.exit(130) + + signal.signal(signal.SIGINT, on_signal) + signal.signal(signal.SIGTERM, on_signal) + + # Preflight checks + for cmd in ("cargo", "bun", "curl", "openssl"): + if not shutil.which(cmd): + log.error(f"Missing required command: {cmd}") + sys.exit(1) + + if not os.environ.get("MASTER_WALLET_KEY"): + log.error("MASTER_WALLET_KEY is not set.") + log.error(f"Set it in the environment or add it to {paths.load_net_dir / '.env'}") + sys.exit(1) + + os.environ["API_ENDPOINTS"] = cfg.http_api_url.rstrip("/") + "/" + os.environ["VAULT_URL"] = f"file://vault.json?master_key={secrets.token_hex(32)}" + log.info(f"VAULT_URL={os.environ['VAULT_URL']}") + + # All nodectl CLI invocations discover the config via this env var + os.environ["CONFIG_PATH"] = str(paths.nodectl_config) + + # Run all phases; BootstrapError is our structured failure signal + exit_code = 0 + try: + bootstrap.phase1_build() + bootstrap.run() + except BootstrapError: + exit_code = 1 # error already logged inside _fail() + except Exception: + import traceback + log.error(f"Unexpected error:\n{traceback.format_exc()}") + exit_code = 1 + finally: + bootstrap.shutdown(force=(exit_code != 0)) + log.close() + + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/src/node/tests/test_run_net_py/run_singlehost_nodectl.sh b/src/node/tests/test_run_net_py/run_singlehost_nodectl.sh deleted file mode 100755 index 895797a..0000000 --- a/src/node/tests/test_run_net_py/run_singlehost_nodectl.sh +++ /dev/null @@ -1,268 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# One-button bootstrap for local singlehost + nodectl service. -# Preconditions: -# - Place vault file at node/tests/test_run_net_py/vault.json (or override VAULT_FILE) -# - .env for node/tests/test_load_net must contain funded MASTER_WALLET_KEY - -# SCRIPT_LOG=bootstrap NODECTL_LOG=nodectl ./run_singlehost_nodectl.sh - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" -RUN_NET_DIR="$REPO_ROOT/node/tests/test_run_net_py" -LOAD_NET_DIR="$REPO_ROOT/node/tests/test_load_net" -TMP_DIR="$RUN_NET_DIR/tmp" -NODECTL_CONFIG="$TMP_DIR/nodectl-local.json" -NODECTL_LOG_RAW="${NODECTL_LOG:-nodectl-service.log}" -SCRIPT_LOG_RAW="${SCRIPT_LOG:-singlehost-bootstrap.log}" -VAULT_FILE="${VAULT_FILE:-$RUN_NET_DIR/vault.json}" - -MASTER_TOPUP_TON="${MASTER_TOPUP_TON:-300}" -WALLET_TOPUP_TON="${WALLET_TOPUP_TON:-2000}" -POOL_TOPUP_TON="${POOL_TOPUP_TON:-50000}" -PARTICIPANTS_WAIT_SECONDS="${PARTICIPANTS_WAIT_SECONDS:-600}" -NOBUILD="${NOBUILD:-0}" -KEEP_NODECTL_ON_SUCCESS="${KEEP_NODECTL_ON_SUCCESS:-1}" -NODECTL_PID="" -INTERRUPTED=0 - -NODECTL_LOG_NAME="$(basename "$NODECTL_LOG_RAW")" -SCRIPT_LOG_NAME="$(basename "$SCRIPT_LOG_RAW")" - -if [[ "$NODECTL_LOG_NAME" == *.log ]]; then - NODECTL_LOG="$TMP_DIR/$NODECTL_LOG_NAME" -else - NODECTL_LOG="$TMP_DIR/${NODECTL_LOG_NAME}.log" -fi - -if [[ "$SCRIPT_LOG_NAME" == *.log ]]; then - SCRIPT_LOG="$TMP_DIR/$SCRIPT_LOG_NAME" -else - SCRIPT_LOG="$TMP_DIR/${SCRIPT_LOG_NAME}.log" -fi - -mkdir -p "$TMP_DIR" -: > "$SCRIPT_LOG" -exec > >(tee -a "$SCRIPT_LOG") 2>&1 -echo "=== $(date -u +'%Y-%m-%d %H:%M:%S UTC') run_singlehost_nodectl.sh started ===" -echo "Script log: $SCRIPT_LOG" - -cleanup() { - local exit_code=$? - if [[ -n "${NODECTL_PID:-}" ]] && kill -0 "$NODECTL_PID" >/dev/null 2>&1; then - if [[ "$exit_code" -ne 0 || "$INTERRUPTED" -eq 1 || "$KEEP_NODECTL_ON_SUCCESS" != "1" ]]; then - echo "Cleaning up: stopping nodectl pid $NODECTL_PID" - if ! kill "$NODECTL_PID" >/dev/null 2>&1; then - echo "Warning: failed to stop nodectl pid $NODECTL_PID" >&2 - fi - wait "$NODECTL_PID" >/dev/null 2>&1 || true - fi - fi - if [[ "$exit_code" -ne 0 && -n "${NODECTL_PID:-}" ]]; then - echo "Hint: if nodectl is still running, stop it manually: kill $NODECTL_PID" >&2 - fi - return "$exit_code" -} - -on_signal() { - INTERRUPTED=1 - exit 130 -} - -trap cleanup EXIT -trap on_signal INT TERM - -require_cmd() { - command -v "$1" >/dev/null 2>&1 || { - echo "Missing required command: $1" >&2 - exit 1 - } -} - -require_cmd python3 -require_cmd cargo -require_cmd bun -require_cmd jq -require_cmd curl - -if [[ ! -f "$VAULT_FILE" ]]; then - echo "Vault file not found: $VAULT_FILE" >&2 - exit 1 -fi - -detect_master_key() { - if [[ -n "${VAULT_MASTER_KEY:-}" ]]; then - echo "$VAULT_MASTER_KEY" - return 0 - fi - if [[ -f "$RUN_NET_DIR/nodectl_blank.json" ]]; then - local blank_key - blank_key="$(jq -r '.vault.url // empty' "$RUN_NET_DIR/nodectl_blank.json" | sed -n 's/.*master_key=\([0-9a-fA-F]\+\).*/\1/p' | head -n1)" - if [[ -n "$blank_key" ]]; then - echo "$blank_key" - return 0 - fi - fi - return 1 -} - -if [[ -z "${VAULT_URL:-}" ]]; then - MASTER_KEY="$(detect_master_key || true)" - if [[ -z "${MASTER_KEY:-}" ]]; then - echo "Set VAULT_MASTER_KEY (or VAULT_URL) before run." >&2 - exit 1 - fi - export VAULT_URL="file://$VAULT_FILE&master_key=$MASTER_KEY" -fi - -echo "[1/8] Starting singlehost network (--elections)..." -cd "$RUN_NET_DIR" -RUN_NET_ARGS=(--elections) -if [[ "$NOBUILD" == "1" || "$NOBUILD" == "true" || "$NOBUILD" == "yes" ]]; then - RUN_NET_ARGS+=(--nobuild) -fi -PYTHONUNBUFFERED=1 python3 -u test_run_net.py "${RUN_NET_ARGS[@]}" - -if [[ ! -f "$NODECTL_CONFIG" ]]; then - echo "Generated config not found: $NODECTL_CONFIG" >&2 - exit 1 -fi - -echo "[2/8] Enabling all bindings in nodectl config..." -python3 - "$NODECTL_CONFIG" <<'PY' -import json, sys -p = sys.argv[1] -cfg = json.load(open(p)) -for b in cfg.get("bindings", {}).values(): - b["enable"] = True -json.dump(cfg, open(p, "w"), indent=2) -PY - -rpc_seqno() { - curl -sS -X POST 'http://127.0.0.1:3301/jsonRPC' \ - -H 'Content-Type: application/json' \ - -d '{"id":"1","jsonrpc":"2.0","method":"getMasterchainInfo","params":{}}' \ - | jq -r '.result.last.seqno // empty' -} - -echo "[3/8] Waiting for blockchain progress (seqno increments)..." -seq_a="" -for _ in $(seq 1 60); do - seq_a="$(rpc_seqno || true)" - [[ "$seq_a" =~ ^[0-9]+$ ]] && break - sleep 2 -done -if [[ ! "$seq_a" =~ ^[0-9]+$ ]]; then - echo "Failed to read masterchain seqno from 127.0.0.1:3301" >&2 - exit 1 -fi -sleep 8 -seq_b="$(rpc_seqno || true)" -if [[ ! "$seq_b" =~ ^[0-9]+$ ]] || (( seq_b <= seq_a )); then - echo "Masterchain seqno is not growing (seqno: $seq_a -> ${seq_b:-n/a})" >&2 - exit 1 -fi -echo " seqno: $seq_a -> $seq_b" - -echo "[4/8] Starting nodectl service in background..." -cd "$REPO_ROOT" -: > "$NODECTL_LOG" -cargo run -p nodectl -- --verbose=info service --config "$NODECTL_CONFIG" > "$NODECTL_LOG" 2>&1 & -NODECTL_PID=$! -sleep 2 -if ! kill -0 "$NODECTL_PID" >/dev/null 2>&1; then - echo "nodectl failed to start; log tail:" >&2 - tail -n 120 "$NODECTL_LOG" >&2 || true - exit 1 -fi - -wait_log_pattern() { - local pattern="$1" - local timeout="$2" - local waited=0 - while (( waited < timeout )); do - if grep -q "$pattern" "$NODECTL_LOG"; then - return 0 - fi - sleep 1 - waited=$((waited + 1)) - done - return 1 -} - -wait_log_pattern "master wallet opened: address=" 90 || { - echo "No 'master wallet opened' in nodectl log." >&2 - tail -n 120 "$NODECTL_LOG" >&2 || true - exit 1 -} - -MASTER_ADDR="$(grep -m1 -oE 'master wallet opened: address=[^ ]+' "$NODECTL_LOG" | sed 's/.*address=//')" -if [[ -z "$MASTER_ADDR" ]]; then - echo "Failed to detect master wallet address from nodectl log." >&2 - exit 1 -fi -echo " master wallet: $MASTER_ADDR" - -echo "[5/8] Installing bun deps (if needed)..." -cd "$LOAD_NET_DIR" -if [[ ! -d node_modules ]]; then - bun install -fi - -echo "[6/8] Top-up master/wallets/pools..." -bun run topup "$MASTER_ADDR" "$MASTER_TOPUP_TON" - -mapfile -t WALLET_ADDRS < <(grep -oE 'opened wallet: address=[^ ]+' "$NODECTL_LOG" | sed 's/.*address=//' | sort -u) -mapfile -t POOL_ADDRS < <(grep -oE 'opened nominator pool: address=[^ ]+' "$NODECTL_LOG" | sed 's/.*address=//' | sort -u) - -for a in "${WALLET_ADDRS[@]}"; do - bun run topup "$a" "$WALLET_TOPUP_TON" -done -for a in "${POOL_ADDRS[@]}"; do - bun run topup "$a" "$POOL_TOPUP_TON" -done - -participant_count() { - curl -sS -X POST 'http://127.0.0.1:3301/jsonRPC' \ - -H 'Content-Type: application/json' \ - -d '{"id":"1","jsonrpc":"2.0","method":"runGetMethod","params":{"address":"-1:3333333333333333333333333333333333333333333333333333333333333333","method":"participant_list_extended","stack":[]}}' \ - | jq -r '.result.stack[4][1].elements | length // 0' -} - -echo "[7/8] Waiting for elections participants..." -START_TS="$(date +%s)" -LAST_COUNT=0 -while true; do - if grep -q "stack parser error: stack entry is not a tuple" "$NODECTL_LOG"; then - echo "Tuple parser error detected in nodectl log." >&2 - exit 1 - fi - cnt="$(participant_count || echo 0)" - [[ "$cnt" =~ ^[0-9]+$ ]] || cnt=0 - LAST_COUNT="$cnt" - if (( cnt > 0 )); then - break - fi - now="$(date +%s)" - if (( now - START_TS > PARTICIPANTS_WAIT_SECONDS )); then - break - fi - sleep 5 -done - -echo "[8/8] Summary" -echo " nodectl pid: $NODECTL_PID" -echo " nodectl log: $NODECTL_LOG" -echo " master wallet: $MASTER_ADDR" -echo " opened wallets: ${#WALLET_ADDRS[@]}" -echo " opened pools: ${#POOL_ADDRS[@]}" -echo " participant_list_extended count: $LAST_COUNT" -if grep -q "stack parser error: stack entry is not a tuple" "$NODECTL_LOG"; then - echo " parser status: FAILED" - exit 1 -fi -echo " parser status: OK (no tuple parser errors found)" -echo -echo "Service is running in background." -echo "Stop command: kill $NODECTL_PID" diff --git a/src/node/tests/test_run_net_py/test_nodectl_ci.sh b/src/node/tests/test_run_net_py/test_nodectl_ci.sh new file mode 100644 index 0000000..39336dc --- /dev/null +++ b/src/node/tests/test_run_net_py/test_nodectl_ci.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# CI wrapper for the nodectl e2e test. +# Runs run_singlehost_nodectl.py with CI-appropriate settings. +set -euo pipefail +cd "$(dirname "$0")" + +export PARTICIPANTS_WAIT_SECONDS=900 # CI runners are slower +export NOBUILD=0 # always rebuild in CI +export KEEP_NODECTL_ON_SUCCESS=0 # stop everything after test + +exec python3 run_singlehost_nodectl.py diff --git a/src/node/tests/test_run_net_py/test_run_net.py b/src/node/tests/test_run_net_py/test_run_net.py index 8304114..88c78ad 100644 --- a/src/node/tests/test_run_net_py/test_run_net.py +++ b/src/node/tests/test_run_net_py/test_run_net.py @@ -793,6 +793,25 @@ def build_global_config(zerostate_info: str): print(" done") +def add_control_client_key_to_nodes(pub_key_b64: str): + """Add a shared control client public key to every node's control_server.clients.list.""" + global run_fullnode, nodes_count + print("Adding shared control client public key to all nodes...", end="") + for n in range(0 if run_fullnode else 1, nodes_count + 1): + node_cfg_path = build_node_work_path(n) / "config.json" + if not node_cfg_path.exists(): + print(f"\n Warning: config.json not found for node {n}, skipping", end="") + continue + with open(node_cfg_path) as f: + cfg = json.load(f) + clients_list = cfg.get("control_server", {}).get("clients", {}).get("list", []) + clients_list.append({"type_id": 1209251014, "pub_key": pub_key_b64}) + cfg.setdefault("control_server", {}).setdefault("clients", {})["list"] = clients_list + with open(node_cfg_path, "w") as f: + json.dump(cfg, f, indent=2) + print(" done") + + def build_nodectl_config(root_path): global run_fullnode, nodes_count, common_config_path @@ -843,6 +862,13 @@ def main(): action="store_true", help="Enable simplex consensus for masterchain (implies --simplex)", ) + parser.add_argument( + "--control-client-public-key", + type=str, + default=None, + metavar="BASE64", + help="Base64 public key to add to every node's control_server.clients.list", + ) args = parser.parse_args() # --simplex-mc implies --simplex @@ -894,6 +920,9 @@ def main(): if n != 0: validator_pub_keys.append(vk) + if args.control_client_public_key: + add_control_client_key_to_nodes(args.control_client_public_key) + build_nodectl_config(test_root_path) # Load simplex config if simplex is enabled