radicle-reticulum/src/radicle_reticulum/seed.py

175 lines
6.0 KiB
Python

"""Radicle seed node manager.
Starts and manages a dedicated radicle-node configured as a seed server
(storage.policy = allow). The seed runs under its own RAD_HOME so it
doesn't interfere with the user's own radicle identity.
Users add the seed as a peer once:
rad node connect <seed-NID>@127.0.0.1:<seed-port>
The bridge then connects this seed to remote seeds over Reticulum.
"""
import json
import os
import socket
import subprocess
import time
from pathlib import Path
from typing import Optional
DEFAULT_SEED_HOME = Path.home() / ".radicle-rns" / "seed"
DEFAULT_SEED_PORT = 8779
SEED_CONFIG = {
"node": {
"alias": "radicle-rns-seed",
"listen": [], # filled in by write_config()
"connect": [],
"externalAddresses": [],
"seedingPolicy": {
"default": "allow",
"repos": {}
},
"db": {"journalMode": "wal"},
}
}
class SeedNode:
"""Manages a dedicated radicle-node process configured as a seed."""
def __init__(
self,
seed_home: Path = DEFAULT_SEED_HOME,
port: int = DEFAULT_SEED_PORT,
):
self.seed_home = Path(seed_home)
self.port = port
self._process: Optional[subprocess.Popen] = None
# ── Setup ────────────────────────────────────────────────────────────────
def is_initialized(self) -> bool:
"""Return True if rad auth has been run for this seed home."""
try:
result = subprocess.run(
["rad", "self"],
env=self._env(),
capture_output=True,
text=True,
timeout=5,
)
return result.returncode == 0 and "NID" in result.stdout
except Exception:
return False
def write_config(self):
"""Write config.json only if it does not already exist."""
self.seed_home.mkdir(parents=True, exist_ok=True)
config_path = self.seed_home / "config.json"
if config_path.exists():
return
config = json.loads(json.dumps(SEED_CONFIG)) # deep copy
config["node"]["listen"] = [f"127.0.0.1:{self.port}"]
config_path.write_text(json.dumps(config, indent=2))
# ── Lifecycle ─────────────────────────────────────────────────────────────
def start(self):
"""Start radicle-node for the seed. Raises if not initialized."""
if not self.is_initialized():
raise RuntimeError(
f"Seed identity not found. Initialize it with:\n"
f" RAD_HOME={self.seed_home} rad auth\n"
f"Then run 'radicle-rns seed' again."
)
self.write_config()
# Discard stdout/stderr — if kept as PIPE the buffer fills and the
# process deadlocks once radicle-node produces more than ~64 KB of logs.
self._process = subprocess.Popen(
["radicle-node"],
env=self._env(),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Wait up to 10s for the port to open.
deadline = time.time() + 10
while time.time() < deadline:
if self._process.poll() is not None:
raise RuntimeError(
f"Seed radicle-node exited immediately after launch. "
f"Check: RAD_HOME={self.seed_home} radicle-node"
)
if self._port_open():
return
time.sleep(0.3)
if self._process.poll() is not None:
raise RuntimeError("Seed radicle-node exited before port opened.")
# Still running but port not open — may be slow on first run; continue.
print(f"Warning: seed port {self.port} not yet open after 10s, continuing anyway.")
def stop(self):
"""Stop the seed radicle-node."""
if self._process and self._process.poll() is None:
self._process.terminate()
try:
self._process.wait(timeout=5)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait() # reap zombie
def is_running(self) -> bool:
return self._process is not None and self._process.poll() is None
# ── Queries ───────────────────────────────────────────────────────────────
def get_nid(self) -> Optional[str]:
"""Return the seed's radicle NID (z6Mk...)."""
result = subprocess.run(
["rad", "self"],
env=self._env(),
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return None
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[0] == "NID":
return parts[1]
return None
def connect_peer(self, nid: str, addr: str) -> bool:
"""Tell the seed to connect to a remote peer."""
result = subprocess.run(
["rad", "node", "connect", f"{nid}@{addr}"],
env=self._env(),
capture_output=True,
text=True,
timeout=30,
)
return result.returncode == 0
# ── Internal ──────────────────────────────────────────────────────────────
def _env(self) -> dict:
env = os.environ.copy()
env["RAD_HOME"] = str(self.seed_home)
return env
def _port_open(self) -> bool:
try:
with socket.create_connection(("127.0.0.1", self.port), timeout=0.5):
return True
except OSError:
return False