fix(lux): the fleet is observed through lux-k8s, on /v1

luxd serves /v1 only — server/http/server.go pins baseURL="/v1" — so every
/ext call in this repo was a silent no-op. The fleet collector's _rpc()
returns None on non-200, so the lux.cloud dashboard rendered nodes_rpc_ok:0,
version:null and every node rpc_reachable:false while the fleet was in fact
running luxd/1.36.2 (mainnet), 1.36.24 (testnet), 1.36.25 (devnet).

Fixing the path alone would not have helped: the collector addressed each
validator by a hardcoded public LoadBalancer IP, and there are now zero
LoadBalancer Services in lux-mainnet/testnet/devnet. Those IPs time out. So
node addressing is no longer configured — it is derived from the cluster on
every collection, and the one way to reach a validator is the lux-k8s API
server's pod proxy, since luxd RPC is cluster-internal.

That collapses the old MODE A / MODE B split into a single path: read the CR
for the declared shape, the pods for phase/readiness/IP, then each pod's
JSON-RPC through the proxy. One credential authenticates both. Deleting the
endpoint ConfigMap and its 15 baked-in IPs removes the failure mode where
infrastructure moves and a dashboard quietly reports zeros.

Probes move to /v1/health/liveness, not /v1/health: a degraded-but-running
validator answers /v1/health with 503, so probing it would restart nodes that
are merely behind. Any fleet from charts/lux could never have gone Ready.

LuxNode.external_ip becomes .ip — with no public per-pod endpoint, the honest
value is the pod IP.

Avalanche endpoints keep /ext; that is their API, not ours.

Measured, with controls: pod-proxy POST /v1/info -> 200 on all 15 pods;
/ext/health -> 404; /zzz/health -> 404; nonexistent pod -> 404. A test asserts
every probe path is under /v1 and fails if /ext returns.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-27 19:18:04 -07:00
co-authored by Hanzo Dev
parent 88397558f2
commit 1f30a4c21c
20 changed files with 465 additions and 451 deletions
+2 -2
View File
@@ -231,7 +231,7 @@ def _generate_lux_explorer(cfg: ChainConfig, network: str, net: "NetworkSpec", n
"""
ex = cfg.services.explorer
host = cfg.brand.domains.explorer or f"explorer.{network}.{cfg.chain.slug}.network"
rpc_url = net.rpc_url or f"http://{cfg.chain.slug}d-0.{cfg.chain.slug}d-headless.{namespace}.svc:9630/ext/bc/C/rpc"
rpc_url = net.rpc_url or f"http://{cfg.chain.slug}d-0.{cfg.chain.slug}d-headless.{namespace}.svc:9630/v1/bc/C/rpc"
port = ex.port
return f"""apiVersion: apps/v1
@@ -479,7 +479,7 @@ spec:
- name: CHAIN_ID
value: "{net.chain_id}"
- name: RPC_URL
value: "http://{cfg.chain.slug}d-0.{cfg.chain.slug}d:9630/ext/bc/C/rpc"
value: "http://{cfg.chain.slug}d-0.{cfg.chain.slug}d:9630/v1/bc/C/rpc"
- name: DRIP_AMOUNT
value: "{faucet.drip_amount}"
- name: RATE_LIMIT
+1 -1
View File
@@ -140,7 +140,7 @@ class FleetManager:
rpc_endpoint = None
if external_ips:
first_ip = next(iter(external_ips.values()))
rpc_endpoint = f"http://{first_ip}:{net_cfg.http_port}/ext/bc/C/rpc"
rpc_endpoint = f"http://{first_ip}:{net_cfg.http_port}/v1/bc/C/rpc"
chain_statuses = status.get("chainStatuses", {})
+153 -226
View File
@@ -1,25 +1,27 @@
"""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:
devnet / testnet / mainnet, observing it READ-ONLY through the lux-k8s API
server — the one way to reach an individual validator.
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.
For each namespace it reads the operator ``LuxNetwork`` CR (``lux.cloud/v1``)
for declared validator count, image tag, plugin-set and phase, lists the luxd
pods for real phase + readiness, then queries every pod's luxd JSON-RPC through
the API server's pod proxy 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.
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.
Addressing goes through the API server because validators have no public
per-pod endpoint: their Services are ClusterIP and luxd's RPC listens only
inside the cluster. Reaching them by hardcoded LoadBalancer IP silently rotted
to nothing when those LoadBalancers were removed, which is why the node address
is now derived from the cluster on every collection rather than configured.
Both modes are strictly observational — no mutations, no writes, no secrets in
the response. But the aggregate is a cross-tenant INFRA view (whole-fleet health
+, in MODE B, operator CR and pod topology), so it is NOT customer data and must
NOT be served unauthenticated. The HTTP surfaces that expose it
This is strictly observational — no mutations, no writes, no secrets in the
response. But the aggregate is a cross-tenant INFRA view (whole-fleet health,
operator CR and pod topology), so it is NOT customer data and must NOT be
served unauthenticated. The HTTP surfaces that expose it
(``bootnode.fleet_app`` and ``api/fleets:lux_fleet_overview``) gate it
server-side behind the IAM admin role (``bootnode.core.iam.require_admin``).
"""
@@ -27,7 +29,6 @@ server-side behind the IAM admin role (``bootnode.core.iam.require_admin``).
from __future__ import annotations
import asyncio
import json
import logging
import os
import tempfile
@@ -59,66 +60,13 @@ LUX_NETWORKS: dict[str, dict[str, Any]] = {
"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).
# Read-only lux-k8s kubeconfig. File path (LUX_K8S_KUBECONFIG) or inline YAML
# (LUX_K8S_KUBECONFIG_DATA); in-cluster config when neither is set.
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
# The API server proxies to the pod, so budget for that extra hop.
_RPC_TIMEOUT = httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0)
# ---------------------------------------------------------------------------
@@ -140,14 +88,14 @@ class LuxNode(BaseModel):
pod_index: int
phase: str = "Unknown" # K8s pod phase: Running/Pending/...
ready: bool = False # K8s readiness
external_ip: Optional[str] = None
ip: Optional[str] = None # pod IP — validators have no public address
rpc_reachable: bool = False # did luxd RPC answer?
node_id: Optional[str] = None
version: Optional[str] = None # e.g. luxd/1.30.39
version: Optional[str] = None # e.g. luxd/1.36.2
git_commit: Optional[str] = None
db_version: Optional[str] = None
peers: Optional[int] = None
healthy: Optional[bool] = None # luxd /ext/health (best-effort)
healthy: Optional[bool] = None # luxd /v1/health (best-effort)
chains: list[ChainHeight] = Field(default_factory=list)
@@ -196,12 +144,16 @@ class LuxFleetOverview(BaseModel):
# ---------------------------------------------------------------------------
def _load_lux_k8s_clients():
"""Return (CustomObjectsApi, CoreV1Api) for lux-k8s, read-only.
"""Return (CustomObjectsApi, CoreV1Api, httpx_kwargs) 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).
The credential is resolved once and shared: the same host, CA and bearer
token that authenticate the k8s reads also authenticate the pod-proxy RPC,
so there is one way in and nothing to keep in sync.
"""
from kubernetes import client, config
@@ -223,7 +175,26 @@ def _load_lux_k8s_clients():
# Last resort: in-cluster. Only valid if deployed inside lux-k8s.
config.load_incluster_config()
return client.CustomObjectsApi(), client.CoreV1Api()
conf = client.Configuration.get_default_copy()
kwargs: dict[str, Any] = {
"base_url": (conf.host or "").rstrip("/"),
"verify": conf.ssl_ca_cert or bool(conf.verify_ssl),
"headers": {s["key"]: s["value"] for s in conf.auth_settings().values()
if s.get("in") == "header"},
"timeout": _RPC_TIMEOUT,
}
if conf.cert_file:
kwargs["cert"] = (conf.cert_file, conf.key_file)
return client.CustomObjectsApi(), client.CoreV1Api(), kwargs
def _pod_proxy(namespace: str, pod: str, port: int) -> str:
"""Address one luxd pod through the API server proxy.
The single way this collector reaches a validator: luxd RPC is
cluster-internal, so the API server is the only door in.
"""
return f"/api/v1/namespaces/{namespace}/pods/{pod}:{port}/proxy"
def fleet_data_available() -> bool:
@@ -255,63 +226,60 @@ async def _rpc(http: httpx.AsyncClient, base: str, path: str, method: str,
return None
async def _probe_node_rpc(node: LuxNode, http_port: int) -> None:
async def _probe_node_rpc(http: httpx.AsyncClient, base: str, node: LuxNode) -> 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
# Version / nodeID / peers in parallel.
ver, nid, peers, health = await asyncio.gather(
_rpc(http, base, "/v1/info", "info.getNodeVersion"),
_rpc(http, base, "/v1/info", "info.getNodeID"),
_rpc(http, base, "/v1/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
# Per-chain bootstrap + heights.
chains: list[ChainHeight] = []
boot_results = await asyncio.gather(*[
_rpc(http, base, "/v1/info", "info.isBootstrapped", {"chain": c})
for c in TRACKED_CHAINS
])
c_height, p_height = await asyncio.gather(
_rpc(http, base, "/v1/bc/C/rpc", "eth_blockNumber", []),
_rpc(http, base, "/v1/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.)"""
"""luxd /v1/health — returns healthy flag. A degraded node answers 503 with
a body, so 503 is data; any other status or a parse error is unknown, not
unhealthy. (Known to encode-fail on some builds.)"""
try:
r = await http.get(f"{base}/ext/health")
r = await http.get(f"{base}/v1/health")
if r.status_code not in (200, 503):
return None
return bool(r.json().get("healthy"))
@@ -336,7 +304,7 @@ def _read_luxnetwork_cr(custom_api, namespace: str) -> dict:
def _list_luxd_pods(core_api, namespace: str) -> dict[int, dict[str, Any]]:
"""Map pod index -> {phase, ready} for luxd-N pods."""
"""Map pod index -> {phase, ready, ip} for luxd-N pods."""
out: dict[int, dict[str, Any]] = {}
try:
pods = core_api.list_namespaced_pod(
@@ -364,31 +332,8 @@ def _list_luxd_pods(core_api, namespace: str) -> dict[int, dict[str, Any]]:
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
"ready": ready,
"ip": (p.status.pod_ip if p.status else None)}
return out
@@ -446,69 +391,53 @@ async def _dex_summary(nodes: list[LuxNode], network: str) -> DexSummary:
async def _collect_network(
network: str,
endpoints: dict[str, Any],
custom_api=None,
core_api=None,
custom_api,
core_api,
http: httpx.AsyncClient,
) -> LuxNetworkFleet:
"""Collect one network. MODE A (default) queries configured RPC endpoints;
MODE B additionally reads the CR + pods/services when k8s clients are given."""
"""Collect one network: CR (declared shape) + pods (observed) + luxd RPC."""
cfg = LUX_NETWORKS[network]
ns = cfg["namespace"]
ep = endpoints.get(network, {})
http_port = int(ep.get("http_port") or cfg["http_port"])
http_port = int(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]] = {}
try:
cr, pods = await asyncio.gather(
asyncio.to_thread(_read_luxnetwork_cr, custom_api, ns),
asyncio.to_thread(_list_luxd_pods, 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"]
except Exception as e:
logger.warning("lux-k8s read failed for %s: %s", network, e)
fleet.error = f"lux-k8s unreachable: {e}"
return fleet
# 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),
phase=pods[i]["phase"],
ready=pods[i]["ready"],
ip=pods[i]["ip"],
)
for i in sorted(indices)
for i in sorted(pods)
]
# Probe RPC concurrently for nodes with a reachable external IP.
await asyncio.gather(*[_probe_node_rpc(n, http_port) for n in nodes])
await asyncio.gather(*[
_probe_node_rpc(http, _pod_proxy(ns, n.pod_name, http_port), n) for n in nodes
])
# In MODE A (no CR), derive declared validators + version from observation.
# Without a CR, derive the declared shape from what is actually running.
if not fleet.validators:
fleet.validators = len(nodes)
if not fleet.version:
@@ -521,19 +450,12 @@ async def _collect_network(
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_ready = sum(1 for n in nodes if n.ready)
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"
fleet.error = f"no luxd pods in {ns}"
return fleet
@@ -542,27 +464,32 @@ async def _collect_network(
# ---------------------------------------------------------------------------
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.
"""
"""Collect the full read-only fleet overview across all networks."""
import time
selected = networks or list(LUX_NETWORKS.keys())
selected = [n for n in (networks or LUX_NETWORKS) if n in LUX_NETWORKS]
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)
try:
custom_api, core_api, http_kwargs = await asyncio.to_thread(_load_lux_k8s_clients)
except Exception as e:
logger.error("lux-k8s credential unavailable: %s", e)
overview.networks = [
LuxNetworkFleet(network=n, namespace=LUX_NETWORKS[n]["namespace"],
network_id=LUX_NETWORKS[n]["network_id"],
chain_id=LUX_NETWORKS[n]["chain_id"],
error=f"lux-k8s credential unavailable: {e}")
for n in selected
]
overview.totals = {"networks": len(overview.networks), "validators": 0,
"nodes_total": 0, "nodes_ready": 0, "nodes_rpc_ok": 0,
"total_peers": 0}
return overview
overview.networks = await asyncio.gather(*[
_collect_network(n, endpoints, custom_api, core_api) for n in selected
])
async with httpx.AsyncClient(**http_kwargs) as http:
overview.networks = await asyncio.gather(*[
_collect_network(n, custom_api, core_api, http) for n in selected
])
overview.totals = {
"networks": len(overview.networks),
+1 -1
View File
@@ -441,7 +441,7 @@ class K8sNodeDetector:
rpc_endpoint = None
if pod_ip:
port = 9650 if chain == "lux" else 8545
rpc_endpoint = f"http://{pod_ip}:{port}/ext/bc/C/rpc"
rpc_endpoint = f"http://{pod_ip}:{port}/v1/bc/C/rpc"
existing = (
await db.execute(
+6 -6
View File
@@ -48,18 +48,18 @@ SEED_CLUSTERS: list[dict] = [
_NETWORK_CONFIG = {
"mainnet": {
"base_block_height": 28_500_000,
"rpc_endpoint": "https://api.lux.network/ext/bc/mainnet/rpc",
"ws_endpoint": "wss://api.lux.network/ext/bc/mainnet/ws",
"rpc_endpoint": "https://api.lux.network/v1/bc/C/rpc",
"ws_endpoint": "wss://api.lux.network/v1/bc/C/ws",
},
"testnet": {
"base_block_height": 15_200_000,
"rpc_endpoint": "https://api.lux.network/ext/bc/testnet/rpc",
"ws_endpoint": "wss://api.lux.network/ext/bc/testnet/ws",
"rpc_endpoint": "https://api.lux-test.network/v1/bc/C/rpc",
"ws_endpoint": "wss://api.lux-test.network/v1/bc/C/ws",
},
"devnet": {
"base_block_height": 5_100_000,
"rpc_endpoint": "https://api.lux.network/ext/bc/devnet/rpc",
"ws_endpoint": "wss://api.lux.network/ext/bc/devnet/ws",
"rpc_endpoint": "https://api.lux-dev.network/v1/bc/C/rpc",
"ws_endpoint": "wss://api.lux-dev.network/v1/bc/C/ws",
},
}
+3 -3
View File
@@ -53,9 +53,9 @@ app.add_middleware(
@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()}
# The collector reads the fleet through the lux-k8s API server, so the
# overview is only collectable when that credential is present.
return {"status": "ok", "lux_k8s": fleet_data_available()}
@app.get("/v1/fleets/lux/overview", response_model=LuxFleetOverview)
+1 -1
View File
@@ -68,7 +68,7 @@ networks:
networkId: 494949
chainId: 494949
validators: 5
rpcUrl: "https://api.pars.network/ext/bc/C/rpc"
rpcUrl: "https://api.pars.network/v1/bc/C/rpc"
snapshotInterval: 3600
testnet:
networkId: 494950
+143 -127
View File
@@ -2,15 +2,16 @@
Pure-logic + mocked-IO coverage:
- CR parsing (lux.cloud/v1 shape): version, plugin-set, validators, phase.
- Node-set union from pods + per-pod LB services.
- RPC enrichment (mocked httpx) → version/peers/heights/bootstrapped.
- Pod-proxy addressing: the one way the collector reaches a validator.
- RPC enrichment (mocked httpx) → version/peers/heights/bootstrapped, and a
regression guard that every luxd call goes to /v1 (luxd serves no /ext).
- Overview aggregation + totals.
- Graceful "no kubeconfig" shaped-empty response.
- Graceful "no lux-k8s credential" shaped-empty response.
"""
import os
from unittest.mock import patch
import httpx
import pytest
from bootnode.core.chains import lux_fleet
@@ -18,8 +19,8 @@ from bootnode.core.chains.lux_fleet import (
ChainHeight,
DexSummary,
LuxNode,
LuxNetworkFleet,
_parse_cr,
_pod_proxy,
get_overview,
)
@@ -35,7 +36,7 @@ SAMPLE_CR = {
"kind": "LuxNetwork",
"spec": {
"validators": 5,
"image": {"repository": "ghcr.io/luxfi/node", "tag": "v1.30.40-d69d3257"},
"image": {"repository": "ghcr.io/luxfi/node", "tag": "v1.36.25"},
"pluginSource": {
"bucket": "s3://lux-plugins-devnet/v1.3.1/",
"vmPlugins": [
@@ -53,8 +54,8 @@ class TestParseCR:
def test_extracts_declared_shape(self):
p = _parse_cr(SAMPLE_CR)
assert p["validators"] == 5
assert p["image"] == "ghcr.io/luxfi/node:v1.30.40-d69d3257"
assert p["version"] == "v1.30.40-d69d3257"
assert p["image"] == "ghcr.io/luxfi/node:v1.36.25"
assert p["version"] == "v1.36.25"
assert p["plugin_set"] == "s3://lux-plugins-devnet/v1.3.1/"
assert p["phase"] == "Unknown"
@@ -71,141 +72,157 @@ class TestParseCR:
assert p["vm_plugins"] == []
class TestEndpointConfig:
def test_defaults_present(self):
eps = lux_fleet.DEFAULT_ENDPOINTS
assert set(eps) == {"mainnet", "testnet", "devnet"}
for net, cfg in eps.items():
assert cfg["http_port"] in (9630, 9640, 9650)
assert len(cfg["nodes"]) == 5
def test_env_json_overrides_defaults(self, monkeypatch):
monkeypatch.delenv(lux_fleet.ENV_ENDPOINTS_FILE, raising=False)
monkeypatch.setenv(
lux_fleet.ENV_ENDPOINTS_JSON,
'{"devnet": {"http_port": 9650, "nodes": [{"index": 0, "ip": "9.9.9.9"}]}}',
class TestPodProxy:
def test_addresses_the_pod_through_the_api_server(self):
assert _pod_proxy("lux-mainnet", "luxd-3", 9630) == (
"/api/v1/namespaces/lux-mainnet/pods/luxd-3:9630/proxy"
)
eps = lux_fleet._load_endpoints()
assert eps["devnet"]["nodes"][0]["ip"] == "9.9.9.9"
def test_bad_json_falls_back_to_defaults(self, monkeypatch):
monkeypatch.delenv(lux_fleet.ENV_ENDPOINTS_FILE, raising=False)
monkeypatch.setenv(lux_fleet.ENV_ENDPOINTS_JSON, "{not json")
assert lux_fleet._load_endpoints() == lux_fleet.DEFAULT_ENDPOINTS
class TestModeA_DirectRPC:
@pytest.mark.asyncio
async def test_overview_direct_rpc_no_kubeconfig(self, monkeypatch):
# No kubeconfig → MODE A: nodes come from endpoint config, health from RPC.
monkeypatch.delenv(lux_fleet.ENV_KUBECONFIG_PATH, raising=False)
monkeypatch.delenv(lux_fleet.ENV_KUBECONFIG_DATA, raising=False)
async def fake_probe(node: LuxNode, http_port: int):
node.rpc_reachable = True
node.version = "luxd/1.30.40"
node.peers = 4
node.chains = [ChainHeight(chain="C", bootstrapped=True, height=500)]
with patch("os.path.exists", return_value=False), \
patch.object(lux_fleet, "_probe_node_rpc", side_effect=fake_probe), \
patch.object(lux_fleet, "_dex_summary",
side_effect=lambda n, net: _dex_stub()):
ov = await get_overview(["mainnet"])
f = ov.networks[0]
assert f.network == "mainnet"
assert f.nodes_total == 5 # from DEFAULT_ENDPOINTS
assert f.nodes_ready == 5 # MODE A: ready == rpc_reachable
assert f.nodes_rpc_ok == 5
assert f.validators == 5 # derived from node count
assert f.version == "luxd/1.30.40" # derived from RPC
assert f.phase == "Running"
assert ov.totals["nodes_rpc_ok"] == 5
def test_port_is_per_network(self):
ports = {n: c["http_port"] for n, c in lux_fleet.LUX_NETWORKS.items()}
assert ports == {"mainnet": 9630, "testnet": 9640, "devnet": 9650}
class TestCollectNetwork:
@pytest.mark.asyncio
async def test_mode_b_enrichment_with_mocked_io(self):
"""MODE B: CR + pods/svcs enrichment merged with RPC. luxd-0/1/2 expose
RPC (have LB IP); luxd-3/4 have pods only."""
pods = {i: {"phase": "Running", "ready": True} for i in range(5)}
ips = {0: "10.0.0.1", 1: "10.0.0.2", 2: "10.0.0.3"}
async def test_cr_and_pods_merged_with_rpc(self):
"""CR declares the shape, pods observe it, RPC fills in live state.
luxd-0/1/2 answer RPC; luxd-3/4 are up but their RPC does not."""
pods = {i: {"phase": "Running", "ready": True, "ip": f"10.0.0.{i}"}
for i in range(5)}
async def fake_probe(node: LuxNode, http_port: int):
if node.external_ip:
node.rpc_reachable = True
node.version = "luxd/1.30.39"
node.node_id = f"NodeID-{node.pod_index}"
node.peers = 4
node.healthy = True
node.chains = [
ChainHeight(chain="C", bootstrapped=True, height=108),
ChainHeight(chain="P", bootstrapped=True, height=0),
ChainHeight(chain="X", bootstrapped=True),
ChainHeight(chain="D", bootstrapped=False),
]
async def fake_probe(http, base, node: LuxNode):
assert base == _pod_proxy("lux-devnet", node.pod_name, 9650)
if node.pod_index > 2:
return
node.rpc_reachable = True
node.version = "luxd/1.36.25"
node.node_id = f"NodeID-{node.pod_index}"
node.peers = 4
node.healthy = True
node.chains = [
ChainHeight(chain="C", bootstrapped=True, height=108),
ChainHeight(chain="P", bootstrapped=True, height=0),
ChainHeight(chain="X", bootstrapped=True),
ChainHeight(chain="D", bootstrapped=False),
]
with patch.object(lux_fleet, "_read_luxnetwork_cr", return_value=SAMPLE_CR), \
patch.object(lux_fleet, "_list_luxd_pods", return_value=pods), \
patch.object(lux_fleet, "_list_luxd_external_ips", return_value=ips), \
patch.object(lux_fleet, "_probe_node_rpc", side_effect=fake_probe), \
patch.object(lux_fleet, "_dex_summary", side_effect=lambda n, net: _dex_stub()):
# endpoints={} so the node set comes purely from k8s discovery.
fleet = await lux_fleet._collect_network(
"devnet", {}, custom_api=object(), core_api=object())
"devnet", custom_api=object(), core_api=object(), http=object())
assert fleet.network == "devnet"
assert fleet.namespace == "lux-devnet"
assert fleet.version == "v1.30.40-d69d3257" # from CR
assert fleet.version == "v1.36.25" # from CR
assert fleet.plugin_set == "s3://lux-plugins-devnet/v1.3.1/"
assert fleet.validators == 5
assert fleet.nodes_total == 5 # union of pods + svcs
assert fleet.nodes_ready == 5 # MODE B: pod readiness
assert fleet.nodes_rpc_ok == 3 # only LB-exposed nodes answer
assert fleet.total_peers == 12 # 3 nodes * 4 peers
assert fleet.nodes_total == 5
assert fleet.nodes_ready == 5 # pod readiness
assert fleet.nodes_rpc_ok == 3 # only 3 answer RPC
assert fleet.total_peers == 12 # 3 nodes * 4 peers
assert fleet.dex.d_chain_bootstrapped is False
assert [n.ip for n in fleet.nodes] == [f"10.0.0.{i}" for i in range(5)]
n3 = next(n for n in fleet.nodes if n.pod_index == 3)
assert n3.rpc_reachable is False
assert n3.phase == "Running"
@pytest.mark.asyncio
async def test_mode_a_uses_endpoint_config(self):
"""MODE A: node set + IPs come from the endpoint config (no k8s)."""
endpoints = {"mainnet": {"http_port": 9630, "nodes": [
{"index": 0, "ip": "1.1.1.1"}, {"index": 1, "ip": "2.2.2.2"},
]}}
async def test_version_derived_from_rpc_when_cr_absent(self):
pods = {0: {"phase": "Running", "ready": True, "ip": "10.0.0.9"}}
async def fake_probe(node: LuxNode, http_port: int):
assert http_port == 9630
async def fake_probe(http, base, node: LuxNode):
node.rpc_reachable = True
node.version = "luxd/1.30.40"
node.version = "luxd/1.36.2"
node.peers = 7
node.chains = [ChainHeight(chain="D", bootstrapped=True)]
with patch.object(lux_fleet, "_probe_node_rpc", side_effect=fake_probe), \
with patch.object(lux_fleet, "_read_luxnetwork_cr", return_value={}), \
patch.object(lux_fleet, "_list_luxd_pods", return_value=pods), \
patch.object(lux_fleet, "_probe_node_rpc", side_effect=fake_probe), \
patch.object(lux_fleet, "_dex_summary", side_effect=lambda n, net: _dex_stub()):
fleet = await lux_fleet._collect_network("mainnet", endpoints)
fleet = await lux_fleet._collect_network(
"mainnet", custom_api=object(), core_api=object(), http=object())
assert fleet.nodes_total == 2
assert [n.external_ip for n in fleet.nodes] == ["1.1.1.1", "2.2.2.2"]
assert fleet.nodes_ready == 2 # ready == rpc_reachable in MODE A
assert fleet.validators == 2 # derived from node count
assert fleet.version == "luxd/1.30.40"
assert fleet.total_peers == 14
assert fleet.nodes_total == 1
assert fleet.validators == 1 # derived from node count
assert fleet.version == "luxd/1.36.2" # derived from RPC
assert fleet.phase == "Running"
assert fleet.total_peers == 7
@pytest.mark.asyncio
async def test_unreachable_cluster_reports_error_not_silence(self):
def boom(*a, **kw):
raise RuntimeError("connection refused")
with patch.object(lux_fleet, "_read_luxnetwork_cr", side_effect=boom), \
patch.object(lux_fleet, "_list_luxd_pods", side_effect=boom):
fleet = await lux_fleet._collect_network(
"testnet", custom_api=object(), core_api=object(), http=object())
assert fleet.error is not None
assert "connection refused" in fleet.error
assert fleet.nodes_total == 0
class TestOverview:
@pytest.mark.asyncio
async def test_missing_credential_yields_shaped_error(self):
def boom():
raise RuntimeError("no kubeconfig")
with patch.object(lux_fleet, "_load_lux_k8s_clients", side_effect=boom):
ov = await get_overview()
assert len(ov.networks) == 3
assert all(f.error and "no kubeconfig" in f.error for f in ov.networks)
assert ov.totals["nodes_rpc_ok"] == 0
@pytest.mark.asyncio
async def test_totals_aggregate_every_network(self):
pods = {i: {"phase": "Running", "ready": True, "ip": f"10.1.0.{i}"}
for i in range(5)}
async def fake_probe(http, base, node: LuxNode):
node.rpc_reachable = True
node.version = "luxd/1.36.2"
node.peers = 3
with patch.object(lux_fleet, "_load_lux_k8s_clients",
return_value=(object(), object(), {"base_url": "https://k8s"})), \
patch.object(lux_fleet, "_read_luxnetwork_cr", return_value=SAMPLE_CR), \
patch.object(lux_fleet, "_list_luxd_pods", return_value=pods), \
patch.object(lux_fleet, "_probe_node_rpc", side_effect=fake_probe), \
patch.object(lux_fleet, "_dex_summary", side_effect=lambda n, net: _dex_stub()):
ov = await get_overview()
assert ov.totals == {
"networks": 3, "validators": 15, "nodes_total": 15,
"nodes_ready": 15, "nodes_rpc_ok": 15, "total_peers": 45,
}
class TestRpcParsing:
@pytest.mark.asyncio
async def test_probe_parses_rpc_results(self):
"""_probe_node_rpc maps luxd JSON-RPC into the node model."""
import httpx
async def test_probe_parses_rpc_results_and_only_calls_v1(self):
"""_probe_node_rpc maps luxd JSON-RPC into the node model.
def handler(request: httpx.Request) -> httpx.Response:
Also asserts every request path is under the pod proxy's /v1 — luxd
serves no /ext, so an /ext call is a silent all-null dashboard.
"""
base = _pod_proxy("lux-devnet", "luxd-0", 9650)
seen: list[str] = []
def router(request: httpx.Request) -> httpx.Response:
seen.append(request.url.path)
if request.url.path.endswith("/v1/health"):
return httpx.Response(200, json={"healthy": True})
body = request.read().decode()
if "info.getNodeVersion" in body:
return httpx.Response(200, json={"result": {
"version": "luxd/1.30.39", "gitCommit": "abc123",
"version": "luxd/1.36.25", "gitCommit": "abc123",
"databaseVersion": "v1.4.5"}})
if "info.getNodeID" in body:
return httpx.Response(200, json={"result": {"nodeID": "NodeID-XYZ"}})
@@ -220,36 +237,35 @@ class TestRpcParsing:
return httpx.Response(200, json={"result": {"height": "42"}})
return httpx.Response(404)
def health_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"healthy": True})
def router(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/ext/health"):
return health_handler(request)
return handler(request)
transport = httpx.MockTransport(router)
node = LuxNode(pod_name="luxd-0", pod_index=0, external_ip="10.0.0.1")
# Patch AsyncClient to use the mock transport.
orig = httpx.AsyncClient
def patched(*args, **kwargs):
kwargs["transport"] = transport
kwargs.pop("timeout", None)
return orig(*args, **kwargs)
with patch("bootnode.core.chains.lux_fleet.httpx.AsyncClient", patched):
await lux_fleet._probe_node_rpc(node, 9650)
node = LuxNode(pod_name="luxd-0", pod_index=0, ip="10.0.0.1")
async with httpx.AsyncClient(base_url="https://k8s",
transport=httpx.MockTransport(router)) as http:
await lux_fleet._probe_node_rpc(http, base, node)
assert node.rpc_reachable is True
assert node.version == "luxd/1.30.39"
assert node.version == "luxd/1.36.25"
assert node.git_commit == "abc123"
assert node.node_id == "NodeID-XYZ"
assert node.peers == 4
assert node.healthy is True
heights = {c.chain: c for c in node.chains}
assert heights["C"].height == 108
assert heights["C"].bootstrapped is True
assert heights["P"].height == 42
assert heights["C"].bootstrapped is True
assert heights["D"].bootstrapped is False
assert seen, "no requests were made"
assert all(p.startswith(f"{base}/v1/") for p in seen), seen
assert not any("/ext" in p for p in seen), seen
@pytest.mark.asyncio
async def test_degraded_node_503_health_is_data_not_unknown(self):
"""luxd answers /v1/health with 503 + a body when degraded."""
base = _pod_proxy("lux-mainnet", "luxd-2", 9630)
def router(request: httpx.Request) -> httpx.Response:
return httpx.Response(503, json={"healthy": False})
async with httpx.AsyncClient(base_url="https://k8s",
transport=httpx.MockTransport(router)) as http:
assert await lux_fleet._node_health(http, base) is False
+4 -4
View File
@@ -206,7 +206,7 @@ data:
echo "[BOOTSTRAP] Waiting for node health..."
i=0
while [ "$i" -lt 180 ]; do
if curl -sf -m 2 http://127.0.0.1:{{ $httpPort }}/ext/health >/dev/null 2>&1; then
if curl -sf -m 2 http://127.0.0.1:{{ $httpPort }}/v1/health/liveness >/dev/null 2>&1; then
echo "[BOOTSTRAP] Node healthy after ${i}s"
break
fi
@@ -219,7 +219,7 @@ data:
{{- range .Values.chainTracking.aliases }}
curl -s -m 5 -X POST -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"admin.aliasChain","params":{"chain":"C","alias":"{{ . }}"}}' \
http://127.0.0.1:{{ $httpPort }}/ext/admin >/dev/null 2>&1 || true
http://127.0.0.1:{{ $httpPort }}/v1/admin >/dev/null 2>&1 || true
{{- end }}
{{- end }}
@@ -231,7 +231,7 @@ data:
if [ ! -f "$MARKER" ]; then
height_hex=$(curl -s -m 10 -X POST -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' \
http://127.0.0.1:{{ $httpPort }}/ext/bc/C/rpc | sed -n 's/.*"result":"\(0x[0-9A-Fa-f]*\)".*/\1/p' | head -n1)
http://127.0.0.1:{{ $httpPort }}/v1/bc/C/rpc | sed -n 's/.*"result":"\(0x[0-9A-Fa-f]*\)".*/\1/p' | head -n1)
if [ -z "$height_hex" ]; then
height_hex="0x0"
@@ -272,7 +272,7 @@ data:
if [ -s "$RLP_FILE" ]; then
echo "[BOOTSTRAP] Starting import of $RLP_FILE..."
import_payload=$(printf '{"jsonrpc":"2.0","id":1,"method":"admin_importChain","params":["%s"]}' "$RLP_FILE")
import_result=$(curl -s -m {{ int .Values.bootstrap.rlpImport.timeout }} -X POST -H 'Content-Type: application/json' --data "$import_payload" http://127.0.0.1:{{ $httpPort }}/ext/bc/C/rpc || true)
import_result=$(curl -s -m {{ int .Values.bootstrap.rlpImport.timeout }} -X POST -H 'Content-Type: application/json' --data "$import_payload" http://127.0.0.1:{{ $httpPort }}/v1/bc/C/rpc || true)
echo "[BOOTSTRAP] Import result: $import_result"
echo "$import_result" | grep -q '"success":true' && touch "$MARKER" && echo "[BOOTSTRAP] Import complete" || echo "[BOOTSTRAP] Import did not return success"
else
+2 -2
View File
@@ -171,7 +171,7 @@ spec:
containerPort: {{ $stakingPort }}
livenessProbe:
httpGet:
path: /ext/health
path: /v1/health/liveness
port: {{ $httpPort }}
initialDelaySeconds: 60
periodSeconds: 15
@@ -179,7 +179,7 @@ spec:
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /ext/health
path: /v1/health/liveness
port: {{ $httpPort }}
initialDelaySeconds: 30
periodSeconds: 10
+2 -2
View File
@@ -137,7 +137,7 @@ services:
POLYGON_MAINNET_RPC: https://polygon-rpc.com
ARBITRUM_MAINNET_RPC: https://arb1.arbitrum.io/rpc
OPTIMISM_MAINNET_RPC: https://mainnet.optimism.io
AVALANCHE_MAINNET_RPC: https://api.avax.network/ext/bc/C/rpc
AVALANCHE_MAINNET_RPC: https://api.avax.network/v1/bc/C/rpc
BSC_MAINNET_RPC: https://bsc-dataseed.binance.org
SOLANA_MAINNET_RPC: https://api.mainnet-beta.solana.com
ports:
@@ -273,7 +273,7 @@ services:
postgres_pass: graph-bootnode-2026
postgres_db: graph_node
ipfs: graph-ipfs:5001
ethereum: "lux:${LUX_RPC_URL:-http://host.docker.internal:9630/ext/bc/C/rpc} zoo:${ZOO_RPC_URL:-http://host.docker.internal:9630/ext/bc/zoo/rpc} pars:${PARS_RPC_URL:-http://host.docker.internal:9630/ext/bc/pars/rpc} hanzo:${HANZO_RPC_URL:-http://host.docker.internal:9630/ext/bc/hanzo/rpc} spc:${SPC_RPC_URL:-http://host.docker.internal:9630/ext/bc/spc/rpc}"
ethereum: "lux:${LUX_RPC_URL:-http://host.docker.internal:9630/v1/bc/C/rpc} zoo:${ZOO_RPC_URL:-http://host.docker.internal:9630/v1/bc/zoo/rpc} pars:${PARS_RPC_URL:-http://host.docker.internal:9630/v1/bc/pars/rpc} hanzo:${HANZO_RPC_URL:-http://host.docker.internal:9630/v1/bc/hanzo/rpc} spc:${SPC_RPC_URL:-http://host.docker.internal:9630/v1/bc/spc/rpc}"
GRAPH_LOG: info
ETHEREUM_REORG_THRESHOLD: 1
ETHEREUM_ANCESTOR_COUNT: 1
+1 -1
View File
@@ -27,7 +27,7 @@ export interface LuxNode {
pod_index: number
phase: string
ready: boolean
external_ip: string | null
ip: string | null
rpc_reachable: boolean
node_id: string | null
version: string | null
+2 -2
View File
@@ -40,8 +40,8 @@ SOL_MAINNET_RPC=https://api.mainnet-beta.solana.com
SOL_DEVNET_RPC=https://api.devnet.solana.com
# Lux Network
LUX_MAINNET_RPC=https://api.lux.network/ext/bc/C/rpc
LUX_TESTNET_RPC=https://api.lux-test.network/ext/bc/C/rpc
LUX_MAINNET_RPC=https://api.lux.network/v1/bc/C/rpc
LUX_TESTNET_RPC=https://api.lux-test.network/v1/bc/C/rpc
# =============================================================================
# BLOCKCHAIN NODES (Optional - use "docker compose --profile nodes up -d")
+1 -1
View File
@@ -108,7 +108,7 @@ services:
BASE_MAINNET_RPC: ${BASE_MAINNET_RPC:-https://mainnet.base.org}
ARB_MAINNET_RPC: ${ARB_MAINNET_RPC:-https://arb1.arbitrum.io/rpc}
POLYGON_MAINNET_RPC: ${POLYGON_MAINNET_RPC:-https://polygon-rpc.com}
LUX_MAINNET_RPC: ${LUX_MAINNET_RPC:-https://api.lux.network/ext/bc/C/rpc}
LUX_MAINNET_RPC: ${LUX_MAINNET_RPC:-https://api.lux.network/v1/bc/C/rpc}
ports:
- "8000:8000"
depends_on:
+4 -4
View File
@@ -678,7 +678,7 @@ lux:
chain_id: 96369
name: "Lux C-Chain"
type: evm
rpc: "${LUX_MAINNET_RPC:-https://api.lux.network/ext/bc/C/rpc}"
rpc: "${LUX_MAINNET_RPC:-https://api.lux.network/v1/bc/C/rpc}"
enabled: true
features:
- native
@@ -689,7 +689,7 @@ lux:
chain_id: 0
name: "Lux P-Chain"
type: lux_platform
rpc: "${LUX_PCHAIN_RPC:-https://api.lux.network/ext/bc/P}"
rpc: "${LUX_PCHAIN_RPC:-https://api.lux.network/v1/bc/P}"
enabled: true
features:
- staking
@@ -698,7 +698,7 @@ lux:
chain_id: 0
name: "Lux X-Chain"
type: lux_dag
rpc: "${LUX_XCHAIN_RPC:-https://api.lux.network/ext/bc/X}"
rpc: "${LUX_XCHAIN_RPC:-https://api.lux.network/v1/bc/X}"
enabled: true
features:
- utxo
@@ -707,6 +707,6 @@ lux:
chain_id: 96370
name: "Lux Testnet"
type: evm
rpc: "${LUX_TESTNET_RPC:-https://api.lux-test.network/ext/bc/C/rpc}"
rpc: "${LUX_TESTNET_RPC:-https://api.lux-test.network/v1/bc/C/rpc}"
enabled: true
testnet: true
+99
View File
@@ -0,0 +1,99 @@
# Read-only lux-k8s identity for the Lux Fleet API.
#
# APPLIED TO do-sfo3-lux-k8s (not bootnode-k8s, where the workload runs). The
# fleet collector lives on bootnode-k8s and observes lux-k8s across the cluster
# boundary, so it needs a credential there. This grants the least privilege that
# still answers "what is the fleet doing?":
#
# pods get/list — phase, readiness, pod IP
# pods/proxy get — GET luxd /v1/health
# create — POST luxd JSON-RPC (/v1/info, /v1/bc/C/rpc, ...)
# luxnetworks get — declared validators, image tag, plugin set
#
# No write verbs on any real object, no secrets, no exec, no scale, and the
# binding is limited to the three luxd namespaces. pods/proxy `create` is how
# the k8s API expresses "POST through the proxy"; it creates nothing.
#
# Mint the kubeconfig the workload mounts with:
#
# kubectl --context=do-sfo3-lux-k8s -n bootnode \
# create token lux-fleet-reader --duration=87600h
#
# then write it into bootnode-k8s as secret/lux-k8s-reader-kubeconfig (key
# `kubeconfig`) in namespace bootnode.
---
apiVersion: v1
kind: Namespace
metadata:
name: bootnode
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: lux-fleet-reader
namespace: bootnode
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: lux-fleet-reader
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
# GET reaches luxd /v1/health; POST (expressed as `create`) reaches its
# JSON-RPC. Neither creates a Kubernetes object.
- apiGroups: [""]
resources: ["pods/proxy"]
verbs: ["get", "create"]
- apiGroups: ["lux.cloud"]
resources: ["luxnetworks"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: lux-fleet-reader
namespace: lux-mainnet
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: lux-fleet-reader
subjects:
- kind: ServiceAccount
name: lux-fleet-reader
namespace: bootnode
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: lux-fleet-reader
namespace: lux-testnet
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: lux-fleet-reader
subjects:
- kind: ServiceAccount
name: lux-fleet-reader
namespace: bootnode
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: lux-fleet-reader
namespace: lux-devnet
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: lux-fleet-reader
subjects:
- kind: ServiceAccount
name: lux-fleet-reader
namespace: bootnode
+23 -51
View File
@@ -1,56 +1,26 @@
# Lux Fleet API — read-only live overview of the managed luxd fleet.
#
# A minimal, isolated FastAPI surface (bootnode.fleet_app) reusing the bootnode
# api image. It observes the luxd fleet on do-sfo3-lux-k8s strictly READ-ONLY by
# querying each validator's public luxd JSON-RPC endpoint (MODE A): node version,
# node ID, peers, per-chain bootstrap (C/P/X/D), C-Chain eth_blockNumber, P-Chain
# platform.getHeight, and a native D-Chain DEX summary. It serves
# GET /v1/fleets/lux/overview — the data source for the lux.cloud fleet dashboard.
# api image. It observes the luxd fleet on do-sfo3-lux-k8s strictly READ-ONLY
# through that cluster's API server: the LuxNetwork CR for the declared shape,
# the luxd pods for phase/readiness/IP, and each pod's luxd JSON-RPC via the
# API server's pod proxy for node version, node ID, peers, per-chain bootstrap
# (C/P/X/D), C-Chain eth_blockNumber, P-Chain platform.getHeight and a native
# D-Chain DEX summary. It serves GET /v1/fleets/lux/overview — the data source
# for the lux.cloud fleet dashboard.
#
# No lux-k8s credential is needed and nothing on the luxd nets is touched: the
# per-pod RPC endpoints are public infra. Endpoints are operational config in a
# ConfigMap (updatable without a rebuild). If a READ-ONLY lux-k8s kubeconfig is
# later provisioned (LUX_K8S_KUBECONFIG), the service auto-enriches with CR data
# (declared validators, image tag, plugin-set) — MODE B.
# The pod proxy is the only way in: luxd RPC is cluster-internal and the luxd
# Services are ClusterIP, so there is no public per-pod endpoint to call. The
# read-only lux-k8s credential (LUX_K8S_KUBECONFIG) is therefore required, and
# it is the same credential used for both the k8s reads and the RPC.
#
# Nothing on the luxd nets is mutated — every call is a GET or a read-only
# JSON-RPC query.
#
# Why a separate deployment: the read-only fleet view is orthogonal to the api
# monolith and depends only on the self-contained collector, so it ships and
# scales independently behind the same api.cloud.lux.network ingress.
---
# Per-pod luxd RPC endpoints (public LoadBalancer IPs). Operational config, not
# secrets. Edit + rollout to update without rebuilding the image.
apiVersion: v1
kind: ConfigMap
metadata:
name: lux-fleet-endpoints
namespace: bootnode
data:
endpoints.json: |
{
"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"}
]}
}
---
apiVersion: apps/v1
kind: Deployment
@@ -84,13 +54,13 @@ spec:
- containerPort: 8000
name: http
env:
- name: LUX_FLEET_ENDPOINTS_FILE
value: /etc/lux-fleet/endpoints.json
- name: LUX_K8S_KUBECONFIG
value: /etc/lux-k8s/kubeconfig
- name: ALLOWED_ORIGINS
value: "https://lux.cloud,https://www.lux.cloud,https://cloud.lux.network"
volumeMounts:
- name: endpoints
mountPath: /etc/lux-fleet
- name: lux-k8s
mountPath: /etc/lux-k8s
readOnly: true
readinessProbe:
httpGet:
@@ -116,9 +86,11 @@ spec:
runAsUser: 1000
allowPrivilegeEscalation: false
volumes:
- name: endpoints
configMap:
name: lux-fleet-endpoints
# Read-only lux-k8s credential (ServiceAccount bootnode:lux-fleet-reader
# in lux-k8s; see lux-fleet-reader-rbac.yaml, applied to lux-k8s).
- name: lux-k8s
secret:
secretName: lux-k8s-reader-kubeconfig
---
apiVersion: v1
+5 -5
View File
@@ -28,31 +28,31 @@ data:
[chains.lux]
shard = "primary"
provider = [
{ label = "lux-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/ext/bc/C/rpc", features = ["archive"] }
{ label = "lux-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/v1/bc/C/rpc", features = ["archive"] }
]
[chains.zoo]
shard = "primary"
provider = [
{ label = "zoo-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/ext/bc/2Y625Kvdd4wZ5LKx9kLSf1jGALyARmXVN1YsTb4F3Hzv4bPDbp/rpc", features = ["archive"] }
{ label = "zoo-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/v1/bc/2Y625Kvdd4wZ5LKx9kLSf1jGALyARmXVN1YsTb4F3Hzv4bPDbp/rpc", features = ["archive"] }
]
[chains.pars]
shard = "primary"
provider = [
{ label = "pars-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/ext/bc/2pUskxqaL5Bpx7uRUGG1fDjPckjxQ4UKX4sLKeaS1NdSVBJd3F/rpc", features = ["archive"] }
{ label = "pars-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/v1/bc/2pUskxqaL5Bpx7uRUGG1fDjPckjxQ4UKX4sLKeaS1NdSVBJd3F/rpc", features = ["archive"] }
]
[chains.hanzo]
shard = "primary"
provider = [
{ label = "hanzo-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/ext/bc/2GiQb73CeJESjc4omFv2YtQHZrRgJf25NXPzAr5J6UNHRcDV2F/rpc", features = ["archive"] }
{ label = "hanzo-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/v1/bc/2GiQb73CeJESjc4omFv2YtQHZrRgJf25NXPzAr5J6UNHRcDV2F/rpc", features = ["archive"] }
]
[chains.spc]
shard = "primary"
provider = [
{ label = "spc-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/ext/bc/rtjwvtE1tEvrokmpeYdTq7b2zqZgmybKwR5MLjKMGAR1W78dQ/rpc", features = ["archive"] }
{ label = "spc-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/v1/bc/rtjwvtE1tEvrokmpeYdTq7b2zqZgmybKwR5MLjKMGAR1W78dQ/rpc", features = ["archive"] }
]
---
apiVersion: apps/v1
+2 -2
View File
@@ -240,7 +240,7 @@ impl Default for BootstrapSpec {
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct HealthSpec {
/// HTTP path for health check (e.g. "/ext/health" for Lux, "/healthz" for Ethereum).
/// HTTP path for health check (e.g. "/v1/health/liveness" for Lux, "/healthz" for Ethereum).
#[serde(default = "default_health_path")]
pub path: String,
/// HTTP method (GET or POST).
@@ -273,7 +273,7 @@ impl Default for HealthSpec {
}
}
fn default_health_path() -> String { "/ext/health".to_string() }
fn default_health_path() -> String { "/v1/health/liveness".to_string() }
fn default_health_method() -> String { "GET".to_string() }
fn default_health_port_name() -> String { "http".to_string() }
fn default_health_grace() -> u64 { 300 }
+10 -10
View File
@@ -156,7 +156,7 @@ impl ChainDriver for LuxDriver {
fn default_health(&self) -> HealthSpec {
HealthSpec {
path: "/ext/health".into(),
path: "/v1/health/liveness".into(),
method: "GET".into(),
port_name: "http".into(),
..Default::default()
@@ -374,7 +374,7 @@ impl ChainDriver for LuxDriver {
// Wait for health
s.push_str("echo \"[BOOTSTRAP] Waiting for node health...\"\ni=0\n");
s.push_str(&format!(
"while [ \"$i\" -lt 180 ]; do\n if curl -sf -m 2 http://127.0.0.1:{}/ext/health >/dev/null 2>&1; then\n echo \"[BOOTSTRAP] Node healthy after ${{i}}s\"; break\n fi\n i=$((i + 1)); sleep 1\ndone\n\n",
"while [ \"$i\" -lt 180 ]; do\n if curl -sf -m 2 http://127.0.0.1:{}/v1/health/liveness >/dev/null 2>&1; then\n echo \"[BOOTSTRAP] Node healthy after ${{i}}s\"; break\n fi\n i=$((i + 1)); sleep 1\ndone\n\n",
http_port
));
@@ -382,14 +382,14 @@ impl ChainDriver for LuxDriver {
if let Some(tracking) = &cfg.chain_tracking {
for alias in &tracking.aliases {
s.push_str(&format!(
"curl -s -m 5 -X POST -H 'Content-Type: application/json' --data '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"admin.aliasChain\",\"params\":{{\"chain\":\"C\",\"alias\":\"{}\"}}}}' http://127.0.0.1:{}/ext/admin >/dev/null 2>&1 || true\n",
"curl -s -m 5 -X POST -H 'Content-Type: application/json' --data '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"admin.aliasChain\",\"params\":{{\"chain\":\"C\",\"alias\":\"{}\"}}}}' http://127.0.0.1:{}/v1/admin >/dev/null 2>&1 || true\n",
alias, http_port
));
}
}
for chain_ref in &cfg.chains {
s.push_str(&format!(
"curl -s -m 5 -X POST -H 'Content-Type: application/json' --data '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"admin.aliasChain\",\"params\":{{\"chain\":\"{}\",\"alias\":\"{}\"}}}}' http://127.0.0.1:{}/ext/admin >/dev/null 2>&1 || true\n",
"curl -s -m 5 -X POST -H 'Content-Type: application/json' --data '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"admin.aliasChain\",\"params\":{{\"chain\":\"{}\",\"alias\":\"{}\"}}}}' http://127.0.0.1:{}/v1/admin >/dev/null 2>&1 || true\n",
chain_ref.blockchain_id, chain_ref.alias, http_port
));
}
@@ -408,7 +408,7 @@ impl ChainDriver for LuxDriver {
s.push_str(" echo \"[BOOTSTRAP] Seeded from snapshot, skipping RLP\"\n touch \"$MARKER\"\n");
s.push_str("elif [ ! -f \"$MARKER\" ]; then\n");
s.push_str(&format!(
" height_hex=$(curl -s -m 10 -X POST -H 'Content-Type: application/json' --data '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}}' http://127.0.0.1:{}/ext/bc/C/rpc | sed -n 's/.*\"result\":\"\\(0x[0-9A-Fa-f]*\\)\".*/\\1/p' | head -n1)\n",
" height_hex=$(curl -s -m 10 -X POST -H 'Content-Type: application/json' --data '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}}' http://127.0.0.1:{}/v1/bc/C/rpc | sed -n 's/.*\"result\":\"\\(0x[0-9A-Fa-f]*\\)\".*/\\1/p' | head -n1)\n",
http_port
));
s.push_str(" [ -z \"$height_hex\" ] && height_hex=\"0x0\"\n");
@@ -432,7 +432,7 @@ impl ChainDriver for LuxDriver {
s.push_str("\n if [ -s \"$RLP_FILE\" ]; then\n");
s.push_str(&format!(
" import_result=$(curl -s -m {} -X POST -H 'Content-Type: application/json' --data \"$(printf '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"admin_importChain\",\"params\":[\"%s\"]}}' \"$RLP_FILE\")\" http://127.0.0.1:{}/ext/bc/C/rpc || true)\n",
" import_result=$(curl -s -m {} -X POST -H 'Content-Type: application/json' --data \"$(printf '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"admin_importChain\",\"params\":[\"%s\"]}}' \"$RLP_FILE\")\" http://127.0.0.1:{}/v1/bc/C/rpc || true)\n",
rlp.timeout, http_port
));
s.push_str(" echo \"$import_result\" | grep -q '\"success\":true' && touch \"$MARKER\" || true\n");
@@ -576,7 +576,7 @@ impl ChainDriver for LuxDriver {
.map_err(|e| OperatorError::Reconcile(format!("HTTP client: {}", e)))?;
// Health check
let url = format!("http://{}:{}/ext/health", pod_ip, http_port);
let url = format!("http://{}:{}/v1/health", pod_ip, http_port);
let healthy = client.get(&url).send().await.map(|r| r.status().is_success()).unwrap_or(false);
// Bootstrap check
@@ -592,7 +592,7 @@ impl ChainDriver for LuxDriver {
// P-chain height
let p_height = {
let url = format!("http://{}:{}/ext/bc/P", pod_ip, http_port);
let url = format!("http://{}:{}/v1/bc/P", pod_ip, http_port);
client.post(&url)
.json(&serde_json::json!({"jsonrpc":"2.0","id":1,"method":"platform.getHeight","params":{}}))
.send().await.ok()
@@ -602,7 +602,7 @@ impl ChainDriver for LuxDriver {
// C-chain height
let c_height = {
let url = format!("http://{}:{}/ext/bc/C/rpc", pod_ip, http_port);
let url = format!("http://{}:{}/v1/bc/C/rpc", pod_ip, http_port);
client.post(&url)
.json(&serde_json::json!({"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}))
.send().await.ok()
@@ -615,7 +615,7 @@ impl ChainDriver for LuxDriver {
// Node ID + peers
let (node_id, connected, inbound, outbound) = {
let info_url = format!("http://{}:{}/ext/info", pod_ip, http_port);
let info_url = format!("http://{}:{}/v1/info", pod_ip, http_port);
let nid = client.post(&info_url)
.json(&serde_json::json!({"jsonrpc":"2.0","id":1,"method":"info.getNodeID","params":{}}))
.send().await.ok()