Skip to content

Commit 6f1a438

Browse files
committed
Add operator endpoint discovery
1 parent 6710a0f commit 6f1a438

4 files changed

Lines changed: 437 additions & 6 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ uv run xian network join mainnet-node --network mainnet \
5454
--init-node --restore-snapshot
5555
uv run xian node init mainnet-node --restore-snapshot
5656
uv run xian node status mainnet-node
57+
uv run xian node endpoints mainnet-node
5758
uv run xian snapshot restore mainnet-node
5859
uv run xian doctor mainnet-node
5960
uv run xian node start mainnet-node
@@ -121,6 +122,11 @@ available, and an optional live RPC status probe. `doctor` checks workspace
121122
resolution and, when given a node name, the profile/manifest/home prerequisites
122123
for that node.
123124

125+
`node endpoints` prints the effective local URLs for CometBFT RPC,
126+
`abci_query`, Xian and CometBFT metrics, and optional dashboard / Prometheus /
127+
Grafana services. This is the quickest way to discover what a template-enabled
128+
node is expected to expose.
129+
124130
Node profiles now also carry `monitoring_enabled`. When that is true,
125131
`xian-cli` asks `xian-stack` to manage the Prometheus and Grafana sidecars
126132
alongside the node runtime.

src/xian_cli/cli.py

Lines changed: 259 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
DEFAULT_RPC_TIMEOUT_SECONDS,
3535
default_home_for_backend,
3636
fetch_json,
37+
get_xian_stack_node_endpoints,
3738
get_xian_stack_node_status,
3839
resolve_stack_dir,
3940
start_xian_stack_node,
@@ -1180,11 +1181,7 @@ def _handle_node_stop(args: argparse.Namespace) -> int:
11801181
return 0
11811182

11821183

1183-
def _collect_node_status(
1184-
args: argparse.Namespace,
1185-
*,
1186-
check_rpc: bool,
1187-
) -> dict:
1184+
def _resolve_node_context(args: argparse.Namespace) -> dict[str, object]:
11881185
base_dir = args.base_dir.resolve()
11891186
profile_path, profile, network_path, network = _load_profile_and_network(
11901187
base_dir=base_dir,
@@ -1211,6 +1208,180 @@ def _collect_node_status(
12111208
stack_dir=stack_dir,
12121209
explicit_home=getattr(args, "home", None),
12131210
)
1211+
return {
1212+
"base_dir": base_dir,
1213+
"profile_path": profile_path,
1214+
"profile": profile,
1215+
"network_path": network_path,
1216+
"network": network,
1217+
"runtime_backend": runtime_backend,
1218+
"stack_dir": stack_dir,
1219+
"home": home,
1220+
}
1221+
1222+
1223+
def _format_url_host(hostname: str) -> str:
1224+
if ":" in hostname and not hostname.startswith("["):
1225+
return f"[{hostname}]"
1226+
return hostname
1227+
1228+
1229+
def _replace_url_port(url: str, *, port: int, suffix: str = "") -> str:
1230+
parsed = urlparse(url)
1231+
scheme = parsed.scheme or "http"
1232+
hostname = _format_url_host(parsed.hostname or "127.0.0.1")
1233+
return f"{scheme}://{hostname}:{port}{suffix}"
1234+
1235+
1236+
def _rpc_base_url(rpc_status_url: str) -> str:
1237+
if rpc_status_url.endswith("/status"):
1238+
return rpc_status_url[: -len("/status")]
1239+
return rpc_status_url.rstrip("/")
1240+
1241+
1242+
def _fallback_node_endpoints(
1243+
*,
1244+
rpc_status_url: str,
1245+
profile: NodeProfile,
1246+
) -> dict[str, str]:
1247+
base_url = _rpc_base_url(rpc_status_url)
1248+
endpoints = {
1249+
"rpc": base_url,
1250+
"rpc_status": rpc_status_url,
1251+
"abci_query": f"{base_url}/abci_query",
1252+
"cometbft_metrics": _replace_url_port(
1253+
base_url,
1254+
port=26660,
1255+
suffix="/metrics",
1256+
),
1257+
"xian_metrics": _replace_url_port(
1258+
base_url,
1259+
port=9108,
1260+
suffix="/metrics",
1261+
),
1262+
}
1263+
if bool(profile.get("dashboard_enabled")):
1264+
dashboard_host = str(profile.get("dashboard_host", "127.0.0.1"))
1265+
dashboard_port = int(profile.get("dashboard_port", 8080))
1266+
dashboard_url = f"http://{dashboard_host}:{dashboard_port}"
1267+
endpoints["dashboard"] = dashboard_url
1268+
endpoints["dashboard_status"] = f"{dashboard_url}/api/status"
1269+
if bool(profile.get("monitoring_enabled")):
1270+
endpoints["prometheus"] = _replace_url_port(base_url, port=9090)
1271+
endpoints["grafana"] = _replace_url_port(base_url, port=3000)
1272+
return endpoints
1273+
1274+
1275+
def _collect_node_endpoints(args: argparse.Namespace) -> dict[str, object]:
1276+
context = _resolve_node_context(args)
1277+
profile = context["profile"]
1278+
runtime_backend = context["runtime_backend"]
1279+
stack_dir = context["stack_dir"]
1280+
rpc_status_url = getattr(args, "rpc_url", "http://127.0.0.1:26657/status")
1281+
1282+
payload: dict[str, object] = {
1283+
"profile_path": str(context["profile_path"]),
1284+
"network_path": str(context["network_path"]),
1285+
"runtime_backend": runtime_backend,
1286+
"service_node": bool(profile.get("service_node")),
1287+
"dashboard_enabled": bool(profile.get("dashboard_enabled")),
1288+
"monitoring_enabled": bool(profile.get("monitoring_enabled")),
1289+
}
1290+
if stack_dir is not None:
1291+
payload["stack_dir"] = str(stack_dir)
1292+
1293+
if runtime_backend == "xian-stack" and stack_dir is not None:
1294+
try:
1295+
backend_payload = get_xian_stack_node_endpoints(
1296+
stack_dir=stack_dir,
1297+
service_node=bool(profile.get("service_node")),
1298+
dashboard_enabled=bool(profile.get("dashboard_enabled")),
1299+
monitoring_enabled=bool(profile.get("monitoring_enabled")),
1300+
dashboard_host=str(profile.get("dashboard_host", "127.0.0.1")),
1301+
dashboard_port=int(profile.get("dashboard_port", 8080)),
1302+
)
1303+
payload["endpoints"] = backend_payload["endpoints"]
1304+
payload["backend_checked"] = True
1305+
return payload
1306+
except Exception as exc:
1307+
payload["backend_checked"] = True
1308+
payload["backend_error"] = str(exc)
1309+
1310+
payload["endpoints"] = _fallback_node_endpoints(
1311+
rpc_status_url=rpc_status_url,
1312+
profile=profile,
1313+
)
1314+
return payload
1315+
1316+
1317+
def _summarize_node_status(result: dict[str, object]) -> dict[str, object]:
1318+
rpc_payload = result.get("rpc_status")
1319+
rpc_result = (
1320+
rpc_payload.get("result", {}) if isinstance(rpc_payload, dict) else {}
1321+
)
1322+
sync_info = (
1323+
rpc_result.get("sync_info", {}) if isinstance(rpc_result, dict) else {}
1324+
)
1325+
node_info = (
1326+
rpc_result.get("node_info", {}) if isinstance(rpc_result, dict) else {}
1327+
)
1328+
other = node_info.get("other", {}) if isinstance(node_info, dict) else {}
1329+
1330+
state = "ready"
1331+
if not result.get("initialized"):
1332+
state = "not_initialized"
1333+
elif result.get("backend_checked") and not result.get("backend_running"):
1334+
state = "stopped"
1335+
elif result.get("rpc_checked") and not result.get("rpc_reachable"):
1336+
state = "rpc_unreachable"
1337+
1338+
summary: dict[str, object] = {
1339+
"state": state,
1340+
"initialized": bool(result.get("initialized")),
1341+
"service_node": bool(result.get("profile", {}).get("service_node")),
1342+
"dashboard_enabled": bool(
1343+
result.get("profile", {}).get("dashboard_enabled")
1344+
),
1345+
"monitoring_enabled": bool(
1346+
result.get("profile", {}).get("monitoring_enabled")
1347+
),
1348+
"backend_running": result.get("backend_running"),
1349+
"rpc_reachable": result.get("rpc_reachable"),
1350+
"rpc_height": sync_info.get("latest_block_height"),
1351+
"rpc_catching_up": sync_info.get("catching_up"),
1352+
"rpc_network": node_info.get("network"),
1353+
"peer_count": other.get("n_peers"),
1354+
}
1355+
1356+
backend_status = result.get("backend_status")
1357+
if isinstance(backend_status, dict):
1358+
if summary["dashboard_enabled"]:
1359+
summary["dashboard_reachable"] = backend_status.get(
1360+
"dashboard_reachable"
1361+
)
1362+
if summary["monitoring_enabled"]:
1363+
summary["prometheus_reachable"] = backend_status.get(
1364+
"prometheus_reachable"
1365+
)
1366+
summary["grafana_reachable"] = backend_status.get(
1367+
"grafana_reachable"
1368+
)
1369+
return summary
1370+
1371+
1372+
def _collect_node_status(
1373+
args: argparse.Namespace,
1374+
*,
1375+
check_rpc: bool,
1376+
) -> dict:
1377+
context = _resolve_node_context(args)
1378+
profile_path = context["profile_path"]
1379+
profile = context["profile"]
1380+
network_path = context["network_path"]
1381+
network = context["network"]
1382+
runtime_backend = context["runtime_backend"]
1383+
stack_dir = context["stack_dir"]
1384+
home = context["home"]
12141385
config_path = home / "config" / "config.toml"
12151386
genesis_path = home / "config" / "genesis.json"
12161387
node_key_path = home / "config" / "node_key.json"
@@ -1234,6 +1405,13 @@ def _collect_node_status(
12341405
network=network,
12351406
),
12361407
"rpc_checked": check_rpc,
1408+
"profile": {
1409+
"name": args.name,
1410+
"network": profile.get("network"),
1411+
"service_node": bool(profile.get("service_node")),
1412+
"dashboard_enabled": bool(profile.get("dashboard_enabled")),
1413+
"monitoring_enabled": bool(profile.get("monitoring_enabled")),
1414+
},
12371415
}
12381416
if stack_dir is not None:
12391417
result["stack_dir"] = str(stack_dir)
@@ -1271,6 +1449,17 @@ def _collect_node_status(
12711449
result["rpc_reachable"] = False
12721450
result["rpc_error"] = str(exc)
12731451

1452+
if isinstance(result.get("backend_status"), dict) and isinstance(
1453+
result["backend_status"].get("endpoints"), dict
1454+
):
1455+
result["endpoints"] = result["backend_status"]["endpoints"]
1456+
else:
1457+
result["endpoints"] = _fallback_node_endpoints(
1458+
rpc_status_url=args.rpc_url,
1459+
profile=profile,
1460+
)
1461+
1462+
result["summary"] = _summarize_node_status(result)
12741463
return result
12751464

12761465

@@ -1280,6 +1469,12 @@ def _handle_node_status(args: argparse.Namespace) -> int:
12801469
return 0
12811470

12821471

1472+
def _handle_node_endpoints(args: argparse.Namespace) -> int:
1473+
result = _collect_node_endpoints(args)
1474+
print(json.dumps(result, indent=2))
1475+
return 0
1476+
1477+
12831478
def _handle_snapshot_restore(args: argparse.Namespace) -> int:
12841479
base_dir = args.base_dir.resolve()
12851480
profile_path, profile, _, network = _load_profile_and_network(
@@ -2106,6 +2301,65 @@ def build_parser() -> argparse.ArgumentParser:
21062301
)
21072302
status_parser.set_defaults(handler=_handle_node_status)
21082303

2304+
endpoints_parser = node_subparsers.add_parser(
2305+
"endpoints",
2306+
help=(
2307+
"print the expected local URLs for RPC, metrics, dashboard, "
2308+
"and monitoring"
2309+
),
2310+
)
2311+
endpoints_parser.add_argument("name", help="node profile name")
2312+
endpoints_parser.add_argument(
2313+
"--base-dir",
2314+
type=Path,
2315+
default=Path.cwd(),
2316+
help=(
2317+
"base directory containing ./nodes, ./networks, ./keys, "
2318+
"and optionally sibling repos"
2319+
),
2320+
)
2321+
endpoints_parser.add_argument(
2322+
"--profile",
2323+
type=Path,
2324+
help="explicit node profile path; defaults to ./nodes/<name>.json",
2325+
)
2326+
endpoints_parser.add_argument(
2327+
"--network",
2328+
type=Path,
2329+
help=(
2330+
"explicit network manifest path; defaults to "
2331+
"./networks/<profile.network>/manifest.json"
2332+
),
2333+
)
2334+
endpoints_parser.add_argument(
2335+
"--stack-dir",
2336+
type=Path,
2337+
help=(
2338+
"explicit xian-stack checkout path when using the xian-stack "
2339+
"backend; "
2340+
"overrides stack_dir in the profile"
2341+
),
2342+
)
2343+
endpoints_parser.add_argument(
2344+
"--configs-dir",
2345+
type=Path,
2346+
help=(
2347+
"explicit xian-configs checkout path for canonical manifests "
2348+
"or the sibling workspace layout"
2349+
),
2350+
)
2351+
endpoints_parser.add_argument(
2352+
"--home",
2353+
type=Path,
2354+
help="explicit CometBFT home path; overrides the profile home",
2355+
)
2356+
endpoints_parser.add_argument(
2357+
"--rpc-url",
2358+
default="http://127.0.0.1:26657/status",
2359+
help="RPC status endpoint used to derive default host/port URLs",
2360+
)
2361+
endpoints_parser.set_defaults(handler=_handle_node_endpoints)
2362+
21092363
snapshot_parser = subparsers.add_parser("snapshot", help="snapshot tools")
21102364
snapshot_subparsers = snapshot_parser.add_subparsers(
21112365
dest="snapshot_command", required=True

src/xian_cli/runtime.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,23 @@ def get_xian_stack_node_status(
189189
dashboard_host=dashboard_host,
190190
dashboard_port=dashboard_port,
191191
)
192+
193+
194+
def get_xian_stack_node_endpoints(
195+
*,
196+
stack_dir: Path,
197+
service_node: bool,
198+
dashboard_enabled: bool = False,
199+
monitoring_enabled: bool = False,
200+
dashboard_host: str = "127.0.0.1",
201+
dashboard_port: int = 8080,
202+
) -> dict:
203+
return run_backend_command(
204+
stack_dir,
205+
"endpoints",
206+
service_node=service_node,
207+
dashboard_enabled=dashboard_enabled,
208+
monitoring_enabled=monitoring_enabled,
209+
dashboard_host=dashboard_host,
210+
dashboard_port=dashboard_port,
211+
)

0 commit comments

Comments
 (0)