feat: automated bond (#6860)
* initialise bonding automation * initialise autobond flow * docs for autobond * tweak docs and add scraped stats * resolve issues * fix issues * add extra command advice * fix rabbitai suggestions * fix rabbitai suggestions
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Automated Nym node bonding from CSV.
|
||||
|
||||
Usage:
|
||||
python3 auto_bond_all.py nodes.csv [options]
|
||||
|
||||
Options:
|
||||
--ansible-repo PATH Path to ansible playbooks directory (contains auto-bond.yml and inventory/)
|
||||
--cli-dir PATH Path to directory containing nym-cli binary
|
||||
--dry-run Print commands without executing
|
||||
|
||||
CSV format:
|
||||
inventory_node_id,hostname,ip,account,mnemonic,identity_key,amount,operator_cost
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
NYXD_URL = "https://rpc.nymtech.net"
|
||||
NYM_API_URL = "https://validator.nymtech.net/api"
|
||||
|
||||
# ── Colors ──
|
||||
G = "\033[0;32m" # green
|
||||
R = "\033[0;31m" # red
|
||||
Y = "\033[0;33m" # yellow
|
||||
C = "\033[0;36m" # cyan
|
||||
W = "\033[1;37m" # bold white
|
||||
D = "\033[2;37m" # dim
|
||||
NC = "\033[0m" # reset
|
||||
|
||||
|
||||
def ok(msg): print(f" {G}✓{NC} {msg}")
|
||||
def err(msg): print(f" {R}✗{NC} {msg}")
|
||||
def info(msg): print(f" {C}→{NC} {msg}")
|
||||
def dim(msg): print(f" {D}{msg}{NC}")
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Automated Nym node bonding from CSV")
|
||||
parser.add_argument("csv_file", help="Path to nodes CSV file")
|
||||
parser.add_argument(
|
||||
"--ansible-repo",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Path to ansible playbooks directory (contains auto-bond.yml and inventory/)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cli-dir",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Directory containing the nym-cli binary",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print commands without executing")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resolve_paths(args):
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
ansible_repo = args.ansible_repo.resolve() if args.ansible_repo else script_dir.parents[3]
|
||||
|
||||
nym_cli = (args.cli_dir.resolve() / "nym-cli") if args.cli_dir else (ansible_repo / "target" / "release" / "nym-cli")
|
||||
ansible_pb = ansible_repo / "auto-bond.yml"
|
||||
inventory = ansible_repo / "inventory" / "all"
|
||||
|
||||
errors = []
|
||||
if not nym_cli.exists(): errors.append(f"nym-cli not found at: {nym_cli}")
|
||||
if not ansible_pb.exists(): errors.append(f"auto-bond.yml not found at: {ansible_pb}")
|
||||
if not inventory.exists(): errors.append(f"inventory not found at: {inventory}")
|
||||
if errors and not args.dry_run:
|
||||
for e in errors:
|
||||
err(e)
|
||||
sys.exit(1)
|
||||
|
||||
return nym_cli, ansible_pb, inventory
|
||||
|
||||
|
||||
|
||||
SENSITIVE_FLAGS = {"--mnemonic", "--signature"}
|
||||
|
||||
def redact_cmd(cmd: list) -> list[str]:
|
||||
redacted = []
|
||||
hide_next = False
|
||||
for token in map(str, cmd):
|
||||
if hide_next:
|
||||
redacted.append("***REDACTED***")
|
||||
hide_next = False
|
||||
continue
|
||||
redacted.append(token)
|
||||
if token in SENSITIVE_FLAGS:
|
||||
hide_next = True
|
||||
return redacted
|
||||
|
||||
def run(cmd: list, capture=True) -> subprocess.CompletedProcess:
|
||||
print(f" $ {' '.join(redact_cmd(cmd))}")
|
||||
if dry_run:
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout='{"dry_run": true}', stderr="")
|
||||
result = subprocess.run(cmd, capture_output=capture, text=True, cwd=cwd)
|
||||
if result.returncode != 0:
|
||||
if result.stdout: print(result.stdout)
|
||||
if result.stderr: print(f"{R}{result.stderr}{NC}")
|
||||
result.check_returncode()
|
||||
return result
|
||||
|
||||
|
||||
def extract_ansible_recap(output: str):
|
||||
"""Extract PLAY RECAP block from ansible stdout."""
|
||||
match = re.search(r"(PLAY RECAP \*+.*?)(?=\n\n|\Z)", output, re.DOTALL)
|
||||
return match.group(0).strip() if match else None
|
||||
|
||||
|
||||
def generate_payload(row: dict, nym_cli: Path, dry_run: bool) -> str:
|
||||
result = run([
|
||||
nym_cli, "mixnet", "operators", "nymnode",
|
||||
"create-node-bonding-sign-payload",
|
||||
"--host", row["hostname"],
|
||||
"--identity-key", row["identity_key"],
|
||||
"--amount", row["amount"],
|
||||
"--mnemonic", row["mnemonic"],
|
||||
"--interval-operating-cost", row["operator_cost"],
|
||||
"--nyxd-url", NYXD_URL,
|
||||
"--nym-api-url", NYM_API_URL,
|
||||
"-o", "json",
|
||||
], dry_run)
|
||||
if dry_run:
|
||||
return "DRY_RUN_PAYLOAD"
|
||||
data = json.loads(result.stdout)
|
||||
return data.get("payload") or data.get("sign_payload") or list(data.values())[0]
|
||||
|
||||
|
||||
def ansible_sign(node_id: str, payload: str, ansible_pb: Path, inventory: Path, dry_run: bool):
|
||||
"""Returns (signature, recap_block)."""
|
||||
result = run([
|
||||
"ansible-playbook", ansible_pb,
|
||||
"-i", inventory,
|
||||
"--limit", node_id,
|
||||
"--tags", "bonding",
|
||||
"--extra-vars", f"contract_msg={payload}",
|
||||
], dry_run, cwd=ansible_pb.parent)
|
||||
|
||||
if dry_run:
|
||||
return "DRY_RUN_SIGNATURE", "DRY_RUN_RECAP"
|
||||
|
||||
recap = extract_ansible_recap(result.stdout)
|
||||
|
||||
match = re.search(r'ENCODED_SIGNATURE=([1-9A-HJ-NP-Za-km-z]+)', result.stdout)
|
||||
if not match:
|
||||
raise ValueError(f"Could not find ENCODED_SIGNATURE in ansible output:\n{result.stdout}")
|
||||
|
||||
return match.group(1), recap
|
||||
|
||||
|
||||
def bond_node(row: dict, signature: str, nym_cli: Path, dry_run: bool):
|
||||
cmd = [
|
||||
nym_cli, "mixnet", "operators", "nymnode", "bond",
|
||||
"--host", row["hostname"],
|
||||
"--identity-key", row["identity_key"],
|
||||
"--amount", row["amount"],
|
||||
"--mnemonic", row["mnemonic"],
|
||||
"--signature", signature,
|
||||
"--interval-operating-cost", row["operator_cost"],
|
||||
"--nyxd-url", NYXD_URL,
|
||||
"--nym-api-url", NYM_API_URL,
|
||||
"--force",
|
||||
]
|
||||
dim("$ " + " ".join(str(c) for c in cmd))
|
||||
if dry_run:
|
||||
return
|
||||
result = subprocess.run(cmd, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"bond command failed with exit code {result.returncode}")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
nym_cli, ansible_pb, inventory = resolve_paths(args)
|
||||
|
||||
print(f"\n {D}nym-cli : {nym_cli}{NC}")
|
||||
print(f" {D}playbook : {ansible_pb}{NC}")
|
||||
print(f" {D}inventory: {inventory}{NC}\n")
|
||||
|
||||
with open(args.csv_file) as f:
|
||||
nodes = list(csv.DictReader(f))
|
||||
|
||||
print(f"{W}{'═'*70}{NC}")
|
||||
dry_label = f" {Y}[DRY RUN]{NC}" if args.dry_run else ""
|
||||
print(f" {W}Bonding {len(nodes)} node(s){NC}{dry_label}")
|
||||
print(f"{W}{'═'*70}{NC}\n")
|
||||
|
||||
results = []
|
||||
failures = []
|
||||
|
||||
for i, row in enumerate(nodes, 1):
|
||||
hostname = row["hostname"]
|
||||
node_id = row["inventory_node_id"]
|
||||
|
||||
print(f"\n{W}[{i}/{len(nodes)}]{NC} {C}{hostname}{NC} {D}({node_id}){NC}")
|
||||
print(f" {D}{'─'*60}{NC}")
|
||||
|
||||
recap = None
|
||||
try:
|
||||
info("Generating bonding payload…")
|
||||
payload = generate_payload(row, nym_cli, args.dry_run)
|
||||
|
||||
info("Signing on remote node via Ansible…")
|
||||
signature, recap = ansible_sign(node_id, payload, ansible_pb, inventory, args.dry_run)
|
||||
|
||||
if recap:
|
||||
print(f"\n {D}{recap}{NC}\n")
|
||||
|
||||
info("Submitting bond transaction…")
|
||||
bond_node(row, signature, nym_cli, args.dry_run)
|
||||
|
||||
ok("Bonded successfully")
|
||||
results.append((hostname, True, recap, None))
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
err(f"FAILED: {error_msg}")
|
||||
results.append((hostname, False, recap, error_msg))
|
||||
failures.append((hostname, error_msg))
|
||||
print(f" {Y}↳ Continuing with next node…{NC}")
|
||||
|
||||
# ── Final summary ──
|
||||
print(f"\n{W}{'═'*70}{NC}")
|
||||
print(f" {W}SUMMARY{NC}")
|
||||
print(f"{W}{'═'*70}{NC}")
|
||||
|
||||
for hostname, success, recap, error_msg in results:
|
||||
status = f"{G}✓ OK {NC}" if success else f"{R}✗ FAILED{NC}"
|
||||
print(f" {status} {C}{hostname}{NC}")
|
||||
|
||||
print(f"{W}{'─'*70}{NC}")
|
||||
succeeded = len(results) - len(failures)
|
||||
print(f" Total: {G}{succeeded} succeeded{NC} {R}{len(failures)} failed{NC}")
|
||||
|
||||
if failures:
|
||||
print(f"\n {R}{W}FAILED NODES:{NC}")
|
||||
for hostname, reason in failures:
|
||||
print(f" {R}✗{NC} {C}{hostname}{NC}")
|
||||
print(f" {D}{reason}{NC}")
|
||||
|
||||
print(f"{W}{'═'*70}{NC}\n")
|
||||
|
||||
if failures:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,3 @@
|
||||
inventory_node_id,hostname,ip,account,mnemonic,identity_key,amount,operator_cost
|
||||
node1,nym-exit.node1.example.com,1.2.3.4,n1...,word1 word2 ...,5XjrYTR...,100000000,40000000
|
||||
node2,nym-exit.node2.example.com,5.6.7.8,n1...,word1 word2 ...,5ZWdDN9...,100000000,40000000
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check balances for all accounts in nodes.csv.
|
||||
|
||||
Usage:
|
||||
python3 show_balances.py nodes.csv [options]
|
||||
|
||||
Options:
|
||||
--cli-dir PATH Directory containing the nym-cli binary
|
||||
--dry-run Print commands without executing
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
NYXD_URL = "https://rpc.nymtech.net"
|
||||
|
||||
# ── Colors ──
|
||||
G = "\033[0;32m"
|
||||
R = "\033[0;31m"
|
||||
Y = "\033[0;33m"
|
||||
C = "\033[0;36m"
|
||||
W = "\033[1;37m"
|
||||
D = "\033[2;37m"
|
||||
NC = "\033[0m"
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Check balances for all accounts in CSV")
|
||||
parser.add_argument("csv_file", help="Path to nodes CSV file")
|
||||
parser.add_argument(
|
||||
"--cli-dir",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Directory containing the nym-cli binary",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print commands without executing")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resolve_nym_cli(args):
|
||||
if args.cli_dir:
|
||||
nym_cli = args.cli_dir.resolve() / "nym-cli"
|
||||
else:
|
||||
nym_cli = Path(__file__).resolve().parents[3] / "target" / "release" / "nym-cli"
|
||||
if not nym_cli.exists() and not args.dry_run:
|
||||
print(f" {R}✗{NC} nym-cli not found at: {nym_cli}")
|
||||
sys.exit(1)
|
||||
return nym_cli
|
||||
|
||||
|
||||
def get_balance(nym_cli: Path, account: str, dry_run: bool) -> str:
|
||||
if dry_run:
|
||||
return "DRY_RUN_BALANCE"
|
||||
result = subprocess.run(
|
||||
[nym_cli, "account", "balance", account, "--nyxd-url", NYXD_URL],
|
||||
capture_output=True, text=True, check=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
nym_cli = resolve_nym_cli(args)
|
||||
|
||||
print(f"\n {D}nym-cli: {nym_cli}{NC}\n")
|
||||
|
||||
with open(args.csv_file, newline="", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
required = {"hostname", "account"}
|
||||
missing = required - set(reader.fieldnames or [])
|
||||
if missing:
|
||||
print(f" {R}✗{NC} Missing required CSV columns: {', '.join(sorted(missing))}")
|
||||
sys.exit(1)
|
||||
nodes = list(reader)
|
||||
|
||||
print(f"{W}{'═'*60}{NC}")
|
||||
dry_label = f" {Y}[DRY RUN]{NC}" if args.dry_run else ""
|
||||
print(f" {W}Checking {len(nodes)} account(s){NC}{dry_label}")
|
||||
print(f"{W}{'═'*60}{NC}\n")
|
||||
|
||||
print(f" {W}{'HOSTNAME':<40} {'ACCOUNT':<45} BALANCE{NC}")
|
||||
print(f" {D}{'─'*110}{NC}")
|
||||
|
||||
errors = 0
|
||||
total_nym = 0.0
|
||||
|
||||
for row in nodes:
|
||||
hostname = row["hostname"]
|
||||
account = row["account"]
|
||||
try:
|
||||
balance = get_balance(nym_cli, account, args.dry_run)
|
||||
print(f" {C}{hostname:<40}{NC} {D}{account:<45}{NC} {G}{balance}{NC}")
|
||||
parts = balance.split()
|
||||
if parts:
|
||||
try:
|
||||
total_nym += float(parts[0])
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f" {C}{hostname:<40}{NC} {D}{account:<45}{NC} {R}✗ ERROR: {e}{NC}")
|
||||
errors += 1
|
||||
|
||||
print(f" {D}{'─'*110}{NC}")
|
||||
print(f" {W}Total balance: {G}{total_nym:,.6f} nym{NC}")
|
||||
print(f" {W}Accounts: {G}{len(nodes) - errors} OK{NC} {R}{errors} errors{NC}\n")
|
||||
if errors:
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unbond all nodes listed in nodes.csv.
|
||||
|
||||
Usage:
|
||||
python3 unbond_all.py nodes.csv [options]
|
||||
|
||||
Options:
|
||||
--cli-dir PATH Directory containing the nym-cli binary
|
||||
--dry-run Print commands without executing
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
NYXD_URL = "https://rpc.nymtech.net"
|
||||
|
||||
# ── Colors ──
|
||||
G = "\033[0;32m"
|
||||
R = "\033[0;31m"
|
||||
Y = "\033[0;33m"
|
||||
C = "\033[0;36m"
|
||||
W = "\033[1;37m"
|
||||
D = "\033[2;37m"
|
||||
NC = "\033[0m"
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Unbond all Nym nodes listed in CSV")
|
||||
parser.add_argument("csv_file", help="Path to nodes CSV file")
|
||||
parser.add_argument(
|
||||
"--cli-dir",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Directory containing the nym-cli binary",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print commands without executing")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resolve_nym_cli(args):
|
||||
if args.cli_dir:
|
||||
nym_cli = args.cli_dir.resolve() / "nym-cli"
|
||||
else:
|
||||
nym_cli = Path(__file__).resolve().parents[3] / "target" / "release" / "nym-cli"
|
||||
if not nym_cli.exists() and not args.dry_run:
|
||||
print(f" {R}✗{NC} nym-cli not found at: {nym_cli}")
|
||||
sys.exit(1)
|
||||
return nym_cli
|
||||
|
||||
|
||||
def run(cmd, dry_run: bool):
|
||||
redacted = [str(c) for c in cmd]
|
||||
if "--mnemonic" in redacted:
|
||||
i = redacted.index("--mnemonic")
|
||||
if i + 1 < len(redacted):
|
||||
redacted[i + 1] = "***REDACTED***"
|
||||
print(f" {D}$ {' '.join(redacted)}{NC}")
|
||||
if dry_run:
|
||||
return
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
nym_cli = resolve_nym_cli(args)
|
||||
|
||||
print(f"\n {D}nym-cli: {nym_cli}{NC}\n")
|
||||
|
||||
with open(args.csv_file, newline="", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
required = {"hostname", "mnemonic"}
|
||||
missing = required - set(reader.fieldnames or [])
|
||||
if missing:
|
||||
print(f" {R}✗{NC} Missing required CSV columns: {', '.join(sorted(missing))}")
|
||||
sys.exit(1)
|
||||
nodes = list(reader)
|
||||
|
||||
print(f"{W}{'═'*60}{NC}")
|
||||
dry_label = f" {Y}[DRY RUN]{NC}" if args.dry_run else ""
|
||||
print(f" {W}Unbonding {len(nodes)} node(s){NC}{dry_label}")
|
||||
print(f"{W}{'═'*60}{NC}\n")
|
||||
|
||||
results = []
|
||||
for i, row in enumerate(nodes, 1):
|
||||
|
||||
hostname = (row.get("hostname") or f"<row {i}>").strip()
|
||||
mnemonic = (row.get("mnemonic") or "").strip()
|
||||
print(f"\n{W}[{i}/{len(nodes)}]{NC} {C}{hostname}{NC}")
|
||||
if not mnemonic:
|
||||
print(f" {R}✗{NC} Missing mnemonic")
|
||||
results.append((hostname, False))
|
||||
continue
|
||||
try:
|
||||
run([
|
||||
nym_cli, "mixnet", "operators", "nymnode", "unbond",
|
||||
"--mnemonic", mnemonic,
|
||||
"--nyxd-url", NYXD_URL,
|
||||
], args.dry_run)
|
||||
print(f" {G}✓{NC} Unbonded successfully")
|
||||
results.append((hostname, True))
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" {R}✗{NC} Failed with exit code {e.returncode}")
|
||||
results.append((hostname, False))
|
||||
|
||||
print(f"\n{W}{'═'*60}{NC}")
|
||||
print(f" {W}SUMMARY{NC}")
|
||||
print(f"{W}{'═'*60}{NC}")
|
||||
for hostname, success in results:
|
||||
status = f"{G}✓ OK {NC}" if success else f"{R}✗ FAILED{NC}"
|
||||
print(f" {status} {C}{hostname}{NC}")
|
||||
succeeded = sum(1 for _, s in results if s)
|
||||
print(f"{W}{'─'*60}{NC}")
|
||||
print(f" Total: {G}{succeeded} succeeded{NC} {R}{len(results) - succeeded} failed{NC}")
|
||||
print(f"{W}{'═'*60}{NC}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user