175 lines
6.0 KiB
Python
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
|