mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
feat(lux): graphical fleet dashboard at lux.cloud
Turn lux.cloud into a live graphical fleet dashboard for the managed luxd fleet (devnet/testnet/mainnet, 5 validators each on do-sfo3-lux-k8s). Backend (read-only collector, no mutations, no monolith dependency): - core/chains/lux_fleet.py: reads the operator LuxNetwork CR (lux.cloud/v1) for validators/version/plugin-set/phase, lists luxd-N pods + per-pod LB Services, and queries luxd JSON-RPC per node (info.getNodeVersion/getNodeID/ peers/isBootstrapped, C-Chain eth_blockNumber, P-Chain platform.getHeight), plus a native D-Chain DEX summary. Aggregates all 3 nets concurrently. - fleet_app.py: minimal standalone FastAPI exposing GET /v1/fleets/lux/overview (public, read-only). Reuses the bootnode api image; isolated from the (currently import-broken) monolith. - fleets/__init__.py: same overview endpoint also mounted on the main router. - Uses a READ-ONLY lux-k8s kubeconfig from KMS; only public chain data leaves. Frontend (Next.js 16, Lux white-label, auto by hostname): - lib/fleet.ts: typed client + health/status derivations. - components/lux/fleet-dashboard.tsx: graphical multi-net dashboard — totals, world topology map, per-network cards (phase, version, plugin-set, peers, C-Chain height, DEX state, per-chain C/P/X/D bootstrap), expandable per-node tables (node ID, version, peers, heights). Auto-refresh 15s. - app/fleet/page.tsx: public /fleet page (the lux.cloud fleet dashboard). - components/lux/fleet-strip.tsx + page.tsx: live fleet status strip on the landing hero. - navbar + dashboard nav: Fleet links; register existing Lux fleet mgmt page. - brand.ts: add skillsOrg (fixes pre-existing typecheck/build break). Deploy: infra/k8s/lux-fleet.yaml — lux-fleet Deployment/Service + ExternalSecret (lux-k8s RO kubeconfig from KMS) + ingress path /v1/fleets/lux/overview on api.cloud.lux.network. Verified: collector live against all 3 nets (real heights/peers/versions/DEX); api + collector tests pass (8/8); web typecheck + production build green; /fleet renders live data in browser (screenshot).
This commit is contained in:
@@ -26,6 +26,7 @@ from bootnode.core.chains.fleet_models import (
|
||||
FleetSummary,
|
||||
FleetUpdate,
|
||||
)
|
||||
from bootnode.core.chains.lux_fleet import LuxFleetOverview, get_overview as get_lux_overview
|
||||
from bootnode.db.models import OrgCluster, Project
|
||||
from bootnode.db.session import get_db
|
||||
|
||||
@@ -247,6 +248,24 @@ async def list_chains() -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/lux/overview", response_model=LuxFleetOverview)
|
||||
async def lux_fleet_overview(networks: Optional[str] = None) -> LuxFleetOverview:
|
||||
"""Read-only graphical fleet overview for the managed luxd fleet.
|
||||
|
||||
Public (no auth): exposes only public chain data — phase, validator counts,
|
||||
luxd version, plugin-set, per-node block heights / peer counts / node IDs,
|
||||
and a native-DEX summary — across devnet/testnet/mainnet. Powers the
|
||||
lux.cloud fleet dashboard. No mutations, no secrets.
|
||||
|
||||
``networks`` optionally narrows to a comma-separated subset
|
||||
(e.g. ``?networks=mainnet,testnet``).
|
||||
"""
|
||||
selected = None
|
||||
if networks:
|
||||
selected = [n.strip() for n in networks.split(",") if n.strip()]
|
||||
return await get_lux_overview(selected)
|
||||
|
||||
|
||||
@router.get("/networks")
|
||||
async def list_networks(chain: Optional[str] = None) -> dict:
|
||||
"""List network configurations, optionally filtered by chain."""
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
"""Read-only Lux fleet collector.
|
||||
|
||||
Builds a graphical-dashboard view of the live luxd fleet across
|
||||
devnet / testnet / mainnet, observing it READ-ONLY in one of two modes:
|
||||
|
||||
MODE A — direct RPC (default; touches nothing but public luxd RPC):
|
||||
Query each validator's public luxd JSON-RPC endpoint (per-pod LoadBalancer)
|
||||
for ``info.getNodeVersion``, ``info.getNodeID``, ``info.peers``,
|
||||
``info.isBootstrapped`` (C/P/X/D), ``eth_blockNumber`` (C-Chain) and
|
||||
``platform.getHeight`` (P-Chain), plus a native D-Chain DEX summary. The
|
||||
endpoint set is operational config (``LUX_FLEET_ENDPOINTS_FILE`` /
|
||||
``LUX_FLEET_ENDPOINTS_JSON``), with built-in defaults.
|
||||
|
||||
MODE B — kubeconfig enrichment (optional; only if a READ-ONLY lux-k8s
|
||||
kubeconfig is provided): additionally read the operator ``LuxNetwork`` CR
|
||||
(``lux.cloud/v1``) for declared validator count, image tag, plugin-set and
|
||||
phase, and the luxd pods/services for real pod phase + discovered IPs.
|
||||
|
||||
Both modes are strictly observational — no mutations, no writes. The data
|
||||
exposed (block heights, peer counts, node IDs, versions) is public chain data
|
||||
already reachable via the per-pod RPC endpoints, so the overview is safe to
|
||||
serve unauthenticated to the lux.cloud surface. No secrets cross into the
|
||||
response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The operator CRD that declares each managed network. This is the REAL group
|
||||
# (verified against the live cluster) — not the legacy ``lux.network/v1alpha1``.
|
||||
LUXNETWORK_GROUP = "lux.cloud"
|
||||
LUXNETWORK_VERSION = "v1"
|
||||
LUXNETWORK_PLURAL = "luxnetworks"
|
||||
LUXNETWORK_NAME = "luxd"
|
||||
|
||||
# luxd P2P chains we surface bootstrap state + heights for, in display order.
|
||||
TRACKED_CHAINS = ("C", "P", "X", "D")
|
||||
|
||||
# Per-network luxd HTTP RPC ports + namespaces (mirrors CHAIN_NETWORKS["lux"]).
|
||||
LUX_NETWORKS: dict[str, dict[str, Any]] = {
|
||||
"mainnet": {"namespace": "lux-mainnet", "http_port": 9630, "network_id": 1, "chain_id": 96369},
|
||||
"testnet": {"namespace": "lux-testnet", "http_port": 9640, "network_id": 2, "chain_id": 96368},
|
||||
"devnet": {"namespace": "lux-devnet", "http_port": 9650, "network_id": 3, "chain_id": 96370},
|
||||
}
|
||||
|
||||
# Optional read-only lux-k8s kubeconfig (MODE B enrichment). File path
|
||||
# (LUX_K8S_KUBECONFIG) or inline YAML (LUX_K8S_KUBECONFIG_DATA).
|
||||
ENV_KUBECONFIG_PATH = "LUX_K8S_KUBECONFIG"
|
||||
ENV_KUBECONFIG_DATA = "LUX_K8S_KUBECONFIG_DATA"
|
||||
|
||||
# Endpoint config (MODE A). JSON inline (LUX_FLEET_ENDPOINTS_JSON) or file
|
||||
# (LUX_FLEET_ENDPOINTS_FILE). Shape:
|
||||
# {"<network>": {"http_port": 9630, "nodes": [{"index": 0, "ip": "1.2.3.4"}, ...]}}
|
||||
ENV_ENDPOINTS_JSON = "LUX_FLEET_ENDPOINTS_JSON"
|
||||
ENV_ENDPOINTS_FILE = "LUX_FLEET_ENDPOINTS_FILE"
|
||||
|
||||
# Built-in default endpoints: the managed per-pod LoadBalancer RPC IPs.
|
||||
# Operational config (public infra IPs, not secrets); overridable without a
|
||||
# rebuild via the ConfigMap-mounted endpoint file.
|
||||
DEFAULT_ENDPOINTS: dict[str, dict[str, Any]] = {
|
||||
"mainnet": {"http_port": 9630, "nodes": [
|
||||
{"index": 0, "ip": "129.212.164.46"},
|
||||
{"index": 1, "ip": "137.184.246.133"},
|
||||
{"index": 2, "ip": "143.244.210.43"},
|
||||
{"index": 3, "ip": "143.244.210.71"},
|
||||
{"index": 4, "ip": "24.144.70.85"},
|
||||
]},
|
||||
"testnet": {"http_port": 9640, "nodes": [
|
||||
{"index": 0, "ip": "134.199.187.16"},
|
||||
{"index": 1, "ip": "209.38.174.84"},
|
||||
{"index": 2, "ip": "209.38.173.80"},
|
||||
{"index": 3, "ip": "134.199.187.62"},
|
||||
{"index": 4, "ip": "164.90.247.204"},
|
||||
]},
|
||||
"devnet": {"http_port": 9650, "nodes": [
|
||||
{"index": 0, "ip": "164.90.247.220"},
|
||||
{"index": 1, "ip": "146.190.14.112"},
|
||||
{"index": 2, "ip": "24.199.68.130"},
|
||||
{"index": 3, "ip": "134.199.184.181"},
|
||||
{"index": 4, "ip": "209.38.172.213"},
|
||||
]},
|
||||
}
|
||||
|
||||
_RPC_TIMEOUT = httpx.Timeout(connect=2.5, read=4.0, write=2.5, pool=2.5)
|
||||
|
||||
|
||||
def _load_endpoints() -> dict[str, dict[str, Any]]:
|
||||
"""Resolve the endpoint config (MODE A). Env file/JSON override defaults."""
|
||||
raw: Optional[str] = None
|
||||
path = os.environ.get(ENV_ENDPOINTS_FILE)
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
raw = open(path).read()
|
||||
except OSError as e:
|
||||
logger.warning("endpoint file read failed (%s): %s", path, e)
|
||||
if raw is None:
|
||||
raw = os.environ.get(ENV_ENDPOINTS_JSON)
|
||||
if raw:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, dict) and parsed:
|
||||
return parsed
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("endpoint JSON parse failed: %s", e)
|
||||
return DEFAULT_ENDPOINTS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ChainHeight(BaseModel):
|
||||
"""Per-chain bootstrap + height for one node."""
|
||||
|
||||
chain: str
|
||||
bootstrapped: Optional[bool] = None
|
||||
height: Optional[int] = None
|
||||
|
||||
|
||||
class LuxNode(BaseModel):
|
||||
"""Live status of a single luxd validator pod."""
|
||||
|
||||
pod_name: str
|
||||
pod_index: int
|
||||
phase: str = "Unknown" # K8s pod phase: Running/Pending/...
|
||||
ready: bool = False # K8s readiness
|
||||
external_ip: Optional[str] = None
|
||||
rpc_reachable: bool = False # did luxd RPC answer?
|
||||
node_id: Optional[str] = None
|
||||
version: Optional[str] = None # e.g. luxd/1.30.39
|
||||
git_commit: Optional[str] = None
|
||||
db_version: Optional[str] = None
|
||||
peers: Optional[int] = None
|
||||
healthy: Optional[bool] = None # luxd /ext/health (best-effort)
|
||||
chains: list[ChainHeight] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DexSummary(BaseModel):
|
||||
"""Native DEX (D-Chain) summary for a network."""
|
||||
|
||||
d_chain_bootstrapped: Optional[bool] = None
|
||||
venue: str = "native-dchain"
|
||||
markets: Optional[int] = None # from explorer graph, if available
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class LuxNetworkFleet(BaseModel):
|
||||
"""One network's fleet (devnet/testnet/mainnet)."""
|
||||
|
||||
network: str
|
||||
namespace: str
|
||||
network_id: Optional[int] = None
|
||||
chain_id: Optional[int] = None
|
||||
phase: str = "Unknown" # LuxNetwork CR status.phase
|
||||
image: Optional[str] = None # full repo:tag
|
||||
version: Optional[str] = None # luxd version (image tag)
|
||||
plugin_set: Optional[str] = None # spec.pluginSource.bucket
|
||||
vm_plugins: list[str] = Field(default_factory=list)
|
||||
validators: int = 0 # declared
|
||||
nodes_total: int = 0 # observed pods
|
||||
nodes_ready: int = 0 # ready pods
|
||||
nodes_rpc_ok: int = 0 # nodes answering RPC
|
||||
total_peers: int = 0
|
||||
dex: DexSummary = Field(default_factory=DexSummary)
|
||||
nodes: list[LuxNode] = Field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class LuxFleetOverview(BaseModel):
|
||||
"""All-network fleet overview — the single read the dashboard polls."""
|
||||
|
||||
cluster: str = "lux-k8s"
|
||||
networks: list[LuxNetworkFleet] = Field(default_factory=list)
|
||||
totals: dict[str, int] = Field(default_factory=dict)
|
||||
generated_at: float = 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kubernetes client (read-only, lux-k8s)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_lux_k8s_clients():
|
||||
"""Return (CustomObjectsApi, CoreV1Api) for lux-k8s, read-only.
|
||||
|
||||
Resolution order:
|
||||
1. ``LUX_K8S_KUBECONFIG`` — path to a mounted kubeconfig file.
|
||||
2. ``LUX_K8S_KUBECONFIG_DATA`` — inline kubeconfig YAML.
|
||||
3. in-cluster config (only correct if the api runs inside lux-k8s).
|
||||
"""
|
||||
from kubernetes import client, config
|
||||
|
||||
path = os.environ.get(ENV_KUBECONFIG_PATH)
|
||||
data = os.environ.get(ENV_KUBECONFIG_DATA)
|
||||
|
||||
if path and os.path.exists(path):
|
||||
config.load_kube_config(config_file=path)
|
||||
elif data:
|
||||
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".kubeconfig", delete=False)
|
||||
try:
|
||||
tmp.write(data)
|
||||
tmp.flush()
|
||||
tmp.close()
|
||||
config.load_kube_config(config_file=tmp.name)
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
else:
|
||||
# Last resort: in-cluster. Only valid if deployed inside lux-k8s.
|
||||
config.load_incluster_config()
|
||||
|
||||
return client.CustomObjectsApi(), client.CoreV1Api()
|
||||
|
||||
|
||||
def fleet_data_available() -> bool:
|
||||
"""True if a lux-k8s kubeconfig is configured (or we're in-cluster)."""
|
||||
if os.environ.get(ENV_KUBECONFIG_PATH) and os.path.exists(os.environ[ENV_KUBECONFIG_PATH]):
|
||||
return True
|
||||
if os.environ.get(ENV_KUBECONFIG_DATA):
|
||||
return True
|
||||
return os.path.exists("/var/run/secrets/kubernetes.io/serviceaccount/token")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# luxd RPC (read-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _rpc(http: httpx.AsyncClient, base: str, path: str, method: str,
|
||||
params: Any = None) -> Optional[dict]:
|
||||
"""POST a single JSON-RPC call to luxd; return ``result`` or None."""
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": method}
|
||||
if params is not None:
|
||||
body["params"] = params
|
||||
try:
|
||||
r = await http.post(f"{base}{path}", json=body)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
data = r.json()
|
||||
return data.get("result")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _probe_node_rpc(node: LuxNode, http_port: int) -> None:
|
||||
"""Enrich ``node`` in-place with live luxd RPC data. Best-effort."""
|
||||
if not node.external_ip:
|
||||
return
|
||||
base = f"http://{node.external_ip}:{http_port}"
|
||||
async with httpx.AsyncClient(timeout=_RPC_TIMEOUT) as http:
|
||||
# Version / nodeID / peers in parallel.
|
||||
ver, nid, peers, health = await asyncio.gather(
|
||||
_rpc(http, base, "/ext/info", "info.getNodeVersion"),
|
||||
_rpc(http, base, "/ext/info", "info.getNodeID"),
|
||||
_rpc(http, base, "/ext/info", "info.peers", {}),
|
||||
_node_health(http, base),
|
||||
)
|
||||
if ver:
|
||||
node.rpc_reachable = True
|
||||
node.version = ver.get("version")
|
||||
node.git_commit = ver.get("gitCommit")
|
||||
node.db_version = ver.get("databaseVersion")
|
||||
if nid:
|
||||
node.node_id = nid.get("nodeID")
|
||||
if isinstance(peers, dict) and isinstance(peers.get("peers"), list):
|
||||
node.peers = len(peers["peers"])
|
||||
node.healthy = health
|
||||
|
||||
# Per-chain bootstrap + heights.
|
||||
chains: list[ChainHeight] = []
|
||||
boot_results = await asyncio.gather(*[
|
||||
_rpc(http, base, "/ext/info", "info.isBootstrapped", {"chain": c})
|
||||
for c in TRACKED_CHAINS
|
||||
])
|
||||
c_height, p_height = await asyncio.gather(
|
||||
_rpc(http, base, "/ext/bc/C/rpc", "eth_blockNumber", []),
|
||||
_rpc(http, base, "/ext/bc/P", "platform.getHeight"),
|
||||
)
|
||||
for c, boot in zip(TRACKED_CHAINS, boot_results):
|
||||
ch = ChainHeight(chain=c)
|
||||
if isinstance(boot, dict):
|
||||
ch.bootstrapped = boot.get("isBootstrapped")
|
||||
if c == "C" and isinstance(c_height, str):
|
||||
try:
|
||||
ch.height = int(c_height, 16)
|
||||
except ValueError:
|
||||
pass
|
||||
if c == "P" and isinstance(p_height, dict):
|
||||
try:
|
||||
ch.height = int(p_height.get("height"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
chains.append(ch)
|
||||
node.chains = chains
|
||||
|
||||
|
||||
async def _node_health(http: httpx.AsyncClient, base: str) -> Optional[bool]:
|
||||
"""luxd /ext/health — returns healthy flag. (Known to encode-fail on some
|
||||
builds; treat a non-200 / parse error as unknown, not unhealthy.)"""
|
||||
try:
|
||||
r = await http.get(f"{base}/ext/health")
|
||||
if r.status_code not in (200, 503):
|
||||
return None
|
||||
return bool(r.json().get("healthy"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-network collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read_luxnetwork_cr(custom_api, namespace: str) -> dict:
|
||||
"""Read the LuxNetwork CR for a namespace. Returns {} if absent."""
|
||||
try:
|
||||
return custom_api.get_namespaced_custom_object(
|
||||
group=LUXNETWORK_GROUP, version=LUXNETWORK_VERSION,
|
||||
namespace=namespace, plural=LUXNETWORK_PLURAL, name=LUXNETWORK_NAME,
|
||||
) or {}
|
||||
except Exception as e:
|
||||
logger.warning("LuxNetwork CR read failed for %s: %s", namespace, e)
|
||||
return {}
|
||||
|
||||
|
||||
def _list_luxd_pods(core_api, namespace: str) -> dict[int, dict[str, Any]]:
|
||||
"""Map pod index -> {phase, ready} for luxd-N pods."""
|
||||
out: dict[int, dict[str, Any]] = {}
|
||||
try:
|
||||
pods = core_api.list_namespaced_pod(
|
||||
namespace=namespace, label_selector="app.kubernetes.io/name=luxd",
|
||||
).items
|
||||
if not pods:
|
||||
# Fallback: StatefulSet pods are named luxd-N regardless of labels.
|
||||
pods = [p for p in core_api.list_namespaced_pod(namespace=namespace).items
|
||||
if (p.metadata.name or "").startswith("luxd-")]
|
||||
except Exception as e:
|
||||
logger.warning("pod list failed for %s: %s", namespace, e)
|
||||
return out
|
||||
|
||||
for p in pods:
|
||||
name = p.metadata.name or ""
|
||||
if not name.startswith("luxd-"):
|
||||
continue
|
||||
try:
|
||||
idx = int(name.rsplit("-", 1)[1])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
ready = False
|
||||
conds = (p.status.conditions or []) if p.status else []
|
||||
for c in conds:
|
||||
if c.type == "Ready":
|
||||
ready = (c.status == "True")
|
||||
out[idx] = {"phase": (p.status.phase if p.status else "Unknown") or "Unknown",
|
||||
"ready": ready}
|
||||
return out
|
||||
|
||||
|
||||
def _list_luxd_external_ips(core_api, namespace: str) -> dict[int, str]:
|
||||
"""Map pod index -> external LB IP from per-pod luxd-N Services."""
|
||||
out: dict[int, str] = {}
|
||||
try:
|
||||
svcs = core_api.list_namespaced_service(namespace=namespace).items
|
||||
except Exception as e:
|
||||
logger.warning("service list failed for %s: %s", namespace, e)
|
||||
return out
|
||||
for s in svcs:
|
||||
name = s.metadata.name or ""
|
||||
if not name.startswith("luxd-"):
|
||||
continue
|
||||
try:
|
||||
idx = int(name.rsplit("-", 1)[1])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
ing = (s.status.load_balancer.ingress if s.status and s.status.load_balancer else None) or []
|
||||
for i in ing:
|
||||
if i.ip:
|
||||
out[idx] = i.ip
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _parse_cr(cr: dict) -> dict[str, Any]:
|
||||
"""Extract declared shape + phase from a LuxNetwork CR."""
|
||||
spec = cr.get("spec", {}) or {}
|
||||
status = cr.get("status", {}) or {}
|
||||
image = spec.get("image", {}) or {}
|
||||
repo = image.get("repository")
|
||||
tag = image.get("tag")
|
||||
full = f"{repo}:{tag}" if repo and tag else (tag or repo)
|
||||
plugin = spec.get("pluginSource", {}) or {}
|
||||
vm_plugins = [p.get("vmID") for p in (plugin.get("vmPlugins") or []) if p.get("vmID")]
|
||||
return {
|
||||
"phase": status.get("phase", "Unknown") or "Unknown",
|
||||
"validators": int(spec.get("validators") or 0),
|
||||
"image": full,
|
||||
"version": tag,
|
||||
"plugin_set": plugin.get("bucket"),
|
||||
"vm_plugins": vm_plugins,
|
||||
}
|
||||
|
||||
|
||||
async def _dex_summary(nodes: list[LuxNode], network: str) -> DexSummary:
|
||||
"""Build a DEX summary from D-Chain bootstrap state + explorer markets."""
|
||||
d_boot: Optional[bool] = None
|
||||
for n in nodes:
|
||||
for ch in n.chains:
|
||||
if ch.chain == "D" and ch.bootstrapped is not None:
|
||||
d_boot = d_boot or ch.bootstrapped
|
||||
summary = DexSummary(d_chain_bootstrapped=d_boot)
|
||||
# Best-effort markets count from the native explorer graph.
|
||||
host = {
|
||||
"mainnet": "explorer.lux.network",
|
||||
"testnet": "explorer.testnet.lux.network",
|
||||
"devnet": "explorer.devnet.lux.network",
|
||||
}.get(network)
|
||||
if host:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_RPC_TIMEOUT) as http:
|
||||
r = await http.post(
|
||||
f"https://{host}/v1/graph/lux/dex/graphql",
|
||||
json={"query": "{ markets { id } }"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
mkts = (r.json().get("data") or {}).get("markets")
|
||||
if isinstance(mkts, list):
|
||||
summary.markets = len(mkts)
|
||||
except Exception:
|
||||
pass
|
||||
if d_boot is False:
|
||||
summary.note = "D-Chain not yet bootstrapped"
|
||||
return summary
|
||||
|
||||
|
||||
async def _collect_network(
|
||||
network: str,
|
||||
endpoints: dict[str, Any],
|
||||
custom_api=None,
|
||||
core_api=None,
|
||||
) -> LuxNetworkFleet:
|
||||
"""Collect one network. MODE A (default) queries configured RPC endpoints;
|
||||
MODE B additionally reads the CR + pods/services when k8s clients are given."""
|
||||
cfg = LUX_NETWORKS[network]
|
||||
ns = cfg["namespace"]
|
||||
ep = endpoints.get(network, {})
|
||||
http_port = int(ep.get("http_port") or cfg["http_port"])
|
||||
fleet = LuxNetworkFleet(
|
||||
network=network, namespace=ns,
|
||||
network_id=cfg["network_id"], chain_id=cfg["chain_id"],
|
||||
)
|
||||
|
||||
# Endpoint config = the baseline node set (index -> external RPC IP).
|
||||
ip_by_idx: dict[int, str] = {}
|
||||
for node in ep.get("nodes", []) or []:
|
||||
try:
|
||||
ip_by_idx[int(node["index"])] = str(node["ip"])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
pods: dict[int, dict[str, Any]] = {}
|
||||
|
||||
# MODE B enrichment: CR (declared shape/phase) + observed pods/services.
|
||||
if custom_api is not None and core_api is not None:
|
||||
try:
|
||||
cr, pods, disc_ips = await asyncio.gather(
|
||||
asyncio.to_thread(_read_luxnetwork_cr, custom_api, ns),
|
||||
asyncio.to_thread(_list_luxd_pods, core_api, ns),
|
||||
asyncio.to_thread(_list_luxd_external_ips, core_api, ns),
|
||||
)
|
||||
parsed = _parse_cr(cr)
|
||||
fleet.phase = parsed["phase"]
|
||||
fleet.image = parsed["image"]
|
||||
fleet.version = parsed["version"]
|
||||
fleet.plugin_set = parsed["plugin_set"]
|
||||
fleet.vm_plugins = parsed["vm_plugins"]
|
||||
fleet.validators = parsed["validators"]
|
||||
# Discovered IPs take precedence (current truth), then config.
|
||||
ip_by_idx = {**ip_by_idx, **disc_ips}
|
||||
except Exception as e:
|
||||
logger.warning("lux-k8s enrichment failed for %s: %s", network, e)
|
||||
|
||||
# Build the node set: union of configured endpoints + observed pods.
|
||||
indices = set(ip_by_idx) | set(pods)
|
||||
if not indices and fleet.validators:
|
||||
indices = set(range(fleet.validators))
|
||||
nodes = [
|
||||
LuxNode(
|
||||
pod_name=f"luxd-{i}",
|
||||
pod_index=i,
|
||||
phase=pods.get(i, {}).get("phase", "Unknown"),
|
||||
ready=pods.get(i, {}).get("ready", False),
|
||||
external_ip=ip_by_idx.get(i),
|
||||
)
|
||||
for i in sorted(indices)
|
||||
]
|
||||
|
||||
# Probe RPC concurrently for nodes with a reachable external IP.
|
||||
await asyncio.gather(*[_probe_node_rpc(n, http_port) for n in nodes])
|
||||
|
||||
# In MODE A (no CR), derive declared validators + version from observation.
|
||||
if not fleet.validators:
|
||||
fleet.validators = len(nodes)
|
||||
if not fleet.version:
|
||||
ver = next((n.version for n in nodes if n.version), None)
|
||||
if ver:
|
||||
fleet.version = ver
|
||||
fleet.image = ver
|
||||
if fleet.phase == "Unknown" and any(n.rpc_reachable for n in nodes):
|
||||
fleet.phase = "Running"
|
||||
|
||||
fleet.nodes = nodes
|
||||
fleet.nodes_total = len(nodes)
|
||||
# Without pod phase (MODE A), readiness = RPC reachability.
|
||||
if pods:
|
||||
fleet.nodes_ready = sum(1 for n in nodes if n.ready)
|
||||
else:
|
||||
fleet.nodes_ready = sum(1 for n in nodes if n.rpc_reachable)
|
||||
for n in nodes:
|
||||
if n.rpc_reachable:
|
||||
n.ready = True
|
||||
fleet.nodes_rpc_ok = sum(1 for n in nodes if n.rpc_reachable)
|
||||
fleet.total_peers = sum(n.peers or 0 for n in nodes)
|
||||
fleet.dex = await _dex_summary(nodes, network)
|
||||
if fleet.nodes_total == 0:
|
||||
fleet.error = "no luxd endpoints configured"
|
||||
return fleet
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_overview(networks: Optional[list[str]] = None) -> LuxFleetOverview:
|
||||
"""Collect the full read-only fleet overview across all networks.
|
||||
|
||||
Default = MODE A (direct luxd RPC over configured endpoints). If a read-only
|
||||
lux-k8s kubeconfig is configured, MODE B enrichment (CR + pods) is layered on.
|
||||
"""
|
||||
import time
|
||||
|
||||
selected = networks or list(LUX_NETWORKS.keys())
|
||||
overview = LuxFleetOverview(generated_at=time.time())
|
||||
endpoints = _load_endpoints()
|
||||
|
||||
custom_api = core_api = None
|
||||
if fleet_data_available():
|
||||
try:
|
||||
custom_api, core_api = await asyncio.to_thread(_load_lux_k8s_clients)
|
||||
except Exception as e:
|
||||
logger.warning("lux-k8s enrichment unavailable, using direct RPC: %s", e)
|
||||
|
||||
overview.networks = await asyncio.gather(*[
|
||||
_collect_network(n, endpoints, custom_api, core_api) for n in selected
|
||||
])
|
||||
|
||||
overview.totals = {
|
||||
"networks": len(overview.networks),
|
||||
"validators": sum(f.validators for f in overview.networks),
|
||||
"nodes_total": sum(f.nodes_total for f in overview.networks),
|
||||
"nodes_ready": sum(f.nodes_ready for f in overview.networks),
|
||||
"nodes_rpc_ok": sum(f.nodes_rpc_ok for f in overview.networks),
|
||||
"total_peers": sum(f.total_peers for f in overview.networks),
|
||||
}
|
||||
return overview
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Standalone read-only Lux fleet API.
|
||||
|
||||
A minimal FastAPI surface that exposes the live luxd fleet overview for the
|
||||
lux.cloud dashboard. It depends ONLY on the self-contained fleet collector
|
||||
(``bootnode.core.chains.lux_fleet``) — it does not import the main monolith,
|
||||
its database, or any mutating routers. This keeps the read-only fleet view
|
||||
fully isolated (separation of concerns) and independently deployable.
|
||||
|
||||
Run: uvicorn bootnode.fleet_app:app --host 0.0.0.0 --port 8000
|
||||
|
||||
Serves:
|
||||
GET /health -> liveness
|
||||
GET /v1/fleets/lux/overview -> full read-only fleet overview (all networks)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from bootnode.core.chains.lux_fleet import (
|
||||
LuxFleetOverview,
|
||||
fleet_data_available,
|
||||
get_overview,
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title="Lux Fleet API",
|
||||
version="1.0.0",
|
||||
description="Read-only live overview of the managed luxd fleet (devnet/testnet/mainnet).",
|
||||
)
|
||||
|
||||
# White-label cloud surfaces that consume this read-only API.
|
||||
_DEFAULT_ORIGINS = [
|
||||
"https://lux.cloud",
|
||||
"https://www.lux.cloud",
|
||||
"https://cloud.lux.network",
|
||||
]
|
||||
_extra = [o.strip() for o in os.environ.get("ALLOWED_ORIGINS", "").split(",") if o.strip()]
|
||||
_origins = _DEFAULT_ORIGINS + [o for o in _extra if o not in _DEFAULT_ORIGINS]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
# MODE A (direct luxd RPC over configured endpoints) is always available;
|
||||
# k8s enrichment (CR + pods) is on only when a lux-k8s kubeconfig is set.
|
||||
return {"status": "ok", "k8s_enrichment": fleet_data_available()}
|
||||
|
||||
|
||||
@app.get("/v1/fleets/lux/overview", response_model=LuxFleetOverview)
|
||||
async def lux_overview(networks: str | None = None) -> LuxFleetOverview:
|
||||
"""Read-only fleet overview across devnet/testnet/mainnet.
|
||||
|
||||
``networks`` optionally narrows to a comma-separated subset.
|
||||
"""
|
||||
selected = None
|
||||
if networks:
|
||||
selected = [n.strip() for n in networks.split(",") if n.strip()]
|
||||
return await get_overview(selected)
|
||||
Reference in New Issue
Block a user