feat: add Lux validator fleet management system

Full-stack fleet lifecycle management for Lux blockchain validators
deployed via Helm to DOKS clusters.

Backend (Python/FastAPI):
- HelmDeployer: async helm/kubectl CLI wrapper with temp file cleanup,
  per-release locking, and command injection protection
- LuxFleetManager: fleet CRUD, scale, rolling restart, node health
  probes via luxd RPC (health + C-chain block height)
- Fleet API: authenticated REST endpoints (ApiKeyDep) with RFC 1123
  input validation, kubeconfig error sanitization
- Pydantic models: 3 networks (mainnet/testnet/devnet), fleet status
  enums, helm values translation
- 110 unit tests (0.30s, all passing)

Frontend (TypeScript/Next.js):
- Fleet dashboard: cluster selector, stats banner, fleet cards with
  scale/restart/destroy operations, node status table, create dialog
- Interactive SVG world map: zero-dep Mercator projection with DOKS
  region markers, pulsing animations, P2P mesh lines, click-to-zoom
- DOKS region data: 11 regions with coordinates and API client
- Dark theme throughout
This commit is contained in:
2026-02-10 13:40:28 -08:00
parent aeea477fb3
commit 8f618ba2dd
16 changed files with 4670 additions and 2 deletions
+2
View File
@@ -16,6 +16,7 @@ from bootnode.api.transfers import router as transfers_router
from bootnode.api.wallets import router as wallets_router
from bootnode.api.webhooks import router as webhooks_router
from bootnode.api.team import router as team_router
from bootnode.api.lux import router as lux_router
from bootnode.api.zap import router as zap_router
router = APIRouter()
@@ -36,3 +37,4 @@ router.include_router(gas_router, prefix="/gas", tags=["Gas Manager"])
router.include_router(zap_router, prefix="/zap", tags=["ZAP Protocol"])
router.include_router(infra_router, prefix="/infra", tags=["Infrastructure"])
router.include_router(team_router, prefix="/team", tags=["Team"])
router.include_router(lux_router, prefix="/lux", tags=["Lux Fleet"])
+261
View File
@@ -0,0 +1,261 @@
"""Lux Fleet API -- REST endpoints for managing Lux validator fleets.
Provides CRUD operations for Lux blockchain validator fleets deployed
via Helm charts to DOKS clusters.
"""
import logging
import re
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException
from bootnode.api.deps import ApiKeyDep
from bootnode.core.chains.lux import LuxFleetManager
from bootnode.core.chains.lux_models import (
LuxFleetCreate,
LuxFleetResponse,
LuxFleetStats,
LuxFleetStatus,
LuxFleetSummary,
LuxFleetUpdate,
LuxNetwork,
NETWORK_CONFIGS,
)
logger = logging.getLogger(__name__)
router = APIRouter()
# In-memory fleet registry (source of truth is Helm releases on clusters)
_fleets: dict[str, dict] = {}
# Singleton manager
_manager = LuxFleetManager()
# K8s name validation: RFC 1123 DNS label
_K8S_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$")
def _validate_k8s_name(name: str, field: str = "name") -> None:
"""Validate a string is a valid K8s name (RFC 1123 DNS label)."""
if not _K8S_NAME_RE.match(name):
raise HTTPException(400, f"Invalid {field}: must match RFC 1123 DNS label")
async def _get_kubeconfig(cluster_id: str) -> str:
"""Fetch kubeconfig for a DOKS cluster via the infra API."""
try:
from bootnode.api.infra import do_get_kubeconfig, get_do_client
client = get_do_client()
if not client:
raise HTTPException(500, "DigitalOcean client not configured")
return await do_get_kubeconfig(client, cluster_id)
except ImportError:
raise HTTPException(500, "Infrastructure module not available")
except HTTPException:
raise
except Exception:
logger.error("Failed to fetch kubeconfig for cluster %s", cluster_id)
raise HTTPException(502, "Failed to fetch kubeconfig for cluster")
# ---- Fleet CRUD ----
@router.post("/fleets", response_model=LuxFleetResponse)
async def create_fleet(
params: LuxFleetCreate,
background_tasks: BackgroundTasks,
_api_key: ApiKeyDep,
) -> LuxFleetResponse:
"""Create a new Lux validator fleet on a DOKS cluster."""
fleet_id = f"{params.name}-{params.network.value}"
if fleet_id in _fleets:
raise HTTPException(409, f"Fleet {fleet_id} already exists")
kubeconfig = await _get_kubeconfig(params.cluster_id)
result = await _manager.create(kubeconfig, params)
_fleets[fleet_id] = {
"name": params.name,
"cluster_id": params.cluster_id,
"network": params.network.value,
}
return result
@router.get("/fleets", response_model=list[LuxFleetSummary])
async def list_fleets(_api_key: ApiKeyDep, cluster_id: Optional[str] = None) -> list[LuxFleetSummary]:
"""List all Lux fleets, optionally filtered by cluster."""
if cluster_id:
kubeconfig = await _get_kubeconfig(cluster_id)
return await _manager.list_fleets(kubeconfig, cluster_id)
# List across all known clusters
all_fleets = []
cluster_ids = {f["cluster_id"] for f in _fleets.values()}
for cid in cluster_ids:
try:
kubeconfig = await _get_kubeconfig(cid)
fleets = await _manager.list_fleets(kubeconfig, cid)
all_fleets.extend(fleets)
except Exception as e:
logger.warning("Failed to list fleets on cluster %s: %s", cid, e)
return all_fleets
@router.get("/fleets/{cluster_id}/{network}", response_model=LuxFleetResponse)
async def get_fleet(cluster_id: str, network: LuxNetwork, _api_key: ApiKeyDep) -> LuxFleetResponse:
"""Get full status of a fleet including pods and endpoints."""
kubeconfig = await _get_kubeconfig(cluster_id)
# Find fleet name from registry
name = f"luxd-{network.value}"
for fid, fdata in _fleets.items():
if fdata["cluster_id"] == cluster_id and fdata["network"] == network.value:
name = fdata["name"]
break
return await _manager.get_status(kubeconfig, name, network, cluster_id)
@router.patch("/fleets/{cluster_id}/{network}", response_model=LuxFleetResponse)
async def update_fleet(
cluster_id: str,
network: LuxNetwork,
params: LuxFleetUpdate,
_api_key: ApiKeyDep,
) -> LuxFleetResponse:
"""Update a fleet (helm upgrade). Only provided fields are changed."""
kubeconfig = await _get_kubeconfig(cluster_id)
name = f"luxd-{network.value}"
for fid, fdata in _fleets.items():
if fdata["cluster_id"] == cluster_id and fdata["network"] == network.value:
name = fdata["name"]
break
return await _manager.update(kubeconfig, name, network, cluster_id, params)
@router.delete("/fleets/{cluster_id}/{network}")
async def destroy_fleet(cluster_id: str, network: LuxNetwork, _api_key: ApiKeyDep) -> dict:
"""Destroy a fleet (helm uninstall). PVCs are preserved."""
kubeconfig = await _get_kubeconfig(cluster_id)
success = await _manager.destroy(kubeconfig, network)
# Remove from registry
to_remove = [
fid for fid, fdata in _fleets.items()
if fdata["cluster_id"] == cluster_id and fdata["network"] == network.value
]
for fid in to_remove:
del _fleets[fid]
return {"success": success, "message": f"Fleet {network.value} destroyed on {cluster_id}"}
# ---- Fleet operations ----
@router.post("/fleets/{cluster_id}/{network}/scale", response_model=LuxFleetResponse)
async def scale_fleet(
cluster_id: str,
network: LuxNetwork,
replicas: int,
_api_key: ApiKeyDep,
) -> LuxFleetResponse:
"""Scale fleet to the specified replica count."""
if replicas < 1 or replicas > 20:
raise HTTPException(400, "Replicas must be between 1 and 20")
kubeconfig = await _get_kubeconfig(cluster_id)
name = f"luxd-{network.value}"
return await _manager.scale(kubeconfig, name, network, cluster_id, replicas)
@router.post("/fleets/{cluster_id}/{network}/restart")
async def restart_fleet(
cluster_id: str,
network: LuxNetwork,
background_tasks: BackgroundTasks,
_api_key: ApiKeyDep,
) -> dict:
"""Rolling restart all pods (one at a time, OnDelete strategy)."""
kubeconfig = await _get_kubeconfig(cluster_id)
async def _do_restart():
try:
restarted = await _manager.rolling_restart(kubeconfig, network)
logger.info("Rolling restart complete: %s", restarted)
except Exception as e:
logger.error("Rolling restart failed: %s", e)
background_tasks.add_task(_do_restart)
return {"message": f"Rolling restart initiated for {network.value}", "status": "started"}
@router.get("/fleets/{cluster_id}/{network}/pods/{pod_name}/logs")
async def get_pod_logs(
cluster_id: str,
network: LuxNetwork,
pod_name: str,
_api_key: ApiKeyDep,
tail: int = 100,
) -> dict:
"""Get logs from a specific pod."""
_validate_k8s_name(pod_name, "pod_name")
if tail < 1 or tail > 10000:
raise HTTPException(400, "tail must be between 1 and 10000")
kubeconfig = await _get_kubeconfig(cluster_id)
logs = await _manager.get_pod_logs(kubeconfig, network, pod_name, tail=tail)
return {"pod": pod_name, "lines": logs.splitlines()}
@router.get("/fleets/{cluster_id}/{network}/pods/{pod_name}/health")
async def get_pod_health(
cluster_id: str,
network: LuxNetwork,
pod_name: str,
_api_key: ApiKeyDep,
) -> dict:
"""Probe a luxd node's health via RPC."""
_validate_k8s_name(pod_name, "pod_name")
kubeconfig = await _get_kubeconfig(cluster_id)
return await _manager.probe_node_health(kubeconfig, network, pod_name)
# ---- Stats ----
@router.get("/stats", response_model=LuxFleetStats)
async def get_stats(_api_key: ApiKeyDep) -> LuxFleetStats:
"""Get aggregate fleet statistics."""
total_fleets = len(_fleets)
fleets_by_network: dict[str, int] = {}
for fdata in _fleets.values():
net = fdata["network"]
fleets_by_network[net] = fleets_by_network.get(net, 0) + 1
return LuxFleetStats(
total_fleets=total_fleets,
fleets_by_network=fleets_by_network,
)
# ---- Network info ----
@router.get("/networks")
async def list_networks() -> dict:
"""List available Lux networks and their configurations."""
return {
"networks": {
net.value: {
"network_id": cfg.network_id,
"chain_id": cfg.chain_id,
"http_port": cfg.http_port,
"staking_port": cfg.staking_port,
"namespace": cfg.namespace,
}
for net, cfg in NETWORK_CONFIGS.items()
}
}
+4
View File
@@ -126,6 +126,10 @@ class Settings(BaseSettings):
deploy_k8s_namespace: str = "bootnode"
deploy_k8s_context: str = ""
# Helm / Lux Fleet
helm_chart_path: str = "/opt/charts/lux"
helm_binary: str = "helm"
@property
def is_production(self) -> bool:
return self.app_env == "production"
+7 -1
View File
@@ -3,4 +3,10 @@
from bootnode.core.chains.registry import Chain, ChainRegistry, ChainType, Network
from bootnode.core.chains.rpc import RPCClient
__all__ = ["ChainRegistry", "Chain", "Network", "ChainType", "RPCClient"]
__all__ = [
"ChainRegistry",
"Chain",
"Network",
"ChainType",
"RPCClient",
]
+465
View File
@@ -0,0 +1,465 @@
"""Lux fleet manager -- business logic for validator fleet lifecycle.
Orchestrates Lux validator fleet deployments via HelmDeployer.
Each fleet operation:
1. Fetches kubeconfig for the target cluster
2. Creates a HelmDeployer pointed at that cluster
3. Runs the Helm operation
4. Probes individual pod health via luxd RPC
"""
import asyncio
import json
import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from bootnode.core.chains.lux_models import (
NETWORK_CONFIGS,
LuxFleetCreate,
LuxFleetResponse,
LuxFleetStatus,
LuxFleetSummary,
LuxFleetUpdate,
LuxNetwork,
LuxNodeInfo,
LuxNodeStatus,
fleet_create_to_helm_values,
fleet_update_to_helm_values,
)
from bootnode.core.deploy.helm import HelmDeployer, HelmError
logger = logging.getLogger(__name__)
# Chart path defaults to the devops repo checkout
DEFAULT_CHART_PATH = Path("/opt/charts/lux")
# Network-specific values files
NETWORK_VALUES_FILES: dict[str, str] = {
"mainnet": "values-mainnet.yaml",
"testnet": "values-testnet.yaml",
}
@dataclass
class LuxFleetManager:
"""Manages Lux validator fleet lifecycle via Helm."""
chart_path: Path = DEFAULT_CHART_PATH
def _release_name(self, network: LuxNetwork) -> str:
return f"luxd-{network.value}"
def _namespace(self, network: LuxNetwork) -> str:
return NETWORK_CONFIGS[network].namespace
def _fleet_id(self, name: str, network: LuxNetwork) -> str:
return f"{name}-{network.value}"
async def _get_deployer(self, kubeconfig_data: str) -> HelmDeployer:
"""Create a HelmDeployer from raw kubeconfig YAML.
The returned deployer's kubeconfig temp file is tracked for cleanup.
Call deployer.cleanup_temp_files() when done.
"""
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".kubeconfig", delete=False
)
tmp.write(kubeconfig_data)
tmp.flush()
tmp.close()
kc_path = Path(tmp.name)
deployer = HelmDeployer(
chart_path=self.chart_path,
kubeconfig_path=kc_path,
)
# Track kubeconfig path for cleanup alongside helm values files
if not hasattr(deployer, "_temp_files"):
deployer._temp_files = []
deployer._temp_files.append(kc_path)
return deployer
def _get_values_files(self, network: LuxNetwork) -> list[Path]:
"""Get the network-specific values file if one exists."""
filename = NETWORK_VALUES_FILES.get(network.value)
if filename:
full_path = self.chart_path / filename
if full_path.exists():
return [full_path]
return []
# ---- Fleet lifecycle ----
async def create(
self,
kubeconfig: str,
params: LuxFleetCreate,
) -> LuxFleetResponse:
"""Create a new Lux validator fleet."""
deployer = await self._get_deployer(kubeconfig)
try:
release_name = self._release_name(params.network)
namespace = self._namespace(params.network)
values = fleet_create_to_helm_values(params)
values_files = self._get_values_files(params.network)
logger.info(
"Creating Lux fleet: release=%s namespace=%s network=%s replicas=%d",
release_name, namespace, params.network.value, params.replicas,
)
try:
release = await deployer.install(
release_name=release_name,
namespace=namespace,
values=values,
values_files=values_files,
wait=False,
create_namespace=True,
)
status = LuxFleetStatus.DEPLOYING
except HelmError as e:
logger.error("Fleet creation failed: %s", e)
return LuxFleetResponse(
id=self._fleet_id(params.name, params.network),
name=params.name,
cluster_id=params.cluster_id,
network=params.network,
status=LuxFleetStatus.ERROR,
replicas=params.replicas,
namespace=namespace,
error=str(e),
)
return LuxFleetResponse(
id=self._fleet_id(params.name, params.network),
name=params.name,
cluster_id=params.cluster_id,
network=params.network,
status=status,
replicas=params.replicas,
namespace=namespace,
helm_revision=release.revision,
image=params.image if params.image else None,
)
finally:
deployer.cleanup_temp_files()
async def update(
self,
kubeconfig: str,
name: str,
network: LuxNetwork,
cluster_id: str,
params: LuxFleetUpdate,
) -> LuxFleetResponse:
"""Update an existing fleet (helm upgrade)."""
deployer = await self._get_deployer(kubeconfig)
try:
release_name = self._release_name(network)
namespace = self._namespace(network)
values = fleet_update_to_helm_values(params)
logger.info("Updating fleet: release=%s values=%s", release_name, values)
try:
release = await deployer.upgrade(
release_name=release_name,
namespace=namespace,
values=values,
reuse_values=True,
)
except HelmError as e:
logger.error("Fleet update failed: %s", e)
return LuxFleetResponse(
id=self._fleet_id(name, network),
name=name,
cluster_id=cluster_id,
network=network,
status=LuxFleetStatus.ERROR,
replicas=params.replicas or 0,
namespace=namespace,
error=str(e),
)
# Get pod status after upgrade
pods = await deployer.get_pods(namespace, "app=luxd")
ready_count = sum(1 for p in pods if p.ready)
total = len(pods)
return LuxFleetResponse(
id=self._fleet_id(name, network),
name=name,
cluster_id=cluster_id,
network=network,
status=LuxFleetStatus.UPDATING,
replicas=total,
ready_replicas=ready_count,
namespace=namespace,
helm_revision=release.revision,
)
finally:
deployer.cleanup_temp_files()
async def scale(
self,
kubeconfig: str,
name: str,
network: LuxNetwork,
cluster_id: str,
replicas: int,
) -> LuxFleetResponse:
"""Scale a fleet's replica count."""
return await self.update(
kubeconfig=kubeconfig,
name=name,
network=network,
cluster_id=cluster_id,
params=LuxFleetUpdate(replicas=replicas),
)
async def destroy(
self,
kubeconfig: str,
network: LuxNetwork,
) -> bool:
"""Destroy a fleet (helm uninstall). PVCs persist."""
deployer = await self._get_deployer(kubeconfig)
try:
release_name = self._release_name(network)
namespace = self._namespace(network)
return await deployer.uninstall(release_name, namespace)
finally:
deployer.cleanup_temp_files()
async def get_status(
self,
kubeconfig: str,
name: str,
network: LuxNetwork,
cluster_id: str,
) -> LuxFleetResponse:
"""Get full status of a fleet including pods and endpoints."""
deployer = await self._get_deployer(kubeconfig)
try:
release_name = self._release_name(network)
namespace = self._namespace(network)
net_cfg = NETWORK_CONFIGS[network]
# Get Helm release status
try:
release = await deployer.status(release_name, namespace)
except HelmError:
return LuxFleetResponse(
id=self._fleet_id(name, network),
name=name,
cluster_id=cluster_id,
network=network,
status=LuxFleetStatus.ERROR,
replicas=0,
namespace=namespace,
error="Helm release not found",
)
# Get pods
pods = await deployer.get_pods(namespace, "app=luxd")
ready_count = sum(1 for p in pods if p.ready)
total = len(pods)
# Get services for endpoint discovery
services = await deployer.get_services(namespace)
rpc_endpoint = None
staking_endpoint = None
for svc in services:
for ext_ip in svc.get("external_ips", []):
if ext_ip:
for port in svc.get("ports", []):
if port.get("port") == net_cfg.http_port:
rpc_endpoint = f"http://{ext_ip}:{net_cfg.http_port}"
elif port.get("port") == net_cfg.staking_port:
staking_endpoint = f"{ext_ip}:{net_cfg.staking_port}"
# Build node info
nodes = []
for i, pod in enumerate(sorted(pods, key=lambda p: p.name)):
node_status = LuxNodeStatus.HEALTHY if pod.ready else LuxNodeStatus.UNHEALTHY
if pod.status == "Pending":
node_status = LuxNodeStatus.PENDING
elif pod.status == "Init":
node_status = LuxNodeStatus.INIT
nodes.append(LuxNodeInfo(
pod_name=pod.name,
pod_index=i,
status=node_status,
http_port=net_cfg.http_port,
staking_port=net_cfg.staking_port,
))
# Determine fleet status
if total == 0:
fleet_status = LuxFleetStatus.DEPLOYING
elif ready_count == total:
fleet_status = LuxFleetStatus.RUNNING
elif ready_count > 0:
fleet_status = LuxFleetStatus.DEGRADED
else:
fleet_status = LuxFleetStatus.ERROR
return LuxFleetResponse(
id=self._fleet_id(name, network),
name=name,
cluster_id=cluster_id,
network=network,
status=fleet_status,
replicas=total,
ready_replicas=ready_count,
namespace=namespace,
helm_revision=release.revision,
rpc_endpoint=rpc_endpoint,
staking_endpoint=staking_endpoint,
nodes=nodes,
)
finally:
deployer.cleanup_temp_files()
async def list_fleets(
self,
kubeconfig: str,
cluster_id: str,
) -> list[LuxFleetSummary]:
"""List all Lux fleets on a cluster."""
deployer = await self._get_deployer(kubeconfig)
try:
releases = await deployer.list_releases(
all_namespaces=True,
filter_pattern="^luxd-",
)
fleets = []
for r in releases:
# Infer network from release name (luxd-mainnet -> mainnet)
network_str = r.name.removeprefix("luxd-")
try:
network = LuxNetwork(network_str)
except ValueError:
continue
pods = await deployer.get_pods(r.namespace, "app=luxd")
ready = sum(1 for p in pods if p.ready)
total = len(pods)
if total == 0:
status = LuxFleetStatus.DEPLOYING
elif ready == total:
status = LuxFleetStatus.RUNNING
elif ready > 0:
status = LuxFleetStatus.DEGRADED
else:
status = LuxFleetStatus.ERROR
fleets.append(LuxFleetSummary(
id=f"{cluster_id}:{r.name}",
name=r.name,
network=network,
status=status,
replicas=total,
ready_replicas=ready,
cluster_id=cluster_id,
created_at=r.updated or "",
))
return fleets
finally:
deployer.cleanup_temp_files()
async def get_pod_logs(
self,
kubeconfig: str,
network: LuxNetwork,
pod_name: str,
tail: int = 100,
) -> str:
"""Get logs from a specific pod."""
deployer = await self._get_deployer(kubeconfig)
try:
namespace = self._namespace(network)
return await deployer.get_pod_logs(pod_name, namespace, tail=tail)
finally:
deployer.cleanup_temp_files()
async def rolling_restart(
self,
kubeconfig: str,
network: LuxNetwork,
) -> list[str]:
"""Rolling restart: delete pods one at a time (OnDelete strategy)."""
deployer = await self._get_deployer(kubeconfig)
try:
namespace = self._namespace(network)
pods = await deployer.get_pods(namespace, "app=luxd")
restarted = []
for pod in sorted(pods, key=lambda p: p.name):
logger.info("Rolling restart: deleting %s", pod.name)
await deployer.delete_pod(pod.name, namespace)
restarted.append(pod.name)
# Wait for pod to come back ready (up to 5 min)
for _ in range(60):
await asyncio.sleep(5)
new_pods = await deployer.get_pods(namespace, "app=luxd")
replaced = next((p for p in new_pods if p.name == pod.name), None)
if replaced and replaced.ready:
break
return restarted
finally:
deployer.cleanup_temp_files()
async def probe_node_health(
self,
kubeconfig: str,
network: LuxNetwork,
pod_name: str,
) -> dict:
"""Probe a luxd node's health via kubectl exec + wget."""
deployer = await self._get_deployer(kubeconfig)
try:
namespace = self._namespace(network)
net_cfg = NETWORK_CONFIGS[network]
port = net_cfg.http_port
results: dict = {}
# Health check
try:
health = await deployer.exec_pod(
pod_name, namespace,
["wget", "-qO-", f"http://localhost:{port}/ext/health"],
)
results["health"] = json.loads(health)
results["healthy"] = results["health"].get("healthy", False)
except Exception:
results["healthy"] = False
# Block height
try:
rpc_resp = await deployer.exec_pod(
pod_name, namespace,
[
"wget", "-qO-",
"--header=Content-Type: application/json",
f"--post-data={{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"params\":[],\"id\":1}}",
f"http://localhost:{port}/ext/bc/C/rpc",
],
)
data = json.loads(rpc_resp)
results["c_chain_height"] = int(data.get("result", "0x0"), 16)
except Exception:
results["c_chain_height"] = None
return results
finally:
deployer.cleanup_temp_files()
+444
View File
@@ -0,0 +1,444 @@
"""Shared Pydantic models for Lux fleet management.
This module is the single source of truth for data structures shared between:
- HelmDeployer (core/deploy/helm.py)
- Lux Fleet API (api/lux/__init__.py)
- Lux Chain Manager (core/chains/lux.py)
- Dashboard (web/app/dashboard/infrastructure/lux/)
All four components import from here. No model duplication.
The models map 1:1 to the Helm chart values in lux/devops/charts/lux/values.yaml
so that API request bodies can be translated directly to helm --set flags or
values override files without lossy conversion.
"""
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, field_validator
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class LuxNetwork(str, Enum):
"""Lux network identifier. Maps to values.network in the Helm chart."""
MAINNET = "mainnet"
TESTNET = "testnet"
DEVNET = "devnet"
class LuxFleetStatus(str, Enum):
"""Lifecycle state of a fleet (Helm release)."""
PENDING = "pending" # Request accepted, not yet applied
DEPLOYING = "deploying" # helm install/upgrade in progress
RUNNING = "running" # All pods healthy
DEGRADED = "degraded" # Some pods not ready
UPDATING = "updating" # Rolling update in progress
ERROR = "error" # Helm or K8s error
DESTROYING = "destroying" # helm uninstall in progress
DESTROYED = "destroyed" # Release removed
class LuxNodeStatus(str, Enum):
"""Status of an individual luxd pod within a fleet."""
PENDING = "pending"
INIT = "init" # Init container running
STARTING = "starting" # luxd process starting
BOOTSTRAPPING = "bootstrapping" # RLP import / chain sync
HEALTHY = "healthy" # Health endpoint responding
UNHEALTHY = "unhealthy" # Failed liveness/readiness
TERMINATED = "terminated"
# ---------------------------------------------------------------------------
# Helm chart value sub-models
# These mirror the YAML structure in charts/lux/values.yaml exactly.
# ---------------------------------------------------------------------------
class LuxNetworkConfig(BaseModel):
"""Per-network constants. Read-only reference data, not user input."""
network_id: int
chain_id: int
http_port: int
staking_port: int
namespace: str
# Canonical network parameters. Must match values.yaml .networks.*
NETWORK_CONFIGS: dict[LuxNetwork, LuxNetworkConfig] = {
LuxNetwork.MAINNET: LuxNetworkConfig(
network_id=1,
chain_id=96369,
http_port=9630,
staking_port=9631,
namespace="lux-mainnet",
),
LuxNetwork.TESTNET: LuxNetworkConfig(
network_id=2,
chain_id=96368,
http_port=9640,
staking_port=9641,
namespace="lux-testnet",
),
LuxNetwork.DEVNET: LuxNetworkConfig(
network_id=3,
chain_id=96370,
http_port=9650,
staking_port=9651,
namespace="lux-devnet",
),
}
class RLPImportConfig(BaseModel):
"""C-chain RLP block import settings. Maps to values.bootstrap.rlpImport."""
enabled: bool = False
base_url: str = ""
rlp_filename: str = ""
multi_part: bool = False
parts: list[str] = Field(default_factory=list)
min_height: int = 1
timeout: int = 7200
class BootstrapConfig(BaseModel):
"""Bootstrap node configuration. Maps to values.bootstrap."""
node_ids: list[str] = Field(default_factory=list)
use_hostnames: bool = True
external_ips: list[str] = Field(default_factory=list)
rlp_import: RLPImportConfig = Field(default_factory=RLPImportConfig)
@field_validator("node_ids")
@classmethod
def validate_node_ids(cls, v: list[str]) -> list[str]:
for nid in v:
if not nid.startswith("NodeID-"):
raise ValueError(f"Invalid node ID format: {nid!r} (must start with 'NodeID-')")
return v
class ConsensusConfig(BaseModel):
"""Consensus tuning. Maps to values.consensus."""
sample_size: int = 5
quorum_size: int = 4
sybil_protection_enabled: bool = False
require_validator_to_connect: bool = False
allow_private_ips: bool = True
class ChainTrackingConfig(BaseModel):
"""Subnet/chain tracking. Maps to values.chainTracking."""
track_all_chains: bool = True
tracked_chains: list[str] = Field(default_factory=list)
aliases: list[str] = Field(
default_factory=lambda: ["zoo", "hanzo", "spc", "pars"],
)
class ImageConfig(BaseModel):
"""Container image reference. Maps to values.image."""
repository: str = "registry.digitalocean.com/hanzo/bootnode"
tag: str = "luxd-v1.23.11"
pull_policy: str = "Always"
class ResourceSpec(BaseModel):
"""K8s resource request/limit pair."""
memory: str = "1Gi"
cpu: str = "500m"
class ResourceConfig(BaseModel):
"""Maps to values.resources."""
requests: ResourceSpec = Field(default_factory=lambda: ResourceSpec(memory="1Gi", cpu="500m"))
limits: ResourceSpec = Field(default_factory=lambda: ResourceSpec(memory="4Gi", cpu="2"))
class StorageConfig(BaseModel):
"""Maps to values.storage."""
size: str = "100Gi"
storage_class: str = "do-block-storage"
class NodeServicesConfig(BaseModel):
"""Maps to values.nodeServices."""
enabled: bool = True
type: str = "LoadBalancer"
annotations: dict[str, str] = Field(default_factory=dict)
class APIConfig(BaseModel):
"""Maps to values.api."""
admin_enabled: bool = True
metrics_enabled: bool = True
index_enabled: bool = True
http_allowed_hosts: str = "*"
# ---------------------------------------------------------------------------
# API request/response models
# ---------------------------------------------------------------------------
class LuxFleetCreate(BaseModel):
"""Request body: create a new Lux fleet (Helm release).
Used by: POST /lux/fleets
Consumed by: HelmDeployer, LuxChainManager
"""
name: str = Field(
...,
min_length=1,
max_length=63,
pattern=r"^[a-z][a-z0-9-]*$",
description="Helm release name. Must be a valid K8s name.",
)
cluster_id: str = Field(
...,
description="Target K8s cluster ID (from /infra/clusters).",
)
network: LuxNetwork = LuxNetwork.DEVNET
replicas: int = Field(default=5, ge=1, le=20)
# Optional overrides -- None means use chart defaults
image: Optional[ImageConfig] = None
bootstrap: Optional[BootstrapConfig] = None
consensus: Optional[ConsensusConfig] = None
chain_tracking: Optional[ChainTrackingConfig] = None
resources: Optional[ResourceConfig] = None
storage: Optional[StorageConfig] = None
node_services: Optional[NodeServicesConfig] = None
api: Optional[APIConfig] = None
log_level: str = "info"
db_type: str = "badgerdb"
@field_validator("replicas")
@classmethod
def quorum_safety(cls, v: int) -> int:
"""Warn-level: consensus requires >= 4 for BFT quorum of 5."""
# Validation only; warning handled at the API layer.
return v
class LuxFleetUpdate(BaseModel):
"""Request body: update (helm upgrade) an existing fleet.
Used by: PATCH /lux/fleets/{fleet_id}
Only non-None fields are applied as overrides.
"""
replicas: Optional[int] = Field(default=None, ge=1, le=20)
image: Optional[ImageConfig] = None
consensus: Optional[ConsensusConfig] = None
chain_tracking: Optional[ChainTrackingConfig] = None
resources: Optional[ResourceConfig] = None
log_level: Optional[str] = None
class LuxNodeInfo(BaseModel):
"""Status of a single luxd pod within a fleet.
Populated by LuxChainManager from K8s pod status + RPC probes.
"""
pod_name: str
pod_index: int
node_id: Optional[str] = None
status: LuxNodeStatus = LuxNodeStatus.PENDING
external_ip: Optional[str] = None
http_port: int = 9650
staking_port: int = 9651
# RPC-derived metrics (populated lazily)
is_bootstrapped: Optional[bool] = None
peers: Optional[int] = None
c_chain_height: Optional[int] = None
p_chain_height: Optional[int] = None
uptime_pct: Optional[float] = None
# K8s resource usage
cpu_usage: Optional[float] = None
memory_usage_mb: Optional[float] = None
class LuxFleetResponse(BaseModel):
"""Response body: full fleet state.
Used by: GET /lux/fleets/{fleet_id}, POST /lux/fleets
"""
id: str = Field(description="Fleet ID (= Helm release name + network).")
name: str
cluster_id: str
network: LuxNetwork
status: LuxFleetStatus = LuxFleetStatus.PENDING
replicas: int
ready_replicas: int = 0
image: ImageConfig = Field(default_factory=ImageConfig)
namespace: str = ""
helm_revision: int = 0
rpc_endpoint: Optional[str] = None
staking_endpoint: Optional[str] = None
nodes: list[LuxNodeInfo] = Field(default_factory=list)
created_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
updated_at: Optional[str] = None
error: Optional[str] = None
class LuxFleetSummary(BaseModel):
"""Lightweight fleet summary for list endpoints.
Used by: GET /lux/fleets
"""
id: str
name: str
network: LuxNetwork
status: LuxFleetStatus
replicas: int
ready_replicas: int = 0
cluster_id: str
created_at: str
class LuxFleetStats(BaseModel):
"""Aggregate fleet statistics.
Used by: GET /lux/stats
"""
total_fleets: int = 0
total_nodes: int = 0
healthy_nodes: int = 0
fleets_by_network: dict[str, int] = Field(default_factory=dict)
fleets_by_status: dict[str, int] = Field(default_factory=dict)
# ---------------------------------------------------------------------------
# Helm values translation
# ---------------------------------------------------------------------------
def fleet_create_to_helm_values(req: LuxFleetCreate) -> dict:
"""Convert a LuxFleetCreate request into a flat dict suitable for
helm install --set or a values override YAML file.
This is the canonical translation layer. HelmDeployer calls this;
no other component should build Helm values dicts directly.
"""
vals: dict = {
"network": req.network.value,
"replicas": req.replicas,
"logLevel": req.log_level,
"dbType": req.db_type,
}
if req.image:
vals["image.repository"] = req.image.repository
vals["image.tag"] = req.image.tag
vals["image.pullPolicy"] = req.image.pull_policy
if req.bootstrap:
b = req.bootstrap
vals["bootstrap.useHostnames"] = b.use_hostnames
if b.node_ids:
# Helm list: pass as JSON or comma-separated via --set-json
vals["bootstrap.nodeIDs"] = b.node_ids
if b.external_ips:
vals["bootstrap.externalIPs"] = b.external_ips
r = b.rlp_import
vals["bootstrap.rlpImport.enabled"] = r.enabled
vals["bootstrap.rlpImport.baseUrl"] = r.base_url
vals["bootstrap.rlpImport.rlpFilename"] = r.rlp_filename
vals["bootstrap.rlpImport.multiPart"] = r.multi_part
if r.parts:
vals["bootstrap.rlpImport.parts"] = r.parts
vals["bootstrap.rlpImport.minHeight"] = r.min_height
vals["bootstrap.rlpImport.timeout"] = r.timeout
if req.consensus:
c = req.consensus
vals["consensus.sampleSize"] = c.sample_size
vals["consensus.quorumSize"] = c.quorum_size
vals["consensus.sybilProtectionEnabled"] = c.sybil_protection_enabled
vals["consensus.requireValidatorToConnect"] = c.require_validator_to_connect
vals["consensus.allowPrivateIPs"] = c.allow_private_ips
if req.chain_tracking:
ct = req.chain_tracking
vals["chainTracking.trackAllChains"] = ct.track_all_chains
if ct.tracked_chains:
vals["chainTracking.trackedChains"] = ct.tracked_chains
if ct.aliases:
vals["chainTracking.aliases"] = ct.aliases
if req.resources:
vals["resources.requests.memory"] = req.resources.requests.memory
vals["resources.requests.cpu"] = req.resources.requests.cpu
vals["resources.limits.memory"] = req.resources.limits.memory
vals["resources.limits.cpu"] = req.resources.limits.cpu
if req.storage:
vals["storage.size"] = req.storage.size
vals["storage.storageClass"] = req.storage.storage_class
if req.node_services:
vals["nodeServices.enabled"] = req.node_services.enabled
vals["nodeServices.type"] = req.node_services.type
if req.api:
vals["api.adminEnabled"] = req.api.admin_enabled
vals["api.metricsEnabled"] = req.api.metrics_enabled
vals["api.indexEnabled"] = req.api.index_enabled
vals["api.httpAllowedHosts"] = req.api.http_allowed_hosts
return vals
def fleet_update_to_helm_values(req: LuxFleetUpdate) -> dict:
"""Convert a LuxFleetUpdate into a partial Helm values dict.
Only non-None fields are included.
"""
vals: dict = {}
if req.replicas is not None:
vals["replicas"] = req.replicas
if req.log_level is not None:
vals["logLevel"] = req.log_level
if req.image:
vals["image.repository"] = req.image.repository
vals["image.tag"] = req.image.tag
vals["image.pullPolicy"] = req.image.pull_policy
if req.consensus:
c = req.consensus
vals["consensus.sampleSize"] = c.sample_size
vals["consensus.quorumSize"] = c.quorum_size
vals["consensus.sybilProtectionEnabled"] = c.sybil_protection_enabled
if req.chain_tracking:
ct = req.chain_tracking
vals["chainTracking.trackAllChains"] = ct.track_all_chains
if ct.tracked_chains:
vals["chainTracking.trackedChains"] = ct.tracked_chains
if req.resources:
vals["resources.requests.memory"] = req.resources.requests.memory
vals["resources.requests.cpu"] = req.resources.requests.cpu
vals["resources.limits.memory"] = req.resources.limits.memory
vals["resources.limits.cpu"] = req.resources.limits.cpu
return vals
+2
View File
@@ -21,6 +21,7 @@ from bootnode.core.deploy.base import DeployTarget, ServiceStatus, ServiceType
from bootnode.core.deploy.docker import DockerDeployer
from bootnode.core.deploy.factory import clear_deployer_cache, get_deployer
from bootnode.core.deploy.kubernetes import KubernetesDeployer
from bootnode.core.deploy.helm import HelmDeployer
from bootnode.core.deploy.process import ProcessDeployer
__all__ = [
@@ -30,6 +31,7 @@ __all__ = [
"ServiceType",
# Deployers
"DockerDeployer",
"HelmDeployer",
"KubernetesDeployer",
"ProcessDeployer",
# Factory
+446
View File
@@ -0,0 +1,446 @@
"""Helm CLI deployer for Lux validator fleets.
Wraps the helm and kubectl CLIs via asyncio subprocess for deploying
Helm releases to remote DOKS clusters. NOT a DeployTarget subclass --
different interface (Helm release-oriented, not ServiceType-oriented).
"""
import asyncio
import json
import logging
import tempfile
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
import yaml
logger = logging.getLogger(__name__)
class HelmReleaseStatus(str, Enum):
DEPLOYED = "deployed"
FAILED = "failed"
PENDING_INSTALL = "pending-install"
PENDING_UPGRADE = "pending-upgrade"
PENDING_ROLLBACK = "pending-rollback"
SUPERSEDED = "superseded"
UNINSTALLED = "uninstalled"
UNINSTALLING = "uninstalling"
UNKNOWN = "unknown"
class HelmError(Exception):
"""Raised when a helm/kubectl command fails."""
def __init__(self, message: str, stderr: str = "", returncode: int = 1):
super().__init__(message)
self.stderr = stderr
self.returncode = returncode
@dataclass
class HelmRelease:
name: str
namespace: str
revision: int
status: HelmReleaseStatus
chart: str = ""
app_version: str = ""
updated: str = ""
@dataclass
class PodInfo:
name: str
ready: bool
status: str
restarts: int = 0
node: str = ""
ip: str = ""
age: str = ""
@dataclass
class HelmDeployer:
"""Deploys Helm charts to K8s clusters via CLI.
Each instance is bound to a specific kubeconfig (cluster).
Create per-request via LuxFleetManager._get_deployer().
"""
chart_path: Path
kubeconfig_path: Optional[Path] = None
kube_context: Optional[str] = None
helm_binary: str = "helm"
kubectl_binary: str = "kubectl"
_locks: dict = field(default_factory=dict, repr=False)
def _get_lock(self, release_name: str) -> asyncio.Lock:
if release_name not in self._locks:
self._locks[release_name] = asyncio.Lock()
return self._locks[release_name]
def _base_helm_args(self) -> list[str]:
args = [self.helm_binary]
if self.kubeconfig_path:
args += ["--kubeconfig", str(self.kubeconfig_path)]
if self.kube_context:
args += ["--kube-context", self.kube_context]
return args
def _base_kubectl_args(self) -> list[str]:
args = [self.kubectl_binary]
if self.kubeconfig_path:
args += ["--kubeconfig", str(self.kubeconfig_path)]
if self.kube_context:
args += ["--context", self.kube_context]
return args
async def _run(self, args: list[str], timeout: int = 300) -> str:
logger.debug("Running: %s", " ".join(args))
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
except asyncio.TimeoutError:
proc.kill()
raise HelmError(f"Command timed out after {timeout}s: {' '.join(args[:3])}")
out = stdout.decode().strip()
err = stderr.decode().strip()
if proc.returncode != 0:
raise HelmError(
f"Command failed (rc={proc.returncode}): {err or out}",
stderr=err,
returncode=proc.returncode,
)
return out
def _write_values_file(self, values: dict) -> Path:
"""Write a values dict to a temp YAML file for helm -f.
Temp files are tracked and cleaned up via cleanup_temp_files().
"""
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", prefix="helm-values-", delete=False
)
yaml.safe_dump(values, tmp, default_flow_style=False)
tmp.flush()
tmp.close()
path = Path(tmp.name)
if not hasattr(self, "_temp_files"):
self._temp_files: list[Path] = []
self._temp_files.append(path)
return path
def cleanup_temp_files(self) -> None:
"""Remove all temp files created by this deployer."""
for f in getattr(self, "_temp_files", []):
try:
f.unlink(missing_ok=True)
except OSError:
pass
self._temp_files = []
# ---- Helm operations ----
async def install(
self,
release_name: str,
namespace: str,
values: dict | None = None,
values_files: list[Path] | None = None,
wait: bool = False,
create_namespace: bool = True,
timeout: int = 600,
) -> HelmRelease:
"""helm install."""
async with self._get_lock(release_name):
args = self._base_helm_args() + [
"upgrade", "--install", release_name, str(self.chart_path),
"--namespace", namespace,
"--output", "json",
]
if create_namespace:
args.append("--create-namespace")
if wait:
args += ["--wait", "--timeout", f"{timeout}s"]
# Network-specific values files first (lower priority)
for vf in (values_files or []):
args += ["-f", str(vf)]
# Computed values override (highest priority)
if values:
vf_path = self._write_values_file(values)
args += ["-f", str(vf_path)]
out = await self._run(args, timeout=timeout + 30)
try:
data = json.loads(out)
info = data.get("info", {})
return HelmRelease(
name=release_name,
namespace=namespace,
revision=data.get("version", 1),
status=HelmReleaseStatus(info.get("status", "unknown")),
chart=data.get("chart", {}).get("metadata", {}).get("name", ""),
app_version=data.get("chart", {}).get("metadata", {}).get("appVersion", ""),
updated=info.get("last_deployed", ""),
)
except (json.JSONDecodeError, KeyError):
# Fallback: command succeeded but output wasn't JSON
return HelmRelease(
name=release_name,
namespace=namespace,
revision=1,
status=HelmReleaseStatus.DEPLOYED,
)
async def upgrade(
self,
release_name: str,
namespace: str,
values: dict | None = None,
values_files: list[Path] | None = None,
reuse_values: bool = True,
wait: bool = False,
timeout: int = 600,
) -> HelmRelease:
"""helm upgrade (existing release)."""
async with self._get_lock(release_name):
args = self._base_helm_args() + [
"upgrade", release_name, str(self.chart_path),
"--namespace", namespace,
"--output", "json",
]
if reuse_values:
args.append("--reuse-values")
if wait:
args += ["--wait", "--timeout", f"{timeout}s"]
for vf in (values_files or []):
args += ["-f", str(vf)]
if values:
vf_path = self._write_values_file(values)
args += ["-f", str(vf_path)]
out = await self._run(args, timeout=timeout + 30)
try:
data = json.loads(out)
info = data.get("info", {})
return HelmRelease(
name=release_name,
namespace=namespace,
revision=data.get("version", 1),
status=HelmReleaseStatus(info.get("status", "unknown")),
updated=info.get("last_deployed", ""),
)
except (json.JSONDecodeError, KeyError):
return HelmRelease(
name=release_name,
namespace=namespace,
revision=1,
status=HelmReleaseStatus.DEPLOYED,
)
async def uninstall(self, release_name: str, namespace: str) -> bool:
"""helm uninstall."""
async with self._get_lock(release_name):
args = self._base_helm_args() + [
"uninstall", release_name,
"--namespace", namespace,
]
try:
await self._run(args)
return True
except HelmError as e:
if "not found" in e.stderr.lower():
return True
raise
async def rollback(
self, release_name: str, namespace: str, revision: int = 0
) -> HelmRelease:
"""helm rollback (0 = previous revision)."""
async with self._get_lock(release_name):
args = self._base_helm_args() + [
"rollback", release_name, str(revision),
"--namespace", namespace,
]
await self._run(args)
return await self.status(release_name, namespace)
async def status(self, release_name: str, namespace: str) -> HelmRelease:
"""helm status."""
args = self._base_helm_args() + [
"status", release_name,
"--namespace", namespace,
"--output", "json",
]
out = await self._run(args)
data = json.loads(out)
info = data.get("info", {})
return HelmRelease(
name=data.get("name", release_name),
namespace=namespace,
revision=data.get("version", 0),
status=HelmReleaseStatus(info.get("status", "unknown")),
updated=info.get("last_deployed", ""),
)
async def list_releases(
self,
namespace: str = "",
all_namespaces: bool = False,
filter_pattern: str = "",
) -> list[HelmRelease]:
"""helm list."""
args = self._base_helm_args() + ["list", "--output", "json"]
if all_namespaces:
args.append("--all-namespaces")
elif namespace:
args += ["--namespace", namespace]
if filter_pattern:
args += ["--filter", filter_pattern]
out = await self._run(args)
releases = []
for r in json.loads(out or "[]"):
releases.append(HelmRelease(
name=r.get("name", ""),
namespace=r.get("namespace", ""),
revision=int(r.get("revision", "0")),
status=HelmReleaseStatus(r.get("status", "unknown")),
chart=r.get("chart", ""),
app_version=r.get("app_version", ""),
updated=r.get("updated", ""),
))
return releases
# ---- Kubectl operations ----
async def get_pods(
self, namespace: str, label_selector: str = ""
) -> list[PodInfo]:
"""kubectl get pods."""
args = self._base_kubectl_args() + [
"get", "pods",
"--namespace", namespace,
"--output", "json",
]
if label_selector:
args += ["-l", label_selector]
out = await self._run(args)
data = json.loads(out)
pods = []
for item in data.get("items", []):
meta = item.get("metadata", {})
status = item.get("status", {})
conditions = {c["type"]: c["status"] for c in status.get("conditions", [])}
container_statuses = status.get("containerStatuses", [])
restarts = sum(cs.get("restartCount", 0) for cs in container_statuses)
pods.append(PodInfo(
name=meta.get("name", ""),
ready=conditions.get("Ready") == "True",
status=status.get("phase", "Unknown"),
restarts=restarts,
node=item.get("spec", {}).get("nodeName", ""),
ip=status.get("podIP", ""),
))
return pods
async def get_services(self, namespace: str, label_selector: str = "") -> list[dict]:
"""kubectl get services, returns raw dicts."""
args = self._base_kubectl_args() + [
"get", "services",
"--namespace", namespace,
"--output", "json",
]
if label_selector:
args += ["-l", label_selector]
out = await self._run(args)
data = json.loads(out)
services = []
for item in data.get("items", []):
meta = item.get("metadata", {})
spec = item.get("spec", {})
lb_status = item.get("status", {}).get("loadBalancer", {})
external_ips = [
ing.get("ip", ing.get("hostname", ""))
for ing in lb_status.get("ingress", [])
]
services.append({
"name": meta.get("name", ""),
"type": spec.get("type", ""),
"cluster_ip": spec.get("clusterIP", ""),
"external_ips": external_ips,
"ports": [
{"name": p.get("name", ""), "port": p.get("port"), "target_port": p.get("targetPort")}
for p in spec.get("ports", [])
],
})
return services
async def get_pod_logs(
self,
pod_name: str,
namespace: str,
tail: int = 100,
container: str = "",
) -> str:
"""kubectl logs."""
args = self._base_kubectl_args() + [
"logs", pod_name,
"--namespace", namespace,
f"--tail={tail}",
]
if container:
args += ["-c", container]
return await self._run(args, timeout=30)
async def delete_pod(self, pod_name: str, namespace: str) -> bool:
"""kubectl delete pod (for OnDelete rolling update)."""
args = self._base_kubectl_args() + [
"delete", "pod", pod_name,
"--namespace", namespace,
]
try:
await self._run(args)
return True
except HelmError:
return False
_K8S_NAME_RE = __import__("re").compile(r"^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$")
async def exec_pod(
self, pod_name: str, namespace: str, command: list[str], timeout: int = 30
) -> str:
"""kubectl exec for running commands in a pod.
Validates pod_name and namespace to prevent injection.
"""
if not self._K8S_NAME_RE.match(pod_name):
raise ValueError(f"Invalid pod name: {pod_name}")
if not self._K8S_NAME_RE.match(namespace):
raise ValueError(f"Invalid namespace: {namespace}")
args = self._base_kubectl_args() + [
"exec", pod_name,
"--namespace", namespace,
"--",
] + command
return await self._run(args, timeout=timeout)
+906
View File
@@ -0,0 +1,906 @@
"""Comprehensive tests for Lux fleet management.
Covers:
- lux_models.py: Pydantic models, validators, enums, helm value translation
- helm.py: HelmDeployer internals (mocked subprocess)
- lux.py: LuxFleetManager helper methods (mocked HelmDeployer)
"""
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import yaml
from bootnode.core.chains.lux_models import (
APIConfig,
BootstrapConfig,
ChainTrackingConfig,
ConsensusConfig,
ImageConfig,
LuxFleetCreate,
LuxFleetStatus,
LuxFleetUpdate,
LuxNetwork,
LuxNetworkConfig,
LuxNodeStatus,
NETWORK_CONFIGS,
NodeServicesConfig,
RLPImportConfig,
ResourceConfig,
ResourceSpec,
StorageConfig,
fleet_create_to_helm_values,
fleet_update_to_helm_values,
)
from bootnode.core.deploy.helm import (
HelmDeployer,
HelmError,
HelmRelease,
HelmReleaseStatus,
PodInfo,
)
from bootnode.core.chains.lux import (
LuxFleetManager,
NETWORK_VALUES_FILES,
)
# =========================================================================
# 1. lux_models.py -- pure unit tests
# =========================================================================
class TestLuxNetworkEnum:
"""LuxNetwork enum values."""
def test_values(self):
assert LuxNetwork.MAINNET.value == "mainnet"
assert LuxNetwork.TESTNET.value == "testnet"
assert LuxNetwork.DEVNET.value == "devnet"
def test_from_string(self):
assert LuxNetwork("mainnet") == LuxNetwork.MAINNET
assert LuxNetwork("testnet") == LuxNetwork.TESTNET
assert LuxNetwork("devnet") == LuxNetwork.DEVNET
def test_member_count(self):
assert len(LuxNetwork) == 3
class TestLuxFleetStatusEnum:
"""LuxFleetStatus enum values."""
def test_all_values(self):
expected = {
"pending", "deploying", "running", "degraded",
"updating", "error", "destroying", "destroyed",
}
actual = {s.value for s in LuxFleetStatus}
assert actual == expected
def test_member_count(self):
assert len(LuxFleetStatus) == 8
class TestLuxNodeStatusEnum:
"""LuxNodeStatus enum values."""
def test_all_values(self):
expected = {
"pending", "init", "starting", "bootstrapping",
"healthy", "unhealthy", "terminated",
}
actual = {s.value for s in LuxNodeStatus}
assert actual == expected
def test_member_count(self):
assert len(LuxNodeStatus) == 7
class TestNetworkConfigs:
"""NETWORK_CONFIGS canonical reference data."""
def test_all_networks_present(self):
assert set(NETWORK_CONFIGS.keys()) == {
LuxNetwork.MAINNET,
LuxNetwork.TESTNET,
LuxNetwork.DEVNET,
}
def test_mainnet_config(self):
cfg = NETWORK_CONFIGS[LuxNetwork.MAINNET]
assert cfg.network_id == 1
assert cfg.chain_id == 96369
assert cfg.http_port == 9630
assert cfg.staking_port == 9631
assert cfg.namespace == "lux-mainnet"
def test_testnet_config(self):
cfg = NETWORK_CONFIGS[LuxNetwork.TESTNET]
assert cfg.network_id == 2
assert cfg.chain_id == 96368
assert cfg.http_port == 9640
assert cfg.staking_port == 9641
assert cfg.namespace == "lux-testnet"
def test_devnet_config(self):
cfg = NETWORK_CONFIGS[LuxNetwork.DEVNET]
assert cfg.network_id == 3
assert cfg.chain_id == 96370
assert cfg.http_port == 9650
assert cfg.staking_port == 9651
assert cfg.namespace == "lux-devnet"
def test_unique_network_ids(self):
ids = [c.network_id for c in NETWORK_CONFIGS.values()]
assert len(ids) == len(set(ids))
def test_unique_chain_ids(self):
ids = [c.chain_id for c in NETWORK_CONFIGS.values()]
assert len(ids) == len(set(ids))
def test_unique_namespaces(self):
ns = [c.namespace for c in NETWORK_CONFIGS.values()]
assert len(ns) == len(set(ns))
class TestBootstrapConfig:
"""BootstrapConfig validator tests."""
def test_valid_node_ids(self):
cfg = BootstrapConfig(node_ids=["NodeID-abc123", "NodeID-xyz789"])
assert len(cfg.node_ids) == 2
def test_empty_node_ids(self):
cfg = BootstrapConfig(node_ids=[])
assert cfg.node_ids == []
def test_invalid_node_id_format(self):
with pytest.raises(ValueError, match="Invalid node ID format"):
BootstrapConfig(node_ids=["bad-id"])
def test_invalid_node_id_mixed(self):
"""One valid + one invalid should fail."""
with pytest.raises(ValueError, match="Invalid node ID format"):
BootstrapConfig(node_ids=["NodeID-good", "missing-prefix"])
def test_defaults(self):
cfg = BootstrapConfig()
assert cfg.node_ids == []
assert cfg.use_hostnames is True
assert cfg.external_ips == []
assert cfg.rlp_import.enabled is False
class TestLuxFleetCreate:
"""LuxFleetCreate validation tests."""
def _valid_params(self, **overrides):
defaults = {"name": "my-fleet", "cluster_id": "cluster-abc"}
defaults.update(overrides)
return defaults
def test_valid_minimal(self):
req = LuxFleetCreate(**self._valid_params())
assert req.name == "my-fleet"
assert req.cluster_id == "cluster-abc"
assert req.network == LuxNetwork.DEVNET
assert req.replicas == 5
def test_valid_name_lowercase_with_hyphens(self):
req = LuxFleetCreate(**self._valid_params(name="fleet-01"))
assert req.name == "fleet-01"
def test_valid_name_single_char(self):
req = LuxFleetCreate(**self._valid_params(name="a"))
assert req.name == "a"
def test_invalid_name_uppercase(self):
with pytest.raises(ValueError):
LuxFleetCreate(**self._valid_params(name="MyFleet"))
def test_invalid_name_starts_with_digit(self):
with pytest.raises(ValueError):
LuxFleetCreate(**self._valid_params(name="1fleet"))
def test_invalid_name_starts_with_hyphen(self):
with pytest.raises(ValueError):
LuxFleetCreate(**self._valid_params(name="-fleet"))
def test_invalid_name_underscore(self):
with pytest.raises(ValueError):
LuxFleetCreate(**self._valid_params(name="my_fleet"))
def test_invalid_name_empty(self):
with pytest.raises(ValueError):
LuxFleetCreate(**self._valid_params(name=""))
def test_invalid_name_too_long(self):
with pytest.raises(ValueError):
LuxFleetCreate(**self._valid_params(name="a" * 64))
def test_name_max_length_63(self):
req = LuxFleetCreate(**self._valid_params(name="a" * 63))
assert len(req.name) == 63
def test_replicas_default(self):
req = LuxFleetCreate(**self._valid_params())
assert req.replicas == 5
def test_replicas_min_1(self):
req = LuxFleetCreate(**self._valid_params(replicas=1))
assert req.replicas == 1
def test_replicas_max_20(self):
req = LuxFleetCreate(**self._valid_params(replicas=20))
assert req.replicas == 20
def test_replicas_below_min(self):
with pytest.raises(ValueError):
LuxFleetCreate(**self._valid_params(replicas=0))
def test_replicas_above_max(self):
with pytest.raises(ValueError):
LuxFleetCreate(**self._valid_params(replicas=21))
def test_all_networks_accepted(self):
for net in LuxNetwork:
req = LuxFleetCreate(**self._valid_params(network=net))
assert req.network == net
def test_optional_overrides_default_none(self):
req = LuxFleetCreate(**self._valid_params())
assert req.image is None
assert req.bootstrap is None
assert req.consensus is None
assert req.chain_tracking is None
assert req.resources is None
assert req.storage is None
assert req.node_services is None
assert req.api is None
def test_log_level_default(self):
req = LuxFleetCreate(**self._valid_params())
assert req.log_level == "info"
def test_db_type_default(self):
req = LuxFleetCreate(**self._valid_params())
assert req.db_type == "badgerdb"
class TestLuxFleetUpdate:
"""LuxFleetUpdate partial update tests."""
def test_all_none_by_default(self):
req = LuxFleetUpdate()
assert req.replicas is None
assert req.image is None
assert req.consensus is None
assert req.chain_tracking is None
assert req.resources is None
assert req.log_level is None
def test_replicas_bounds(self):
req = LuxFleetUpdate(replicas=10)
assert req.replicas == 10
def test_replicas_min(self):
req = LuxFleetUpdate(replicas=1)
assert req.replicas == 1
def test_replicas_max(self):
req = LuxFleetUpdate(replicas=20)
assert req.replicas == 20
def test_replicas_below_min(self):
with pytest.raises(ValueError):
LuxFleetUpdate(replicas=0)
def test_replicas_above_max(self):
with pytest.raises(ValueError):
LuxFleetUpdate(replicas=21)
class TestFleetCreateToHelmValues:
"""fleet_create_to_helm_values() translation tests."""
def _minimal_req(self, **overrides):
defaults = {"name": "test", "cluster_id": "c1"}
defaults.update(overrides)
return LuxFleetCreate(**defaults)
def test_minimal_devnet(self):
req = self._minimal_req()
vals = fleet_create_to_helm_values(req)
assert vals["network"] == "devnet"
assert vals["replicas"] == 5
assert vals["logLevel"] == "info"
assert vals["dbType"] == "badgerdb"
def test_mainnet(self):
req = self._minimal_req(network=LuxNetwork.MAINNET)
vals = fleet_create_to_helm_values(req)
assert vals["network"] == "mainnet"
def test_testnet(self):
req = self._minimal_req(network=LuxNetwork.TESTNET)
vals = fleet_create_to_helm_values(req)
assert vals["network"] == "testnet"
def test_custom_replicas(self):
req = self._minimal_req(replicas=10)
vals = fleet_create_to_helm_values(req)
assert vals["replicas"] == 10
def test_no_image_keys_when_none(self):
req = self._minimal_req()
vals = fleet_create_to_helm_values(req)
assert "image.repository" not in vals
assert "image.tag" not in vals
def test_image_override(self):
req = self._minimal_req(image=ImageConfig(
repository="ghcr.io/test/luxd",
tag="v2.0.0",
pull_policy="IfNotPresent",
))
vals = fleet_create_to_helm_values(req)
assert vals["image.repository"] == "ghcr.io/test/luxd"
assert vals["image.tag"] == "v2.0.0"
assert vals["image.pullPolicy"] == "IfNotPresent"
def test_bootstrap_with_node_ids(self):
req = self._minimal_req(bootstrap=BootstrapConfig(
node_ids=["NodeID-aaa", "NodeID-bbb"],
use_hostnames=False,
))
vals = fleet_create_to_helm_values(req)
assert vals["bootstrap.useHostnames"] is False
assert vals["bootstrap.nodeIDs"] == ["NodeID-aaa", "NodeID-bbb"]
def test_bootstrap_rlp_import(self):
req = self._minimal_req(bootstrap=BootstrapConfig(
rlp_import=RLPImportConfig(
enabled=True,
base_url="https://example.com",
rlp_filename="chain.rlp",
timeout=3600,
),
))
vals = fleet_create_to_helm_values(req)
assert vals["bootstrap.rlpImport.enabled"] is True
assert vals["bootstrap.rlpImport.baseUrl"] == "https://example.com"
assert vals["bootstrap.rlpImport.rlpFilename"] == "chain.rlp"
assert vals["bootstrap.rlpImport.timeout"] == 3600
def test_consensus_override(self):
req = self._minimal_req(consensus=ConsensusConfig(
sample_size=10,
quorum_size=8,
sybil_protection_enabled=True,
))
vals = fleet_create_to_helm_values(req)
assert vals["consensus.sampleSize"] == 10
assert vals["consensus.quorumSize"] == 8
assert vals["consensus.sybilProtectionEnabled"] is True
def test_chain_tracking_override(self):
req = self._minimal_req(chain_tracking=ChainTrackingConfig(
track_all_chains=False,
tracked_chains=["X", "P", "C"],
aliases=["zoo"],
))
vals = fleet_create_to_helm_values(req)
assert vals["chainTracking.trackAllChains"] is False
assert vals["chainTracking.trackedChains"] == ["X", "P", "C"]
assert vals["chainTracking.aliases"] == ["zoo"]
def test_resources_override(self):
req = self._minimal_req(resources=ResourceConfig(
requests=ResourceSpec(memory="2Gi", cpu="1"),
limits=ResourceSpec(memory="8Gi", cpu="4"),
))
vals = fleet_create_to_helm_values(req)
assert vals["resources.requests.memory"] == "2Gi"
assert vals["resources.requests.cpu"] == "1"
assert vals["resources.limits.memory"] == "8Gi"
assert vals["resources.limits.cpu"] == "4"
def test_storage_override(self):
req = self._minimal_req(storage=StorageConfig(
size="500Gi",
storage_class="premium-rwo",
))
vals = fleet_create_to_helm_values(req)
assert vals["storage.size"] == "500Gi"
assert vals["storage.storageClass"] == "premium-rwo"
def test_node_services_override(self):
req = self._minimal_req(node_services=NodeServicesConfig(
enabled=False,
type="ClusterIP",
))
vals = fleet_create_to_helm_values(req)
assert vals["nodeServices.enabled"] is False
assert vals["nodeServices.type"] == "ClusterIP"
def test_api_override(self):
req = self._minimal_req(api=APIConfig(
admin_enabled=False,
metrics_enabled=True,
index_enabled=False,
http_allowed_hosts="127.0.0.1",
))
vals = fleet_create_to_helm_values(req)
assert vals["api.adminEnabled"] is False
assert vals["api.metricsEnabled"] is True
assert vals["api.indexEnabled"] is False
assert vals["api.httpAllowedHosts"] == "127.0.0.1"
def test_all_three_networks_produce_correct_network_key(self):
for net in LuxNetwork:
req = self._minimal_req(network=net)
vals = fleet_create_to_helm_values(req)
assert vals["network"] == net.value
class TestFleetUpdateToHelmValues:
"""fleet_update_to_helm_values() partial translation tests."""
def test_empty_update_produces_empty_dict(self):
req = LuxFleetUpdate()
vals = fleet_update_to_helm_values(req)
assert vals == {}
def test_replicas_only(self):
req = LuxFleetUpdate(replicas=3)
vals = fleet_update_to_helm_values(req)
assert vals == {"replicas": 3}
def test_log_level_only(self):
req = LuxFleetUpdate(log_level="debug")
vals = fleet_update_to_helm_values(req)
assert vals == {"logLevel": "debug"}
def test_image_only(self):
req = LuxFleetUpdate(image=ImageConfig(
repository="test", tag="v1", pull_policy="Never",
))
vals = fleet_update_to_helm_values(req)
assert vals["image.repository"] == "test"
assert vals["image.tag"] == "v1"
assert vals["image.pullPolicy"] == "Never"
assert "replicas" not in vals
assert "logLevel" not in vals
def test_consensus_only(self):
req = LuxFleetUpdate(consensus=ConsensusConfig(
sample_size=7,
quorum_size=5,
sybil_protection_enabled=True,
))
vals = fleet_update_to_helm_values(req)
assert vals["consensus.sampleSize"] == 7
assert vals["consensus.quorumSize"] == 5
assert vals["consensus.sybilProtectionEnabled"] is True
assert "replicas" not in vals
def test_resources_only(self):
req = LuxFleetUpdate(resources=ResourceConfig())
vals = fleet_update_to_helm_values(req)
assert "resources.requests.memory" in vals
assert "resources.limits.cpu" in vals
assert "replicas" not in vals
def test_chain_tracking_only(self):
req = LuxFleetUpdate(chain_tracking=ChainTrackingConfig(
track_all_chains=True,
))
vals = fleet_update_to_helm_values(req)
assert vals["chainTracking.trackAllChains"] is True
def test_multiple_fields(self):
req = LuxFleetUpdate(replicas=8, log_level="warn")
vals = fleet_update_to_helm_values(req)
assert vals["replicas"] == 8
assert vals["logLevel"] == "warn"
assert len(vals) == 2
# =========================================================================
# 2. helm.py -- HelmDeployer tests (mock subprocess)
# =========================================================================
class TestHelmReleaseStatusEnum:
"""HelmReleaseStatus enum values."""
def test_all_values(self):
expected = {
"deployed", "failed", "pending-install", "pending-upgrade",
"pending-rollback", "superseded", "uninstalled", "uninstalling",
"unknown",
}
actual = {s.value for s in HelmReleaseStatus}
assert actual == expected
def test_member_count(self):
assert len(HelmReleaseStatus) == 9
class TestHelmRelease:
"""HelmRelease dataclass creation."""
def test_basic_creation(self):
r = HelmRelease(
name="luxd-devnet",
namespace="lux-devnet",
revision=3,
status=HelmReleaseStatus.DEPLOYED,
)
assert r.name == "luxd-devnet"
assert r.namespace == "lux-devnet"
assert r.revision == 3
assert r.status == HelmReleaseStatus.DEPLOYED
assert r.chart == ""
assert r.app_version == ""
assert r.updated == ""
def test_full_creation(self):
r = HelmRelease(
name="luxd-mainnet",
namespace="lux-mainnet",
revision=7,
status=HelmReleaseStatus.DEPLOYED,
chart="lux-0.1.0",
app_version="1.23.11",
updated="2026-02-10T00:00:00Z",
)
assert r.chart == "lux-0.1.0"
assert r.app_version == "1.23.11"
assert r.updated == "2026-02-10T00:00:00Z"
class TestHelmError:
"""HelmError exception."""
def test_basic(self):
e = HelmError("deploy failed")
assert str(e) == "deploy failed"
assert e.stderr == ""
assert e.returncode == 1
def test_with_details(self):
e = HelmError("timeout", stderr="connection refused", returncode=2)
assert e.stderr == "connection refused"
assert e.returncode == 2
class TestPodInfo:
"""PodInfo dataclass."""
def test_creation(self):
p = PodInfo(name="luxd-0", ready=True, status="Running")
assert p.name == "luxd-0"
assert p.ready is True
assert p.restarts == 0
assert p.node == ""
assert p.ip == ""
class TestHelmDeployerBaseArgs:
"""HelmDeployer._base_helm_args() tests."""
def test_no_kubeconfig(self):
d = HelmDeployer(chart_path=Path("/charts/lux"))
args = d._base_helm_args()
assert args == ["helm"]
def test_with_kubeconfig(self):
d = HelmDeployer(
chart_path=Path("/charts/lux"),
kubeconfig_path=Path("/tmp/kube.conf"),
)
args = d._base_helm_args()
assert args == ["helm", "--kubeconfig", "/tmp/kube.conf"]
def test_with_context(self):
d = HelmDeployer(
chart_path=Path("/charts/lux"),
kube_context="my-ctx",
)
args = d._base_helm_args()
assert args == ["helm", "--kube-context", "my-ctx"]
def test_with_kubeconfig_and_context(self):
d = HelmDeployer(
chart_path=Path("/charts/lux"),
kubeconfig_path=Path("/tmp/kc"),
kube_context="ctx",
)
args = d._base_helm_args()
assert args == [
"helm",
"--kubeconfig", "/tmp/kc",
"--kube-context", "ctx",
]
def test_custom_binary(self):
d = HelmDeployer(
chart_path=Path("/charts/lux"),
helm_binary="/usr/local/bin/helm3",
)
args = d._base_helm_args()
assert args[0] == "/usr/local/bin/helm3"
class TestHelmDeployerBaseKubectlArgs:
"""HelmDeployer._base_kubectl_args() tests."""
def test_no_config(self):
d = HelmDeployer(chart_path=Path("/charts/lux"))
args = d._base_kubectl_args()
assert args == ["kubectl"]
def test_with_kubeconfig(self):
d = HelmDeployer(
chart_path=Path("/charts/lux"),
kubeconfig_path=Path("/tmp/kc"),
)
args = d._base_kubectl_args()
assert args == ["kubectl", "--kubeconfig", "/tmp/kc"]
def test_with_context(self):
d = HelmDeployer(
chart_path=Path("/charts/lux"),
kube_context="ctx",
)
args = d._base_kubectl_args()
# kubectl uses --context not --kube-context
assert args == ["kubectl", "--context", "ctx"]
class TestHelmDeployerWriteValues:
"""HelmDeployer._write_values_file() tests."""
def test_creates_valid_yaml(self):
d = HelmDeployer(chart_path=Path("/charts/lux"))
values = {
"network": "devnet",
"replicas": 5,
"logLevel": "info",
"nested": {"key": "value"},
}
path = d._write_values_file(values)
try:
assert path.exists()
assert path.suffix == ".yaml"
assert "helm-values-" in path.name
content = yaml.safe_load(path.read_text())
assert content["network"] == "devnet"
assert content["replicas"] == 5
assert content["logLevel"] == "info"
assert content["nested"]["key"] == "value"
finally:
path.unlink(missing_ok=True)
def test_empty_dict(self):
d = HelmDeployer(chart_path=Path("/charts/lux"))
path = d._write_values_file({})
try:
assert path.exists()
content = yaml.safe_load(path.read_text())
# yaml.safe_load returns None for empty doc
assert content is None or content == {}
finally:
path.unlink(missing_ok=True)
def test_list_values(self):
d = HelmDeployer(chart_path=Path("/charts/lux"))
values = {"nodeIDs": ["NodeID-a", "NodeID-b"]}
path = d._write_values_file(values)
try:
content = yaml.safe_load(path.read_text())
assert content["nodeIDs"] == ["NodeID-a", "NodeID-b"]
finally:
path.unlink(missing_ok=True)
class TestHelmDeployerLock:
"""HelmDeployer._get_lock() tests."""
def test_same_release_returns_same_lock(self):
d = HelmDeployer(chart_path=Path("/charts/lux"))
lock1 = d._get_lock("luxd-devnet")
lock2 = d._get_lock("luxd-devnet")
assert lock1 is lock2
def test_different_releases_return_different_locks(self):
d = HelmDeployer(chart_path=Path("/charts/lux"))
lock1 = d._get_lock("luxd-devnet")
lock2 = d._get_lock("luxd-mainnet")
assert lock1 is not lock2
# =========================================================================
# 3. lux.py -- LuxFleetManager tests (mock HelmDeployer)
# =========================================================================
class TestLuxFleetManagerReleaseName:
"""LuxFleetManager._release_name() tests."""
def test_mainnet(self):
mgr = LuxFleetManager()
assert mgr._release_name(LuxNetwork.MAINNET) == "luxd-mainnet"
def test_testnet(self):
mgr = LuxFleetManager()
assert mgr._release_name(LuxNetwork.TESTNET) == "luxd-testnet"
def test_devnet(self):
mgr = LuxFleetManager()
assert mgr._release_name(LuxNetwork.DEVNET) == "luxd-devnet"
class TestLuxFleetManagerNamespace:
"""LuxFleetManager._namespace() tests."""
def test_mainnet(self):
mgr = LuxFleetManager()
assert mgr._namespace(LuxNetwork.MAINNET) == "lux-mainnet"
def test_testnet(self):
mgr = LuxFleetManager()
assert mgr._namespace(LuxNetwork.TESTNET) == "lux-testnet"
def test_devnet(self):
mgr = LuxFleetManager()
assert mgr._namespace(LuxNetwork.DEVNET) == "lux-devnet"
class TestLuxFleetManagerFleetId:
"""LuxFleetManager._fleet_id() tests."""
def test_format(self):
mgr = LuxFleetManager()
assert mgr._fleet_id("my-fleet", LuxNetwork.DEVNET) == "my-fleet-devnet"
assert mgr._fleet_id("prod", LuxNetwork.MAINNET) == "prod-mainnet"
class TestLuxFleetManagerGetValuesFiles:
"""LuxFleetManager._get_values_files() tests."""
def test_mainnet_returns_file_if_exists(self, tmp_path):
chart_dir = tmp_path / "charts" / "lux"
chart_dir.mkdir(parents=True)
values_file = chart_dir / "values-mainnet.yaml"
values_file.write_text("network: mainnet\n")
mgr = LuxFleetManager(chart_path=chart_dir)
files = mgr._get_values_files(LuxNetwork.MAINNET)
assert len(files) == 1
assert files[0] == values_file
def test_testnet_returns_file_if_exists(self, tmp_path):
chart_dir = tmp_path / "charts" / "lux"
chart_dir.mkdir(parents=True)
values_file = chart_dir / "values-testnet.yaml"
values_file.write_text("network: testnet\n")
mgr = LuxFleetManager(chart_path=chart_dir)
files = mgr._get_values_files(LuxNetwork.TESTNET)
assert len(files) == 1
assert files[0] == values_file
def test_devnet_returns_empty_list(self, tmp_path):
"""Devnet has no network-specific values file in NETWORK_VALUES_FILES."""
chart_dir = tmp_path / "charts" / "lux"
chart_dir.mkdir(parents=True)
mgr = LuxFleetManager(chart_path=chart_dir)
files = mgr._get_values_files(LuxNetwork.DEVNET)
assert files == []
def test_mainnet_returns_empty_if_file_missing(self, tmp_path):
chart_dir = tmp_path / "charts" / "lux"
chart_dir.mkdir(parents=True)
# Do not create the values-mainnet.yaml file
mgr = LuxFleetManager(chart_path=chart_dir)
files = mgr._get_values_files(LuxNetwork.MAINNET)
assert files == []
class TestNetworkValuesFiles:
"""NETWORK_VALUES_FILES constant."""
def test_mainnet_has_values_file(self):
assert "mainnet" in NETWORK_VALUES_FILES
assert NETWORK_VALUES_FILES["mainnet"] == "values-mainnet.yaml"
def test_testnet_has_values_file(self):
assert "testnet" in NETWORK_VALUES_FILES
assert NETWORK_VALUES_FILES["testnet"] == "values-testnet.yaml"
def test_devnet_has_no_values_file(self):
assert "devnet" not in NETWORK_VALUES_FILES
class TestLuxFleetManagerGetDeployer:
"""LuxFleetManager._get_deployer() creates HelmDeployer from kubeconfig."""
async def test_creates_deployer_with_kubeconfig(self):
mgr = LuxFleetManager(chart_path=Path("/opt/charts/lux"))
kubeconfig_yaml = "apiVersion: v1\nkind: Config\nclusters: []\n"
deployer = await mgr._get_deployer(kubeconfig_yaml)
assert isinstance(deployer, HelmDeployer)
assert deployer.chart_path == Path("/opt/charts/lux")
assert deployer.kubeconfig_path is not None
assert deployer.kubeconfig_path.exists()
content = deployer.kubeconfig_path.read_text()
assert "apiVersion: v1" in content
# Cleanup
deployer.kubeconfig_path.unlink(missing_ok=True)
# =========================================================================
# Sub-model default value tests
# =========================================================================
class TestSubModelDefaults:
"""Verify defaults of sub-models match chart expectations."""
def test_rlp_import_defaults(self):
r = RLPImportConfig()
assert r.enabled is False
assert r.base_url == ""
assert r.rlp_filename == ""
assert r.multi_part is False
assert r.parts == []
assert r.min_height == 1
assert r.timeout == 7200
def test_consensus_defaults(self):
c = ConsensusConfig()
assert c.sample_size == 5
assert c.quorum_size == 4
assert c.sybil_protection_enabled is False
assert c.require_validator_to_connect is False
assert c.allow_private_ips is True
def test_chain_tracking_defaults(self):
ct = ChainTrackingConfig()
assert ct.track_all_chains is True
assert ct.tracked_chains == []
assert ct.aliases == ["zoo", "hanzo", "spc", "pars"]
def test_image_defaults(self):
img = ImageConfig()
assert img.repository == "registry.digitalocean.com/hanzo/bootnode"
assert img.tag == "luxd-v1.23.11"
assert img.pull_policy == "Always"
def test_resource_defaults(self):
r = ResourceConfig()
assert r.requests.memory == "1Gi"
assert r.requests.cpu == "500m"
assert r.limits.memory == "4Gi"
assert r.limits.cpu == "2"
def test_storage_defaults(self):
s = StorageConfig()
assert s.size == "100Gi"
assert s.storage_class == "do-block-storage"
def test_node_services_defaults(self):
ns = NodeServicesConfig()
assert ns.enabled is True
assert ns.type == "LoadBalancer"
assert ns.annotations == {}
def test_api_defaults(self):
a = APIConfig()
assert a.admin_enabled is True
assert a.metrics_enabled is True
assert a.index_enabled is True
assert a.http_allowed_hosts == "*"
@@ -0,0 +1,232 @@
"use client"
import { useState, useMemo } from "react"
import { cn } from "@/lib/utils"
import { FleetMap, type FleetRegion } from "@/components/lux/fleet-map"
import { DOKS_REGIONS, type LuxFleet } from "@/components/lux/region-data"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Globe, Plus, RefreshCw, Zap } from "lucide-react"
/** Convert LuxFleet[] to FleetRegion[] for the map component */
function fleetsToRegions(fleets: LuxFleet[]): FleetRegion[] {
// Group fleets by region
const byRegion = new Map<string, LuxFleet[]>()
for (const f of fleets) {
const existing = byRegion.get(f.region) ?? []
existing.push(f)
byRegion.set(f.region, existing)
}
const seenCoords = new Set<string>()
const regions: FleetRegion[] = []
// Active regions
for (const [regionId, regionFleets] of byRegion) {
const r = DOKS_REGIONS[regionId]
if (!r) continue
const nodeCount = regionFleets.reduce((s, f) => s + f.validators, 0)
const healthPcts = regionFleets.filter((f) => f.healthPct != null).map((f) => f.healthPct!)
const avgHealth = healthPcts.length > 0 ? healthPcts.reduce((s, v) => s + v, 0) / healthPcts.length : 100
const healthyNodes = Math.round((avgHealth / 100) * nodeCount)
const networks = regionFleets.some((f) => f.name.includes("testnet"))
? ["mainnet", "testnet"]
: ["mainnet"]
seenCoords.add(`${r.lat},${r.lng}`)
regions.push({
slug: regionId,
name: r.name,
lat: r.lat,
lon: r.lng,
active: true,
nodeCount,
networks,
healthyNodes,
})
}
// Inactive regions
for (const [regionId, r] of Object.entries(DOKS_REGIONS)) {
const key = `${r.lat},${r.lng}`
if (!seenCoords.has(key)) {
seenCoords.add(key)
regions.push({
slug: regionId,
name: r.name,
lat: r.lat,
lon: r.lng,
active: false,
nodeCount: 0,
networks: [],
healthyNodes: 0,
})
}
}
return regions
}
// Demo fleets for development -- replaced by live API in production
const DEMO_FLEETS: LuxFleet[] = [
{ id: "f1", name: "lux-mainnet-us-east", region: "nyc1", status: "running", validators: 12, blockHeight: 8_421_003, healthPct: 99.8 },
{ id: "f2", name: "lux-mainnet-us-west", region: "sfo3", status: "running", validators: 8, blockHeight: 8_420_998, healthPct: 99.5 },
{ id: "f3", name: "lux-mainnet-eu", region: "fra1", status: "running", validators: 10, blockHeight: 8_421_001, healthPct: 100 },
{ id: "f4", name: "lux-mainnet-london", region: "lon1", status: "running", validators: 6, blockHeight: 8_421_000, healthPct: 98.2 },
{ id: "f5", name: "lux-mainnet-apac", region: "sgp1", status: "deploying", validators: 4, blockHeight: 8_418_200, healthPct: 72 },
{ id: "f6", name: "lux-testnet-toronto", region: "tor1", status: "running", validators: 3, blockHeight: 2_100_450, healthPct: 100 },
{ id: "f7", name: "lux-mainnet-sydney", region: "syd1", status: "degraded", validators: 2, blockHeight: 8_419_500, healthPct: 45 },
]
export default function LuxFleetMapPage() {
const [useLive, setUseLive] = useState(false)
const [demoFleets, setDemoFleets] = useState(DEMO_FLEETS)
const [selectedRegion, setSelectedRegion] = useState<string | null>(null)
// Available regions not yet running a fleet
const availableRegions = useMemo(() => {
const activeRegions = new Set(demoFleets.map((f) => f.region))
return Object.entries(DOKS_REGIONS)
.filter(([id]) => !activeRegions.has(id))
.map(([id, r]) => ({ id, ...r }))
}, [demoFleets])
function addRandomFleet() {
if (availableRegions.length === 0) return
const target = availableRegions[Math.floor(Math.random() * availableRegions.length)]
const newFleet: LuxFleet = {
id: `f${Date.now()}`,
name: `lux-fleet-${target.id}`,
region: target.id,
status: "deploying",
validators: Math.floor(Math.random() * 8) + 2,
blockHeight: 8_000_000 + Math.floor(Math.random() * 500_000),
healthPct: 60 + Math.random() * 30,
}
setDemoFleets((prev) => [...prev, newFleet])
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Globe className="h-6 w-6" />
Lux Fleet Map
</h1>
<p className="text-sm text-muted-foreground mt-1">
Real-time geolocated validator nodes across DOKS regions
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setUseLive((v) => !v)}
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
{useLive ? "Demo Mode" : "Live Mode"}
</Button>
{!useLive && (
<Button
variant="outline"
size="sm"
onClick={addRandomFleet}
disabled={availableRegions.length === 0}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add Fleet
</Button>
)}
</div>
</div>
{/* Map */}
<FleetMap
regions={fleetsToRegions(useLive ? [] : demoFleets)}
onRegionClick={setSelectedRegion}
/>
{/* Active Fleets Table */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Zap className="h-4 w-4" />
Active Fleets
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{(useLive ? [] : demoFleets).map((fleet) => (
<div
key={fleet.id}
className={cn(
"flex items-center justify-between rounded-lg border p-3 text-sm transition-colors",
selectedRegion === fleet.region && "border-green-500/30 bg-green-500/5"
)}
>
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs font-medium">{fleet.name}</span>
<span className="text-[11px] text-muted-foreground">
{DOKS_REGIONS[fleet.region]?.name ?? fleet.region}
</span>
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-xs tabular-nums">{fleet.validators}v</span>
<Badge
variant={
fleet.status === "running" ? "success" :
fleet.status === "deploying" ? "warning" :
fleet.status === "degraded" ? "destructive" :
"secondary"
}
className="text-[10px] px-1.5"
>
{fleet.status}
</Badge>
</div>
</div>
))}
{useLive && (
<p className="text-xs text-muted-foreground text-center py-4">
Polling /v1/lux/fleets every 10s...
</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Available Regions</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2">
{availableRegions.map((r) => (
<div
key={r.id}
className={cn(
"flex items-center justify-between rounded-lg border px-3 py-2 text-xs transition-colors cursor-pointer hover:border-gray-400",
selectedRegion === r.id && "border-blue-500/30 bg-blue-500/5"
)}
onClick={() => setSelectedRegion(r.id)}
>
<span className="font-mono">{r.id}</span>
<span className="text-muted-foreground">{r.country}</span>
</div>
))}
{availableRegions.length === 0 && (
<p className="col-span-2 text-xs text-muted-foreground text-center py-4">
All regions have active fleets
</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
)
}
@@ -0,0 +1,958 @@
"use client"
import React, { useState, useEffect, useCallback } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Select } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Plus,
Loader2,
RefreshCcw,
Trash2,
RotateCcw,
Scale,
Shield,
Globe,
Heart,
ChevronDown,
ChevronRight,
Circle,
AlertTriangle,
} from "lucide-react"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type FleetStatus = "running" | "degraded" | "error" | "deploying" | "stopped"
type NetworkType = "mainnet" | "testnet" | "devnet"
interface FleetNode {
pod_name: string
status: string
ready: boolean
external_ip: string | null
c_chain_height: number | null
peers: number | null
node_id: string | null
healthy: boolean | null
}
interface LuxFleetSummary {
cluster_id: string
network: NetworkType
name: string
status: FleetStatus
replicas: number
ready_replicas: number
helm_revision: number
image_tag: string
created_at: string
}
interface LuxFleetResponse extends LuxFleetSummary {
nodes: FleetNode[]
}
interface LuxFleetStats {
total_fleets: number
total_nodes: number
healthy_nodes: number
}
interface Cluster {
id: string
name: string
region: string
}
interface NetworkConfig {
network: NetworkType
chain_id: string
default_image: string
}
// ---------------------------------------------------------------------------
// Constants & helpers
// ---------------------------------------------------------------------------
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
function getAuthHeaders(): Record<string, string> {
if (typeof window === "undefined") return {}
const token = localStorage.getItem("bootnode_token")
const apiKey = localStorage.getItem("bootnode_api_key")
if (token) return { Authorization: `Bearer ${token}` }
if (apiKey) return { "X-API-Key": apiKey }
return {}
}
const STATUS_COLORS: Record<FleetStatus, string> = {
running: "bg-green-500/15 text-green-500 border-green-500/30",
degraded: "bg-yellow-500/15 text-yellow-500 border-yellow-500/30",
error: "bg-red-500/15 text-red-500 border-red-500/30",
deploying: "bg-blue-500/15 text-blue-500 border-blue-500/30",
stopped: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
}
const NETWORK_COLORS: Record<NetworkType, string> = {
mainnet: "border-green-500/50 text-green-400",
testnet: "border-blue-500/50 text-blue-400",
devnet: "border-orange-500/50 text-orange-400",
}
function statusBadge(status: FleetStatus) {
return (
<Badge variant="outline" className={`${STATUS_COLORS[status]} capitalize`}>
{status === "running" && <Circle className="mr-1 h-2 w-2 fill-green-500" />}
{status === "degraded" && <AlertTriangle className="mr-1 h-3 w-3" />}
{status === "error" && <Circle className="mr-1 h-2 w-2 fill-red-500" />}
{status === "deploying" && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{status}
</Badge>
)
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function LuxFleetsPage() {
// Data
const [clusters, setClusters] = useState<Cluster[]>([])
const [selectedCluster, setSelectedCluster] = useState<string>("")
const [fleets, setFleets] = useState<LuxFleetSummary[]>([])
const [fleetDetails, setFleetDetails] = useState<Record<string, LuxFleetResponse>>({})
const [stats, setStats] = useState<LuxFleetStats | null>(null)
const [networks, setNetworks] = useState<NetworkConfig[]>([])
// UI
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [expandedFleet, setExpandedFleet] = useState<string | null>(null)
// Create dialog
const [createOpen, setCreateOpen] = useState(false)
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [formName, setFormName] = useState("")
const [formNetwork, setFormNetwork] = useState<NetworkType>("testnet")
const [formReplicas, setFormReplicas] = useState(5)
const [formImageTag, setFormImageTag] = useState("luxd-v1.23.11")
const [formChainTracking, setFormChainTracking] = useState(true)
// Confirm dialogs
const [confirmRestart, setConfirmRestart] = useState<string | null>(null)
const [confirmDestroy, setConfirmDestroy] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState<string | null>(null)
// Scale dropdown
const [scaleTarget, setScaleTarget] = useState<string | null>(null)
const [scaleValue, setScaleValue] = useState(5)
// ------- API helpers ------------------------------------------------------
async function apiFetch(path: string, opts?: RequestInit) {
const res = await fetch(`${API_URL}${path}`, {
...opts,
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
...(opts?.headers || {}),
},
})
if (!res.ok) {
const body = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(body.detail || `HTTP ${res.status}`)
}
if (res.status === 204) return null
return res.json()
}
// ------- Fetch data -------------------------------------------------------
const fetchClusters = useCallback(async () => {
try {
const data = await apiFetch("/v1/infra/clusters")
const list: Cluster[] = Array.isArray(data) ? data : []
setClusters(list)
if (list.length > 0 && !selectedCluster) {
setSelectedCluster(list[0].id)
}
} catch {
// best-effort
}
}, [selectedCluster])
const fetchFleets = useCallback(
async (silent = false) => {
if (!selectedCluster) return
if (!silent) setLoading(true)
try {
const data = await apiFetch(`/v1/lux/fleets?cluster_id=${selectedCluster}`)
setFleets(Array.isArray(data) ? data : [])
setError(null)
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
setRefreshing(false)
}
},
[selectedCluster],
)
const fetchStats = useCallback(async () => {
try {
const data = await apiFetch("/v1/lux/stats")
setStats(data)
} catch {
// best-effort
}
}, [])
const fetchNetworks = useCallback(async () => {
try {
const data = await apiFetch("/v1/lux/networks")
if (Array.isArray(data)) setNetworks(data)
} catch {
// best-effort
}
}, [])
const fetchFleetDetail = useCallback(
async (network: NetworkType) => {
if (!selectedCluster) return
try {
const data: LuxFleetResponse = await apiFetch(
`/v1/lux/fleets/${selectedCluster}/${network}`,
)
setFleetDetails((prev) => ({ ...prev, [`${selectedCluster}/${network}`]: data }))
} catch {
// best-effort
}
},
[selectedCluster],
)
// ------- Initial load & polling -------------------------------------------
useEffect(() => {
fetchClusters()
fetchStats()
fetchNetworks()
}, [fetchClusters, fetchStats, fetchNetworks])
useEffect(() => {
if (selectedCluster) {
fetchFleets()
}
}, [selectedCluster, fetchFleets])
useEffect(() => {
if (!selectedCluster) return
const interval = setInterval(() => {
fetchFleets(true)
fetchStats()
}, 15000)
return () => clearInterval(interval)
}, [selectedCluster, fetchFleets, fetchStats])
// Fetch node details when a fleet is expanded
useEffect(() => {
if (expandedFleet && selectedCluster) {
const network = expandedFleet as NetworkType
fetchFleetDetail(network)
}
}, [expandedFleet, selectedCluster, fetchFleetDetail])
// ------- Fleet operations -------------------------------------------------
async function handleCreate() {
if (!formName.trim() || !selectedCluster) return
setCreating(true)
setCreateError(null)
try {
await apiFetch("/v1/lux/fleets", {
method: "POST",
body: JSON.stringify({
name: formName.trim(),
cluster_id: selectedCluster,
network: formNetwork,
replicas: formReplicas,
image_tag: formImageTag.trim(),
chain_tracking: formChainTracking,
}),
})
setCreateOpen(false)
resetForm()
fetchFleets()
fetchStats()
} catch (e) {
setCreateError(String(e))
} finally {
setCreating(false)
}
}
function resetForm() {
setFormName("")
setFormNetwork("testnet")
setFormReplicas(5)
setFormImageTag("luxd-v1.23.11")
setFormChainTracking(true)
setCreateError(null)
}
async function handleScale(network: NetworkType, replicas: number) {
setActionLoading(network)
try {
await apiFetch(
`/v1/lux/fleets/${selectedCluster}/${network}/scale?replicas=${replicas}`,
{ method: "POST" },
)
setScaleTarget(null)
fetchFleets()
} catch (e) {
console.error("Scale failed:", e)
} finally {
setActionLoading(null)
}
}
async function handleRestart(network: NetworkType) {
setActionLoading(network)
try {
await apiFetch(`/v1/lux/fleets/${selectedCluster}/${network}/restart`, {
method: "POST",
})
setConfirmRestart(null)
fetchFleets()
} catch (e) {
console.error("Restart failed:", e)
} finally {
setActionLoading(null)
}
}
async function handleDestroy(network: NetworkType) {
setActionLoading(network)
try {
await apiFetch(`/v1/lux/fleets/${selectedCluster}/${network}`, {
method: "DELETE",
})
setConfirmDestroy(null)
setFleets((prev) => prev.filter((f) => f.network !== network))
fetchStats()
} catch (e) {
console.error("Destroy failed:", e)
} finally {
setActionLoading(null)
}
}
// ------- Render -----------------------------------------------------------
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Lux Fleet Dashboard</h1>
<p className="text-muted-foreground">
Deploy and manage luxd validator fleets across Kubernetes clusters
</p>
</div>
<div className="flex items-center gap-3">
{/* Cluster selector */}
<Select
value={selectedCluster}
onValueChange={setSelectedCluster}
className="w-[220px]"
>
{clusters.length === 0 ? (
<option value="" disabled>
No clusters
</option>
) : (
clusters.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.region})
</option>
))
)}
</Select>
<Button
variant="outline"
size="sm"
onClick={() => {
setRefreshing(true)
fetchFleets()
fetchStats()
}}
disabled={refreshing}
>
<RefreshCcw className={`mr-2 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
Refresh
</Button>
{/* Create fleet */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Fleet
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Create Lux Validator Fleet</DialogTitle>
<DialogDescription>
Deploy a fleet of luxd validators to the selected cluster
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="text-sm font-medium">Fleet Name</label>
<Input
placeholder="e.g., lux-mainnet-validators"
value={formName}
onChange={(e) => setFormName(e.target.value)}
className="mt-1"
/>
</div>
<div className="grid gap-4 grid-cols-2">
<div>
<label className="text-sm font-medium">Network</label>
<Select
value={formNetwork}
onValueChange={(v) => setFormNetwork(v as NetworkType)}
className="mt-1"
>
<option value="mainnet">Mainnet</option>
<option value="testnet">Testnet</option>
<option value="devnet">Devnet</option>
</Select>
</div>
<div>
<label className="text-sm font-medium">Replicas</label>
<Input
type="number"
min={1}
max={20}
value={formReplicas}
onChange={(e) => setFormReplicas(parseInt(e.target.value) || 5)}
className="mt-1"
/>
</div>
</div>
<div>
<label className="text-sm font-medium">Image Tag</label>
<Input
placeholder="luxd-v1.23.11"
value={formImageTag}
onChange={(e) => setFormImageTag(e.target.value)}
className="mt-1 font-mono"
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<p className="text-sm font-medium">Chain Tracking</p>
<p className="text-xs text-muted-foreground">
Auto-configure C/P/X chain tracking for {formNetwork}
</p>
</div>
<Switch checked={formChainTracking} onCheckedChange={setFormChainTracking} />
</div>
{createError && (
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
{createError}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setCreateOpen(false)
resetForm()
}}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={creating || !formName.trim() || !selectedCluster}
>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Fleet
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{/* Stats banner */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Fleets</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-muted-foreground" />
<span className="text-2xl font-bold">
{stats ? stats.total_fleets : <Loader2 className="h-5 w-5 animate-spin" />}
</span>
</div>
<p className="text-xs text-muted-foreground">across all clusters</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Nodes</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-muted-foreground" />
<span className="text-2xl font-bold">
{stats ? stats.total_nodes : <Loader2 className="h-5 w-5 animate-spin" />}
</span>
</div>
<p className="text-xs text-muted-foreground">validator pods running</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Healthy Nodes</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Heart className="h-5 w-5 text-green-500" />
<span className="text-2xl font-bold text-green-500">
{stats ? stats.healthy_nodes : <Loader2 className="h-5 w-5 animate-spin" />}
</span>
{stats && stats.total_nodes > 0 && (
<span className="text-sm text-muted-foreground">
/ {stats.total_nodes} ({Math.round((stats.healthy_nodes / stats.total_nodes) * 100)}%)
</span>
)}
</div>
<p className="text-xs text-muted-foreground">passing health checks</p>
</CardContent>
</Card>
</div>
{/* Fleet cards per network */}
{loading ? (
<div className="flex items-center justify-center p-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : error ? (
<Card>
<CardContent className="p-6">
<div className="rounded-md bg-destructive/10 p-4 text-destructive">{error}</div>
</CardContent>
</Card>
) : fleets.length === 0 ? (
<Card>
<CardContent className="p-12 text-center text-muted-foreground">
No fleets deployed on this cluster. Click &quot;Create Fleet&quot; to get started.
</CardContent>
</Card>
) : (
<div className="space-y-6">
{fleets.map((fleet) => {
const key = `${fleet.cluster_id}/${fleet.network}`
const detail = fleetDetails[key]
const isExpanded = expandedFleet === fleet.network
const isActionLoading = actionLoading === fleet.network
return (
<Card key={key} className="border-zinc-800">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() =>
setExpandedFleet(isExpanded ? null : fleet.network)
}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<div>
<CardTitle className="flex items-center gap-2">
{fleet.name}
<Badge variant="outline" className={NETWORK_COLORS[fleet.network]}>
{fleet.network}
</Badge>
</CardTitle>
<CardDescription className="font-mono text-xs mt-1">
{fleet.image_tag}
</CardDescription>
</div>
</div>
<div className="flex items-center gap-4">
{statusBadge(fleet.status)}
<div className="text-sm text-right">
<span className="font-bold">{fleet.ready_replicas}</span>
<span className="text-muted-foreground">/{fleet.replicas} ready</span>
<p className="text-xs text-muted-foreground">
Helm rev {fleet.helm_revision}
</p>
</div>
</div>
</div>
</CardHeader>
{/* Fleet operations */}
<CardContent className="pt-0 pb-4">
<div className="flex items-center gap-2 border-t border-zinc-800 pt-4">
{/* Scale */}
{scaleTarget === fleet.network ? (
<div className="flex items-center gap-2">
<Select
value={String(scaleValue)}
onValueChange={(v) => setScaleValue(parseInt(v))}
className="w-[80px]"
>
{Array.from({ length: 20 }, (_, i) => i + 1).map((n) => (
<option key={n} value={String(n)}>
{n}
</option>
))}
</Select>
<Button
size="sm"
onClick={() => handleScale(fleet.network, scaleValue)}
disabled={isActionLoading}
>
{isActionLoading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Scale className="mr-1 h-3 w-3" />
)}
Apply
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setScaleTarget(null)}
>
Cancel
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
onClick={() => {
setScaleValue(fleet.replicas)
setScaleTarget(fleet.network)
}}
>
<Scale className="mr-2 h-3 w-3" />
Scale
</Button>
)}
{/* Restart */}
{confirmRestart === fleet.network ? (
<div className="flex items-center gap-2">
<span className="text-sm text-yellow-500">Restart all pods?</span>
<Button
size="sm"
variant="destructive"
onClick={() => handleRestart(fleet.network)}
disabled={isActionLoading}
>
{isActionLoading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<RotateCcw className="mr-1 h-3 w-3" />
)}
Confirm
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmRestart(null)}
>
Cancel
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
onClick={() => setConfirmRestart(fleet.network)}
>
<RotateCcw className="mr-2 h-3 w-3" />
Restart
</Button>
)}
{/* Destroy */}
{confirmDestroy === fleet.network ? (
<div className="flex items-center gap-2">
<span className="text-sm text-red-500">
Permanently destroy this fleet?
</span>
<Button
size="sm"
variant="destructive"
onClick={() => handleDestroy(fleet.network)}
disabled={isActionLoading}
>
{isActionLoading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Trash2 className="mr-1 h-3 w-3" />
)}
Destroy
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmDestroy(null)}
>
Cancel
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
className="text-red-400 hover:text-red-300 border-red-500/30 hover:border-red-500/50"
onClick={() => setConfirmDestroy(fleet.network)}
>
<Trash2 className="mr-2 h-3 w-3" />
Destroy
</Button>
)}
</div>
</CardContent>
{/* Expanded: Node list table */}
{isExpanded && (
<CardContent className="pt-0">
<div className="border-t border-zinc-800 pt-4">
<p className="text-sm font-medium text-muted-foreground mb-3">
Nodes ({detail?.nodes?.length ?? "..."})
</p>
{!detail ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : detail.nodes.length === 0 ? (
<p className="text-sm text-muted-foreground text-center p-6">
No nodes reported. Fleet may still be deploying.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Pod Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>External IP</TableHead>
<TableHead className="text-right">C-Chain Height</TableHead>
<TableHead className="text-right">Peers</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.nodes.map((node) => (
<TableRow key={node.pod_name}>
<TableCell>
<div className="flex items-center gap-2">
<Circle
className={`h-2 w-2 fill-current ${
node.healthy === true && node.ready
? "text-green-500"
: node.healthy === false
? "text-red-500"
: node.status === "Pending" ||
node.status === "ContainerCreating"
? "text-yellow-500"
: "text-zinc-500"
}`}
/>
<span className="font-mono text-xs">{node.pod_name}</span>
</div>
</TableCell>
<TableCell>
<span
className={`text-sm ${
node.ready ? "text-green-400" : "text-yellow-400"
}`}
>
{node.status}
</span>
</TableCell>
<TableCell>
<span className="font-mono text-xs text-muted-foreground">
{node.external_ip || "--"}
</span>
</TableCell>
<TableCell className="text-right font-mono text-sm">
{node.c_chain_height != null
? node.c_chain_height.toLocaleString()
: "--"}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{node.peers != null ? node.peers : "--"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
)}
</Card>
)
})}
</div>
)}
{/* Restart confirmation dialog */}
<Dialog
open={confirmRestart !== null}
onOpenChange={(open) => {
if (!open) setConfirmRestart(null)
}}
>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Confirm Restart</DialogTitle>
<DialogDescription>
This will perform a rolling restart of all pods in the{" "}
<span className="font-mono font-bold">{confirmRestart}</span> fleet. Nodes will be
temporarily unavailable during restart.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmRestart(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => confirmRestart && handleRestart(confirmRestart as NetworkType)}
disabled={actionLoading !== null}
>
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Restart Fleet
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Destroy confirmation dialog */}
<Dialog
open={confirmDestroy !== null}
onOpenChange={(open) => {
if (!open) setConfirmDestroy(null)
}}
>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Confirm Destroy</DialogTitle>
<DialogDescription>
This will permanently delete the{" "}
<span className="font-mono font-bold">{confirmDestroy}</span> fleet and all its
nodes. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmDestroy(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => confirmDestroy && handleDestroy(confirmDestroy as NetworkType)}
disabled={actionLoading !== null}
>
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Destroy Fleet
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* API reference */}
<Card className="border-zinc-800">
<CardHeader>
<CardTitle>Fleet APIs</CardTitle>
<CardDescription>Lux fleet management endpoints</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{[
{ method: "GET", path: "/v1/lux/fleets?cluster_id=xxx", desc: "List fleets" },
{ method: "GET", path: "/v1/lux/fleets/{cluster_id}/{network}", desc: "Fleet detail with nodes" },
{ method: "POST", path: "/v1/lux/fleets", desc: "Create fleet" },
{ method: "PATCH", path: "/v1/lux/fleets/{cluster_id}/{network}", desc: "Update fleet config" },
{ method: "DELETE", path: "/v1/lux/fleets/{cluster_id}/{network}", desc: "Destroy fleet" },
{ method: "POST", path: "/v1/lux/fleets/{cluster_id}/{network}/scale?replicas=N", desc: "Scale replicas" },
{ method: "POST", path: "/v1/lux/fleets/{cluster_id}/{network}/restart", desc: "Rolling restart" },
{ method: "GET", path: "/v1/lux/stats", desc: "Fleet statistics" },
{ method: "GET", path: "/v1/lux/networks", desc: "Network configs" },
].map((ep) => (
<div
key={ep.method + ep.path}
className="flex items-center gap-3 p-2.5 border border-zinc-800 rounded-lg"
>
<Badge
variant="default"
className={`font-mono text-xs w-16 justify-center ${
ep.method === "POST"
? "bg-blue-500/20 text-blue-400 border-blue-500/30"
: ep.method === "DELETE"
? "bg-red-500/20 text-red-400 border-red-500/30"
: ep.method === "PATCH"
? "bg-yellow-500/20 text-yellow-400 border-yellow-500/30"
: ""
}`}
>
{ep.method}
</Badge>
<code className="text-xs font-mono flex-1 text-muted-foreground">{ep.path}</code>
<span className="text-xs text-muted-foreground">{ep.desc}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}
+24 -1
View File
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Server, Box, HardDrive, Copy, Loader2 } from "lucide-react"
import { Server, Box, HardDrive, Copy, Loader2, Shield } from "lucide-react"
import Link from "next/link"
interface InfraStats {
@@ -109,6 +109,29 @@ export default function InfrastructurePage() {
{/* Infrastructure Categories */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Lux Validators
</CardTitle>
<CardDescription>
Deploy and manage Lux validator fleets
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Run fleets of luxd validators across Kubernetes clusters.
Mainnet, testnet, and devnet support with chain tracking.
</p>
<Button variant="outline" className="w-full" asChild>
<Link href="/dashboard/infrastructure/lux">
Manage Fleets
</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
+553
View File
@@ -0,0 +1,553 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { cn } from "@/lib/utils"
import { WORLD_PATHS, generateGridLines, latLngToSVG } from "./world-paths"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface FleetRegion {
slug: string
name: string
lat: number
lon: number
active: boolean
nodeCount: number
networks: string[]
healthyNodes: number
}
export interface FleetMapProps {
regions: FleetRegion[]
onRegionClick?: (slug: string) => void
className?: string
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SVG_W = 960
const SVG_H = 500
const COLOR_ACTIVE = "#0066FF"
const COLOR_ACTIVE_GLOW = "#0066FF"
const COLOR_INACTIVE = "#333333"
const COLOR_BG = "#0a0a0a"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Mercator projection: lat/lon -> SVG x/y using the shared world-paths helper */
function toSvg(lat: number, lon: number) {
return latLngToSVG(lat, lon)
}
/** Build P2P mesh connection lines between all active regions */
function buildConnections(regions: FleetRegion[]) {
const active = regions.filter((r) => r.active)
const lines: { x1: number; y1: number; x2: number; y2: number; key: string }[] = []
for (let i = 0; i < active.length; i++) {
for (let j = i + 1; j < active.length; j++) {
const a = toSvg(active[i].lat, active[i].lon)
const b = toSvg(active[j].lat, active[j].lon)
lines.push({
x1: a.x, y1: a.y,
x2: b.x, y2: b.y,
key: `${active[i].slug}-${active[j].slug}`,
})
}
}
return lines
}
/** Health status derived from nodeCount vs healthyNodes */
function healthStatus(r: FleetRegion): "healthy" | "degraded" | "partial" | "inactive" {
if (!r.active) return "inactive"
if (r.nodeCount === 0) return "inactive"
if (r.healthyNodes === r.nodeCount) return "healthy"
if (r.healthyNodes === 0) return "degraded"
return "partial"
}
function healthLabel(r: FleetRegion): string {
const s = healthStatus(r)
switch (s) {
case "healthy": return "Healthy"
case "degraded": return "Degraded"
case "partial": return "Partial"
case "inactive": return "Inactive"
}
}
function healthColor(r: FleetRegion): string {
const s = healthStatus(r)
switch (s) {
case "healthy": return "#22c55e"
case "degraded": return "#ef4444"
case "partial": return "#eab308"
case "inactive": return COLOR_INACTIVE
}
}
// ---------------------------------------------------------------------------
// CSS Animations (injected once)
// ---------------------------------------------------------------------------
const FLEET_STYLES = `
@keyframes fleet-pulse {
0% { r: 4; opacity: 0.8; }
70% { r: 16; opacity: 0; }
100% { r: 16; opacity: 0; }
}
@keyframes fleet-pulse-2 {
0% { r: 4; opacity: 0.5; }
70% { r: 22; opacity: 0; }
100% { r: 22; opacity: 0; }
}
@keyframes fleet-dash {
to { stroke-dashoffset: -20; }
}
@keyframes fleet-fadein {
from { opacity: 0; transform: scale(0.6); }
to { opacity: 1; transform: scale(1); }
}
@keyframes fleet-glow-breathe {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
.fleet-pulse-ring {
animation: fleet-pulse 2.4s ease-out infinite;
pointer-events: none;
}
.fleet-pulse-ring-2 {
animation: fleet-pulse-2 2.4s ease-out infinite;
animation-delay: 0.8s;
pointer-events: none;
}
.fleet-mesh-line {
stroke-dasharray: 6 4;
animation: fleet-dash 1.5s linear infinite;
}
.fleet-node-enter {
animation: fleet-fadein 0.35s ease-out;
cursor: pointer;
}
.fleet-tooltip-enter {
pointer-events: none;
animation: fleet-fadein 0.12s ease-out;
}
.fleet-breathe {
animation: fleet-glow-breathe 3s ease-in-out infinite;
}
`
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function FleetMap({ regions, onRegionClick, className }: FleetMapProps) {
const [hovered, setHovered] = useState<string | null>(null)
const [zoomTarget, setZoomTarget] = useState<{ x: number; y: number; slug: string } | null>(null)
const stylesRef = useRef(false)
// Inject keyframe CSS once
useEffect(() => {
if (stylesRef.current) return
stylesRef.current = true
const el = document.createElement("style")
el.setAttribute("data-fleet-map", "")
el.textContent = FLEET_STYLES
document.head.appendChild(el)
return () => {
el.remove()
}
}, [])
// Click: zoom to region. Click same region or double-click: zoom out.
const handleRegionClick = useCallback(
(slug: string, x: number, y: number) => {
if (zoomTarget?.slug === slug) {
// Already zoomed here -- zoom out
setZoomTarget(null)
} else {
setZoomTarget({ x, y, slug })
}
onRegionClick?.(slug)
},
[zoomTarget, onRegionClick],
)
const handleDoubleClick = useCallback(() => {
setZoomTarget(null)
}, [])
// Derived data
const connections = buildConnections(regions)
const gridLines = generateGridLines(SVG_W, SVG_H)
// Stats
const totalNodes = regions.reduce((s, r) => s + r.nodeCount, 0)
const activeCount = regions.filter((r) => r.active).length
const totalHealthy = regions.reduce((s, r) => s + r.healthyNodes, 0)
const healthPct = totalNodes > 0 ? Math.round((totalHealthy / totalNodes) * 100) : 0
// Zoom transform
const zoomScale = 2.2
const zoomTransform = zoomTarget
? `translate(${SVG_W / 2 - zoomTarget.x * zoomScale}px, ${SVG_H / 2 - zoomTarget.y * zoomScale}px) scale(${zoomScale})`
: "translate(0px, 0px) scale(1)"
// Hovered region info
const hoveredRegion = hovered ? regions.find((r) => r.slug === hovered) : null
const hoveredPt = hoveredRegion ? toSvg(hoveredRegion.lat, hoveredRegion.lon) : null
return (
<div
className={cn(
"relative w-full overflow-hidden rounded-xl border border-white/[0.06]",
className,
)}
style={{ background: COLOR_BG }}
>
{/* ---- Stats overlay ---- */}
<div
className="absolute top-4 right-4 z-10 flex flex-col gap-1.5 rounded-lg px-4 py-3"
style={{
background: "rgba(10,10,10,0.88)",
border: "1px solid rgba(255,255,255,0.08)",
backdropFilter: "blur(8px)",
}}
>
<div className="text-[10px] uppercase tracking-[0.15em] text-gray-500 font-medium">
Lux Fleet Network
</div>
<div className="flex gap-6">
<StatBadge label="Nodes" value={totalNodes} />
<StatBadge label="Regions" value={`${activeCount}/${regions.length}`} />
<StatBadge
label="Health"
value={`${healthPct}%`}
color={healthPct >= 80 ? "#22c55e" : healthPct >= 50 ? "#eab308" : "#ef4444"}
/>
</div>
</div>
{/* ---- Legend ---- */}
<div
className="absolute bottom-4 left-4 z-10 flex gap-4 rounded-lg px-3 py-2"
style={{
background: "rgba(10,10,10,0.88)",
border: "1px solid rgba(255,255,255,0.08)",
backdropFilter: "blur(8px)",
}}
>
<LegendDot color={COLOR_ACTIVE} label="Active" />
<LegendDot color={COLOR_INACTIVE} label="Inactive" />
</div>
{/* ---- SVG Map ---- */}
<svg
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
className="w-full h-auto select-none"
style={{ display: "block" }}
onDoubleClick={handleDoubleClick}
>
<defs>
{/* Glow filter for active dots */}
<filter id="fm-glow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feColorMatrix
in="blur"
type="matrix"
values="0 0 0 0 0
0 0.4 0 0 0
0 0 1 0 0
0 0 0 0.7 0"
result="blueglow"
/>
<feMerge>
<feMergeNode in="blueglow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Subtle glow for mesh lines */}
<filter id="fm-line-glow" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur stdDeviation="1.2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Radial gradient for active dot centers */}
<radialGradient id="fm-dot-gradient" cx="40%" cy="40%">
<stop offset="0%" stopColor="#4d9fff" />
<stop offset="100%" stopColor={COLOR_ACTIVE} />
</radialGradient>
</defs>
{/* Zoomable group */}
<g
style={{
transform: zoomTransform,
transformOrigin: "center center",
transition: "transform 0.7s cubic-bezier(0.25, 0.1, 0.25, 1)",
}}
>
{/* Grid lines */}
{gridLines.map((l, i) => (
<line
key={`g-${i}`}
x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2}
stroke="rgba(255,255,255,0.025)"
strokeWidth={0.5}
/>
))}
{/* Continent outlines */}
{WORLD_PATHS.map((c) => (
<path
key={c.id}
d={c.d}
fill="#111111"
stroke="rgba(255,255,255,0.07)"
strokeWidth={0.6}
strokeLinejoin="round"
/>
))}
{/* P2P mesh connection lines */}
{connections.map((c) => (
<line
key={c.key}
x1={c.x1} y1={c.y1} x2={c.x2} y2={c.y2}
stroke={`${COLOR_ACTIVE}26`}
strokeWidth={0.7}
className="fleet-mesh-line"
filter="url(#fm-line-glow)"
/>
))}
{/* Region markers */}
{regions.map((r) => {
const pt = toSvg(r.lat, r.lon)
const isActive = r.active
const isHovered = hovered === r.slug
const dotColor = isActive ? COLOR_ACTIVE : COLOR_INACTIVE
const dotR = isActive ? (isHovered ? 6.5 : 5) : (isHovered ? 4 : 3)
return (
<g
key={r.slug}
className="fleet-node-enter"
onClick={() => handleRegionClick(r.slug, pt.x, pt.y)}
onMouseEnter={() => setHovered(r.slug)}
onMouseLeave={() => setHovered(null)}
>
{/* Outer pulse ring 1 */}
{isActive && (
<circle
cx={pt.x} cy={pt.y} r={4}
fill="none"
stroke={COLOR_ACTIVE_GLOW}
strokeWidth={1.5}
className="fleet-pulse-ring"
/>
)}
{/* Outer pulse ring 2 (offset) */}
{isActive && (
<circle
cx={pt.x} cy={pt.y} r={4}
fill="none"
stroke={COLOR_ACTIVE_GLOW}
strokeWidth={0.8}
className="fleet-pulse-ring-2"
/>
)}
{/* Ambient glow behind active dots */}
{isActive && (
<circle
cx={pt.x} cy={pt.y} r={10}
fill={`${COLOR_ACTIVE}12`}
className="fleet-breathe"
/>
)}
{/* Core dot */}
<circle
cx={pt.x} cy={pt.y}
r={dotR}
fill={isActive ? "url(#fm-dot-gradient)" : dotColor}
filter={isActive ? "url(#fm-glow)" : undefined}
style={{ transition: "r 0.2s ease" }}
/>
{/* Inner highlight */}
{isActive && (
<circle
cx={pt.x - 1} cy={pt.y - 1}
r={1.5}
fill="rgba(255,255,255,0.3)"
/>
)}
{/* Node count badge */}
{isActive && r.nodeCount > 0 && (
<>
<rect
x={pt.x + 7}
y={pt.y - 15}
width={r.nodeCount >= 100 ? 28 : r.nodeCount >= 10 ? 22 : 16}
height={14}
rx={3}
fill="rgba(0,0,0,0.85)"
stroke={`${COLOR_ACTIVE}66`}
strokeWidth={0.6}
/>
<text
x={pt.x + 7 + (r.nodeCount >= 100 ? 14 : r.nodeCount >= 10 ? 11 : 8)}
y={pt.y - 5.5}
fill="#e5e7eb"
fontSize={8.5}
fontFamily="var(--font-mono, ui-monospace, monospace)"
fontWeight={600}
textAnchor="middle"
>
{r.nodeCount}
</text>
</>
)}
</g>
)
})}
{/* Tooltip */}
{hoveredRegion && hoveredPt && (() => {
const tooltipW = 190
const tooltipH = hoveredRegion.active ? 90 : 36
// Flip tooltip left if too close to right edge
const flipX = hoveredPt.x + tooltipW + 20 > SVG_W
const tx = flipX ? hoveredPt.x - tooltipW - 12 : hoveredPt.x + 14
// Flip tooltip up if too close to bottom
const flipY = hoveredPt.y + tooltipH > SVG_H - 20
const ty = flipY ? hoveredPt.y - tooltipH - 5 : hoveredPt.y - 50
return (
<g className="fleet-tooltip-enter">
{/* Tooltip background */}
<rect
x={tx}
y={ty}
width={tooltipW}
height={tooltipH}
rx={6}
fill="rgba(8,8,8,0.95)"
stroke="rgba(255,255,255,0.1)"
strokeWidth={0.8}
/>
{/* Region name */}
<text
x={tx + 12} y={ty + 16}
fill="#f3f4f6" fontSize={10} fontWeight={600}
>
{hoveredRegion.name}
</text>
{/* Slug */}
<text
x={tx + 12} y={ty + 28}
fill="#6b7280" fontSize={8}
fontFamily="var(--font-mono, ui-monospace, monospace)"
>
{hoveredRegion.slug}
</text>
{hoveredRegion.active && (
<>
{/* Node count */}
<text x={tx + 12} y={ty + 44} fill="#d1d5db" fontSize={8.5}>
Nodes: {hoveredRegion.nodeCount}
{" | "}
Healthy: {hoveredRegion.healthyNodes}
</text>
{/* Networks */}
<text x={tx + 12} y={ty + 58} fill="#9ca3af" fontSize={8}>
Networks: {hoveredRegion.networks.join(", ") || "none"}
</text>
{/* Health status */}
<text
x={tx + 12} y={ty + 74}
fill={healthColor(hoveredRegion)}
fontSize={9} fontWeight={600}
>
{healthLabel(hoveredRegion).toUpperCase()}
{" "}
{hoveredRegion.nodeCount > 0
? `${Math.round((hoveredRegion.healthyNodes / hoveredRegion.nodeCount) * 100)}%`
: ""}
</text>
</>
)}
</g>
)
})()}
</g>
</svg>
</div>
)
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function StatBadge({
label,
value,
color,
}: {
label: string
value: string | number
color?: string
}) {
return (
<div className="flex flex-col items-center">
<span
className="text-lg font-bold font-mono tabular-nums"
style={{ color: color ?? "#e5e7eb" }}
>
{value}
</span>
<span className="text-[9px] uppercase tracking-wider text-gray-500">{label}</span>
</div>
)
}
function LegendDot({ color, label }: { color: string; label: string }) {
return (
<div className="flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{
background: color,
boxShadow: color !== COLOR_INACTIVE ? `0 0 6px ${color}` : "none",
}}
/>
<span className="text-[9px] text-gray-400">{label}</span>
</div>
)
}
export default FleetMap
+72
View File
@@ -0,0 +1,72 @@
// DOKS (DigitalOcean Kubernetes Service) region coordinates and types
// Used by fleet-map.tsx to position validator nodes on the world map
export interface DOKSRegion {
name: string
lat: number
lng: number
country: string
}
export const DOKS_REGIONS: Record<string, DOKSRegion> = {
nyc1: { name: "New York 1", lat: 40.7128, lng: -74.006, country: "US" },
nyc2: { name: "New York 2", lat: 40.7128, lng: -74.006, country: "US" },
nyc3: { name: "New York 3", lat: 40.7128, lng: -74.006, country: "US" },
sfo2: { name: "San Francisco 2", lat: 37.7749, lng: -122.4194, country: "US" },
sfo3: { name: "San Francisco 3", lat: 37.7749, lng: -122.4194, country: "US" },
lon1: { name: "London 1", lat: 51.5074, lng: -0.1278, country: "GB" },
ams3: { name: "Amsterdam 3", lat: 52.3676, lng: 4.9041, country: "NL" },
fra1: { name: "Frankfurt 1", lat: 50.1109, lng: 8.6821, country: "DE" },
tor1: { name: "Toronto 1", lat: 43.6532, lng: -79.3832, country: "CA" },
sgp1: { name: "Singapore 1", lat: 1.3521, lng: 103.8198, country: "SG" },
blr1: { name: "Bangalore 1", lat: 12.9716, lng: 77.5946, country: "IN" },
syd1: { name: "Sydney 1", lat: -33.8688, lng: 151.2093, country: "AU" },
atl1: { name: "Atlanta 1", lat: 33.749, lng: -84.388, country: "US" },
}
export type NodeStatus = "healthy" | "syncing" | "degraded" | "available"
export interface FleetRegionInfo {
regionId: string
region: DOKSRegion
status: NodeStatus
fleetName?: string
validatorCount: number
blockHeight?: number
healthPct?: number
}
export interface LuxFleet {
id: string
name: string
region: string
status: "running" | "deploying" | "degraded" | "stopped"
validators: number
blockHeight?: number
healthPct?: number
createdAt?: string
}
/** Map fleet status to node visual status */
export function fleetStatusToNodeStatus(s: LuxFleet["status"]): NodeStatus {
switch (s) {
case "running":
return "healthy"
case "deploying":
return "syncing"
case "degraded":
return "degraded"
default:
return "available"
}
}
/** Auth headers for API calls -- matches existing pattern in infrastructure/page.tsx */
export function getAuthHeaders(): Record<string, string> {
if (typeof window === "undefined") return {}
const token = localStorage.getItem("bootnode_token")
const apiKey = localStorage.getItem("bootnode_api_key")
if (token) return { Authorization: `Bearer ${token}` }
if (apiKey) return { "X-API-Key": apiKey }
return {}
}
+138
View File
@@ -0,0 +1,138 @@
// Simplified world continent outlines as SVG path data
// Coordinates are in Mercator-projected SVG space (960x500 viewBox)
// Derived from Natural Earth 110m simplified to ~20-40 vertices per continent
// Each path is an array of [x, y] points that will be joined as an SVG polygon
export interface ContinentPath {
id: string
name: string
d: string
}
// Helper: convert lat/lng to Mercator SVG coords in a 960x500 viewBox
function p(lat: number, lng: number): [number, number] {
const x = (lng + 180) * (960 / 360)
const latRad = (lat * Math.PI) / 180
const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2))
const y = 250 - (960 * mercN) / (2 * Math.PI)
return [Math.round(x * 10) / 10, Math.round(y * 10) / 10]
}
function toPath(coords: [number, number][]): string {
return (
"M" +
coords.map(([x, y]) => `${x},${y}`).join("L") +
"Z"
)
}
// North America (simplified outline)
const northAmerica = toPath([
p(49, -125), p(60, -140), p(70, -140), p(72, -120),
p(75, -95), p(72, -75), p(60, -65), p(50, -55),
p(47, -60), p(45, -67), p(43, -70), p(40, -74),
p(30, -82), p(25, -80), p(25, -90), p(20, -87),
p(15, -88), p(15, -92), p(20, -105), p(23, -110),
p(30, -115), p(32, -117), p(34, -120), p(38, -123),
p(42, -124), p(46, -124),
])
// South America
const southAmerica = toPath([
p(12, -72), p(10, -62), p(8, -60), p(5, -52),
p(0, -50), p(-5, -35), p(-10, -37), p(-15, -39),
p(-22, -41), p(-28, -49), p(-33, -52), p(-40, -62),
p(-48, -66), p(-55, -68), p(-55, -70), p(-50, -75),
p(-42, -73), p(-35, -72), p(-20, -70), p(-15, -76),
p(-5, -80), p(0, -78), p(5, -77), p(10, -75),
])
// Europe (including British Isles roughly)
const europe = toPath([
p(36, -10), p(38, -5), p(43, -9), p(44, -1),
p(47, -2), p(49, 0), p(51, 2), p(54, -3),
p(58, -5), p(60, 5), p(64, 10), p(70, 20),
p(71, 28), p(70, 32), p(65, 30), p(60, 30),
p(56, 24), p(55, 20), p(54, 14), p(51, 7),
p(48, 8), p(46, 15), p(44, 12), p(42, 3),
p(37, -2), p(36, -5),
])
// Africa
const africa = toPath([
p(37, -10), p(37, 10), p(35, 12), p(32, 13),
p(30, 33), p(22, 37), p(12, 44), p(10, 42),
p(5, 42), p(0, 42), p(-5, 40), p(-12, 44),
p(-20, 36), p(-26, 33), p(-34, 26), p(-35, 20),
p(-30, 17), p(-22, 14), p(-15, 12), p(-5, 10),
p(0, 10), p(5, 2), p(5, -5), p(7, -5),
p(10, -15), p(15, -17), p(20, -17), p(25, -15),
p(30, -10), p(35, -5),
])
// Asia (simplified -- includes Middle East through East Asia)
const asia = toPath([
p(70, 32), p(72, 60), p(75, 80), p(73, 100),
p(72, 120), p(70, 135), p(65, 140), p(60, 143),
p(55, 135), p(50, 140), p(45, 142), p(40, 140),
p(35, 140), p(35, 130), p(30, 120), p(22, 120),
p(20, 110), p(10, 107), p(5, 105), p(1, 104),
p(8, 80), p(12, 78), p(20, 73), p(25, 68),
p(27, 56), p(30, 48), p(32, 36), p(35, 36),
p(40, 44), p(42, 40), p(44, 38), p(50, 40),
p(55, 38), p(55, 32), p(60, 30), p(65, 30),
])
// Australia
const australia = toPath([
p(-12, 131), p(-12, 136), p(-15, 140), p(-20, 148),
p(-28, 153), p(-33, 152), p(-37, 150), p(-39, 146),
p(-38, 141), p(-35, 137), p(-32, 133), p(-35, 117),
p(-33, 115), p(-28, 114), p(-22, 114), p(-17, 122),
p(-14, 126), p(-12, 129),
])
// Greenland
const greenland = toPath([
p(60, -45), p(65, -55), p(70, -55), p(76, -60),
p(80, -50), p(82, -35), p(80, -20), p(76, -18),
p(70, -22), p(65, -40), p(62, -43),
])
export const WORLD_PATHS: ContinentPath[] = [
{ id: "na", name: "North America", d: northAmerica },
{ id: "sa", name: "South America", d: southAmerica },
{ id: "eu", name: "Europe", d: europe },
{ id: "af", name: "Africa", d: africa },
{ id: "as", name: "Asia", d: asia },
{ id: "oc", name: "Oceania", d: australia },
{ id: "gl", name: "Greenland", d: greenland },
]
// Grid lines for the map background (every 30 degrees)
export function generateGridLines(
width: number,
height: number
): { x1: number; y1: number; x2: number; y2: number }[] {
const lines: { x1: number; y1: number; x2: number; y2: number }[] = []
// Longitude lines (vertical)
for (let lng = -180; lng <= 180; lng += 30) {
const x = (lng + 180) * (width / 360)
lines.push({ x1: x, y1: 0, x2: x, y2: height })
}
// Latitude lines (horizontal) -- Mercator spaced
for (let lat = -60; lat <= 75; lat += 15) {
const [, y] = p(lat, 0)
lines.push({ x1: 0, y1: y, x2: width, y2: y })
}
return lines
}
/** Convert lat/lng to SVG coordinates in 960x500 viewBox */
export function latLngToSVG(lat: number, lng: number): { x: number; y: number } {
const [x, y] = p(lat, lng)
return { x, y }
}
+156
View File
@@ -0,0 +1,156 @@
// DigitalOcean Kubernetes regions and Lux network configuration
// Used by the fleet management UI for cluster provisioning and monitoring.
// ---------------------------------------------------------------------------
// DOKS Regions
// ---------------------------------------------------------------------------
export interface DOKSRegion {
slug: string;
name: string;
city: string;
country: string;
lat: number;
lon: number;
available: boolean;
}
export const DOKS_REGIONS: DOKSRegion[] = [
{ slug: "nyc1", name: "New York 1", city: "New York", country: "US", lat: 40.7128, lon: -74.0060, available: true },
{ slug: "nyc3", name: "New York 3", city: "New York", country: "US", lat: 40.7128, lon: -74.0060, available: true },
{ slug: "sfo2", name: "San Francisco 2", city: "San Francisco", country: "US", lat: 37.7749, lon: -122.4194, available: true },
{ slug: "sfo3", name: "San Francisco 3", city: "San Francisco", country: "US", lat: 37.7749, lon: -122.4194, available: true },
{ slug: "lon1", name: "London 1", city: "London", country: "UK", lat: 51.5074, lon: -0.1278, available: true },
{ slug: "ams3", name: "Amsterdam 3", city: "Amsterdam", country: "NL", lat: 52.3676, lon: 4.9041, available: true },
{ slug: "fra1", name: "Frankfurt 1", city: "Frankfurt", country: "DE", lat: 50.1109, lon: 8.6821, available: true },
{ slug: "tor1", name: "Toronto 1", city: "Toronto", country: "CA", lat: 43.6532, lon: -79.3832, available: true },
{ slug: "sgp1", name: "Singapore 1", city: "Singapore", country: "SG", lat: 1.3521, lon: 103.8198, available: true },
{ slug: "blr1", name: "Bangalore 1", city: "Bangalore", country: "IN", lat: 12.9716, lon: 77.5946, available: true },
{ slug: "syd1", name: "Sydney 1", city: "Sydney", country: "AU", lat: -33.8688, lon: 151.2093, available: true },
];
/** Look up a region by slug, returns undefined if not found. */
export function getRegion(slug: string): DOKSRegion | undefined {
return DOKS_REGIONS.find((r) => r.slug === slug);
}
// ---------------------------------------------------------------------------
// Lux Network Configuration
// ---------------------------------------------------------------------------
export type LuxNetwork = "mainnet" | "testnet" | "devnet";
export interface LuxNetworkConfig {
networkId: number;
chainId: number;
httpPort: number;
stakingPort: number;
namespace: string;
color: string;
}
export const NETWORK_CONFIGS: Record<LuxNetwork, LuxNetworkConfig> = {
mainnet: { networkId: 1, chainId: 96369, httpPort: 9630, stakingPort: 9631, namespace: "lux-mainnet", color: "#0066FF" },
testnet: { networkId: 2, chainId: 96368, httpPort: 9640, stakingPort: 9641, namespace: "lux-testnet", color: "#00CC66" },
devnet: { networkId: 3, chainId: 96370, httpPort: 9650, stakingPort: 9651, namespace: "lux-devnet", color: "#FF6600" },
};
export const NETWORK_NAMES: LuxNetwork[] = ["mainnet", "testnet", "devnet"] as const;
// ---------------------------------------------------------------------------
// API Client Helpers
// ---------------------------------------------------------------------------
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
function authHeaders(): HeadersInit {
const headers: HeadersInit = { "Content-Type": "application/json" };
if (typeof window !== "undefined") {
const token = localStorage.getItem("token");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
}
return headers;
}
async function request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: { ...authHeaders(), ...init?.headers },
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`${res.status} ${res.statusText}: ${body}`);
}
// 204 No Content
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
/** List all fleets, optionally filtered by cluster. */
export async function fetchFleets(clusterId?: string) {
const qs = clusterId ? `?clusterId=${encodeURIComponent(clusterId)}` : "";
return request(`/api/fleets${qs}`);
}
/** Get a single fleet by cluster + network. */
export async function fetchFleet(clusterId: string, network: LuxNetwork) {
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}`);
}
/** Create a new fleet. */
export async function createFleet(params: Record<string, unknown>) {
return request("/api/fleets", {
method: "POST",
body: JSON.stringify(params),
});
}
/** Update an existing fleet. */
export async function updateFleet(
clusterId: string,
network: LuxNetwork,
params: Record<string, unknown>,
) {
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}`, {
method: "PUT",
body: JSON.stringify(params),
});
}
/** Delete a fleet. */
export async function deleteFleet(clusterId: string, network: LuxNetwork) {
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}`, {
method: "DELETE",
});
}
/** Scale a fleet to the given replica count. */
export async function scaleFleet(
clusterId: string,
network: LuxNetwork,
replicas: number,
) {
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}/scale`, {
method: "POST",
body: JSON.stringify({ replicas }),
});
}
/** Restart all nodes in a fleet. */
export async function restartFleet(clusterId: string, network: LuxNetwork) {
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}/restart`, {
method: "POST",
});
}
/** Fetch aggregate stats across all fleets. */
export async function fetchStats() {
return request("/api/stats");
}
/** Fetch available Lux networks. */
export async function fetchNetworks() {
return request("/api/networks");
}