mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user