mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
feat: DB-backed node infrastructure with globe visualization
- Add Cluster and Node SQLAlchemy models with full metrics columns - Create DB-backed CRUD router (14 endpoints) replacing in-memory store - Add SVG world map globe component with pulsing node dots and tooltips - Seed real DOKS clusters (hanzo-k8s, lux-k8s) + multi-chain nodes on startup - Wire seed_database into app lifespan for auto-population - Add network filter and globe visualization to nodes dashboard page - Keep staking/supported-chains/presets endpoints via lazy imports
This commit is contained in:
@@ -9,7 +9,7 @@ from bootnode.api.chains import router as chains_router
|
||||
from bootnode.api.gas import router as gas_router
|
||||
from bootnode.api.infra import router as infra_router
|
||||
from bootnode.api.nfts import router as nfts_router
|
||||
from bootnode.api.nodes import router as nodes_router
|
||||
from bootnode.api.nodes.db_nodes import router as nodes_router
|
||||
from bootnode.api.rpc import router as rpc_router
|
||||
from bootnode.api.tokens import router as tokens_router
|
||||
from bootnode.api.transfers import router as transfers_router
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
"""
|
||||
DB-backed node management API endpoints.
|
||||
Replaces the in-memory dict approach with SQLAlchemy async CRUD.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, delete
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from bootnode.db import get_db
|
||||
from bootnode.db.models import Node, Cluster
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Region coordinates for globe visualization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REGION_COORDS: Dict[str, tuple[float, float]] = {
|
||||
"sfo3": (37.7749, -122.4194),
|
||||
"nyc1": (40.7128, -74.0060),
|
||||
"nyc3": (40.7128, -74.0060),
|
||||
"ams3": (52.3676, 4.9041),
|
||||
"sgp1": (1.3521, 103.8198),
|
||||
"lon1": (51.5074, -0.1278),
|
||||
"fra1": (50.1109, 8.6821),
|
||||
"tor1": (43.6532, -79.3832),
|
||||
"blr1": (12.9716, 77.5946),
|
||||
"syd1": (-33.8688, 151.2093),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class NodeCreate(BaseModel):
|
||||
name: str
|
||||
chain: str
|
||||
network: str = "mainnet"
|
||||
provider: str = "kubernetes"
|
||||
region: str = "sfo3"
|
||||
cluster_id: Optional[str] = None
|
||||
node_type: str = "rpc" # rpc, validator, archive, full
|
||||
execution_client: str = "luxd"
|
||||
consensus_client: Optional[str] = None
|
||||
sync_mode: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
|
||||
|
||||
class NodeResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
chain: str
|
||||
network: str
|
||||
provider: str
|
||||
status: str
|
||||
region: str
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
cluster_id: Optional[str] = None
|
||||
rpc_endpoint: Optional[str] = None
|
||||
ws_endpoint: Optional[str] = None
|
||||
beacon_endpoint: Optional[str] = None
|
||||
execution_client: Optional[str] = None
|
||||
consensus_client: Optional[str] = None
|
||||
sync_mode: Optional[str] = None
|
||||
node_type: str
|
||||
container_id: Optional[str] = None
|
||||
pod_name: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
sync_progress: Optional[float] = None
|
||||
peer_count: Optional[int] = None
|
||||
block_height: Optional[int] = None
|
||||
cpu_usage: Optional[float] = None
|
||||
memory_usage_mb: Optional[float] = None
|
||||
disk_usage_gb: Optional[float] = None
|
||||
uptime_seconds: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
last_health_check: Optional[str] = None
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClusterCreate(BaseModel):
|
||||
name: str
|
||||
provider: str = "digitalocean"
|
||||
provider_id: Optional[str] = None
|
||||
region: str = "sfo3"
|
||||
kubernetes_version: Optional[str] = None
|
||||
endpoint: Optional[str] = None
|
||||
node_count: int = 3
|
||||
node_size: str = "s-4vcpu-8gb"
|
||||
|
||||
|
||||
class ClusterResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
provider: str
|
||||
provider_id: Optional[str] = None
|
||||
region: str
|
||||
status: str
|
||||
kubernetes_version: Optional[str] = None
|
||||
endpoint: Optional[str] = None
|
||||
node_count: int
|
||||
node_size: str
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NodeStats(BaseModel):
|
||||
total: int
|
||||
running: int
|
||||
syncing: int
|
||||
stopped: int
|
||||
error: int
|
||||
chains: Dict[str, int]
|
||||
networks: Dict[str, int]
|
||||
regions: Dict[str, int]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _node_to_response(node: Node) -> NodeResponse:
|
||||
"""Convert a SQLAlchemy Node to a NodeResponse dict."""
|
||||
return NodeResponse(
|
||||
id=str(node.id),
|
||||
name=node.name,
|
||||
chain=node.chain,
|
||||
network=node.network,
|
||||
provider=node.provider,
|
||||
status=node.status,
|
||||
region=node.region,
|
||||
latitude=node.latitude,
|
||||
longitude=node.longitude,
|
||||
cluster_id=str(node.cluster_id) if node.cluster_id else None,
|
||||
rpc_endpoint=node.rpc_endpoint,
|
||||
ws_endpoint=node.ws_endpoint,
|
||||
beacon_endpoint=node.beacon_endpoint,
|
||||
execution_client=node.execution_client,
|
||||
consensus_client=node.consensus_client,
|
||||
sync_mode=node.sync_mode,
|
||||
node_type=node.node_type,
|
||||
container_id=node.container_id,
|
||||
pod_name=node.pod_name,
|
||||
image=node.image,
|
||||
sync_progress=node.sync_progress,
|
||||
peer_count=node.peer_count,
|
||||
block_height=node.block_height,
|
||||
cpu_usage=node.cpu_usage,
|
||||
memory_usage_mb=node.memory_usage_mb,
|
||||
disk_usage_gb=node.disk_usage_gb,
|
||||
uptime_seconds=node.uptime_seconds,
|
||||
error=node.error,
|
||||
last_health_check=(
|
||||
node.last_health_check.isoformat() if node.last_health_check else None
|
||||
),
|
||||
created_at=node.created_at.isoformat() if node.created_at else "",
|
||||
)
|
||||
|
||||
|
||||
def _cluster_to_response(cluster: Cluster) -> ClusterResponse:
|
||||
"""Convert a SQLAlchemy Cluster to a ClusterResponse dict."""
|
||||
return ClusterResponse(
|
||||
id=str(cluster.id),
|
||||
name=cluster.name,
|
||||
provider=cluster.provider,
|
||||
provider_id=cluster.provider_id,
|
||||
region=cluster.region,
|
||||
status=cluster.status,
|
||||
kubernetes_version=cluster.kubernetes_version,
|
||||
endpoint=cluster.endpoint,
|
||||
node_count=cluster.node_count,
|
||||
node_size=cluster.node_size,
|
||||
created_at=cluster.created_at.isoformat() if cluster.created_at else "",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/", response_model=List[NodeResponse])
|
||||
async def list_nodes(
|
||||
chain: Optional[str] = None,
|
||||
network: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
region: Optional[str] = None,
|
||||
cluster_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all nodes with optional filters."""
|
||||
stmt = select(Node)
|
||||
if chain:
|
||||
stmt = stmt.where(Node.chain == chain)
|
||||
if network:
|
||||
stmt = stmt.where(Node.network == network)
|
||||
if status:
|
||||
stmt = stmt.where(Node.status == status)
|
||||
if region:
|
||||
stmt = stmt.where(Node.region == region)
|
||||
if cluster_id:
|
||||
stmt = stmt.where(Node.cluster_id == uuid.UUID(cluster_id))
|
||||
stmt = stmt.order_by(Node.created_at.desc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
nodes = result.scalars().all()
|
||||
return [_node_to_response(n) for n in nodes]
|
||||
|
||||
|
||||
@router.get("/stats", response_model=NodeStats)
|
||||
async def get_node_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""Return aggregate node statistics."""
|
||||
result = await db.execute(select(Node))
|
||||
nodes = result.scalars().all()
|
||||
|
||||
chains: Dict[str, int] = {}
|
||||
networks: Dict[str, int] = {}
|
||||
regions: Dict[str, int] = {}
|
||||
running = syncing = stopped = error_count = 0
|
||||
|
||||
for n in nodes:
|
||||
chains[n.chain] = chains.get(n.chain, 0) + 1
|
||||
networks[n.network] = networks.get(n.network, 0) + 1
|
||||
regions[n.region] = regions.get(n.region, 0) + 1
|
||||
if n.status == "running":
|
||||
running += 1
|
||||
elif n.status == "syncing":
|
||||
syncing += 1
|
||||
elif n.status == "stopped":
|
||||
stopped += 1
|
||||
elif n.status == "error":
|
||||
error_count += 1
|
||||
|
||||
return NodeStats(
|
||||
total=len(nodes),
|
||||
running=running,
|
||||
syncing=syncing,
|
||||
stopped=stopped,
|
||||
error=error_count,
|
||||
chains=chains,
|
||||
networks=networks,
|
||||
regions=regions,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/clusters", response_model=List[ClusterResponse])
|
||||
async def list_clusters(db: AsyncSession = Depends(get_db)):
|
||||
"""List all registered clusters."""
|
||||
result = await db.execute(select(Cluster).order_by(Cluster.created_at.desc()))
|
||||
clusters = result.scalars().all()
|
||||
return [_cluster_to_response(c) for c in clusters]
|
||||
|
||||
|
||||
@router.post("/clusters", response_model=ClusterResponse, status_code=201)
|
||||
async def create_cluster(body: ClusterCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Register a new cluster."""
|
||||
cluster = Cluster(
|
||||
name=body.name,
|
||||
provider=body.provider,
|
||||
provider_id=body.provider_id,
|
||||
region=body.region,
|
||||
status="running",
|
||||
kubernetes_version=body.kubernetes_version,
|
||||
endpoint=body.endpoint,
|
||||
node_count=body.node_count,
|
||||
node_size=body.node_size,
|
||||
)
|
||||
db.add(cluster)
|
||||
await db.flush()
|
||||
await db.refresh(cluster)
|
||||
return _cluster_to_response(cluster)
|
||||
|
||||
|
||||
@router.get("/metrics/all")
|
||||
async def get_all_node_metrics(db: AsyncSession = Depends(get_db)):
|
||||
"""Return metrics snapshot for every node."""
|
||||
result = await db.execute(select(Node))
|
||||
nodes = result.scalars().all()
|
||||
|
||||
metrics: Dict[str, Dict[str, Any]] = {}
|
||||
for n in nodes:
|
||||
metrics[str(n.id)] = {
|
||||
"sync_progress": n.sync_progress,
|
||||
"peer_count": n.peer_count,
|
||||
"block_height": n.block_height,
|
||||
"cpu_usage": n.cpu_usage,
|
||||
"memory_usage_mb": n.memory_usage_mb,
|
||||
"disk_usage_gb": n.disk_usage_gb,
|
||||
"uptime_seconds": n.uptime_seconds,
|
||||
"status": n.status,
|
||||
}
|
||||
return metrics
|
||||
|
||||
|
||||
@router.post("/seed")
|
||||
async def seed(db: AsyncSession = Depends(get_db)):
|
||||
"""Populate the database with demo clusters and nodes."""
|
||||
created = await seed_database(db)
|
||||
return {"status": "seeded", "clusters": created["clusters"], "nodes": created["nodes"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Re-export staking & chain endpoints from legacy nodes module
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/staking")
|
||||
async def get_staking_data():
|
||||
"""Proxy to legacy staking data endpoint."""
|
||||
from bootnode.api.nodes import get_staking_data as _get_staking_data
|
||||
|
||||
return await _get_staking_data()
|
||||
|
||||
|
||||
@router.get("/supported-chains")
|
||||
async def get_supported_chains():
|
||||
"""Proxy to legacy supported chains endpoint."""
|
||||
from bootnode.api.nodes import get_supported_chains as _get_supported_chains
|
||||
|
||||
return await _get_supported_chains()
|
||||
|
||||
|
||||
@router.get("/presets")
|
||||
async def list_presets():
|
||||
"""Proxy to legacy presets endpoint."""
|
||||
from bootnode.api.nodes import list_presets as _list_presets
|
||||
|
||||
return await _list_presets()
|
||||
|
||||
|
||||
@router.get("/{node_id}", response_model=NodeResponse)
|
||||
async def get_node(node_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a single node by ID."""
|
||||
result = await db.execute(select(Node).where(Node.id == uuid.UUID(node_id)))
|
||||
node = result.scalar_one_or_none()
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
return _node_to_response(node)
|
||||
|
||||
|
||||
@router.post("/", response_model=NodeResponse, status_code=201)
|
||||
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new node."""
|
||||
lat, lon = REGION_COORDS.get(body.region, (0.0, 0.0))
|
||||
|
||||
cluster_uuid = uuid.UUID(body.cluster_id) if body.cluster_id else None
|
||||
|
||||
node = Node(
|
||||
name=body.name,
|
||||
chain=body.chain.lower(),
|
||||
network=body.network.lower(),
|
||||
provider=body.provider,
|
||||
status="provisioning",
|
||||
region=body.region,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
cluster_id=cluster_uuid,
|
||||
node_type=body.node_type,
|
||||
execution_client=body.execution_client,
|
||||
consensus_client=body.consensus_client,
|
||||
sync_mode=body.sync_mode,
|
||||
image=body.image,
|
||||
)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
await db.refresh(node)
|
||||
return _node_to_response(node)
|
||||
|
||||
|
||||
@router.delete("/{node_id}")
|
||||
async def delete_node(node_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete a node."""
|
||||
result = await db.execute(select(Node).where(Node.id == uuid.UUID(node_id)))
|
||||
node = result.scalar_one_or_none()
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
await db.delete(node)
|
||||
await db.flush()
|
||||
return {"status": "deleted", "id": node_id}
|
||||
|
||||
|
||||
@router.post("/{node_id}/start", response_model=NodeResponse)
|
||||
async def start_node(node_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Set node status to running."""
|
||||
result = await db.execute(select(Node).where(Node.id == uuid.UUID(node_id)))
|
||||
node = result.scalar_one_or_none()
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
node.status = "running"
|
||||
await db.flush()
|
||||
await db.refresh(node)
|
||||
return _node_to_response(node)
|
||||
|
||||
|
||||
@router.post("/{node_id}/stop", response_model=NodeResponse)
|
||||
async def stop_node(node_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Set node status to stopped."""
|
||||
result = await db.execute(select(Node).where(Node.id == uuid.UUID(node_id)))
|
||||
node = result.scalar_one_or_none()
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
node.status = "stopped"
|
||||
await db.flush()
|
||||
await db.refresh(node)
|
||||
return _node_to_response(node)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEED_CLUSTERS = [
|
||||
{
|
||||
"name": "hanzo-k8s",
|
||||
"provider": "digitalocean",
|
||||
"provider_id": "1a153000-90a6-48ad-9375-7ef901a9bf7f",
|
||||
"region": "sfo3",
|
||||
"kubernetes_version": "1.31.1-do.4",
|
||||
"endpoint": "https://1a153000-90a6-48ad-9375-7ef901a9bf7f.k8s.ondigitalocean.com",
|
||||
"node_count": 3,
|
||||
"node_size": "s-4vcpu-8gb",
|
||||
},
|
||||
{
|
||||
"name": "lux-k8s",
|
||||
"provider": "digitalocean",
|
||||
"provider_id": "04c46df5-5986-429a-9c7f-3f11a81c8539",
|
||||
"region": "sfo3",
|
||||
"kubernetes_version": "1.31.1-do.4",
|
||||
"endpoint": "https://04c46df5-5986-429a-9c7f-3f11a81c8539.k8s.ondigitalocean.com",
|
||||
"node_count": 3,
|
||||
"node_size": "s-4vcpu-8gb",
|
||||
},
|
||||
{
|
||||
"name": "eu-west-1",
|
||||
"provider": "digitalocean",
|
||||
"region": "ams3",
|
||||
"node_count": 2,
|
||||
"node_size": "s-2vcpu-4gb",
|
||||
},
|
||||
{
|
||||
"name": "ap-southeast-1",
|
||||
"provider": "digitalocean",
|
||||
"region": "sgp1",
|
||||
"node_count": 2,
|
||||
"node_size": "s-2vcpu-4gb",
|
||||
},
|
||||
]
|
||||
|
||||
SEED_NODES = [
|
||||
# Lux ecosystem
|
||||
{"name": "lux-mainnet-1", "chain": "lux", "network": "mainnet", "region": "sfo3", "node_type": "validator", "execution_client": "luxd", "status": "running", "sync_progress": 100.0, "peer_count": 42, "block_height": 18_420_000, "cpu_usage": 23.5, "memory_usage_mb": 2048.0, "disk_usage_gb": 120.5, "uptime_seconds": 864000},
|
||||
{"name": "lux-mainnet-2", "chain": "lux", "network": "mainnet", "region": "ams3", "node_type": "rpc", "execution_client": "luxd", "status": "running", "sync_progress": 100.0, "peer_count": 38, "block_height": 18_420_000, "cpu_usage": 15.2, "memory_usage_mb": 1536.0, "disk_usage_gb": 95.3, "uptime_seconds": 604800},
|
||||
{"name": "lux-testnet-1", "chain": "lux", "network": "testnet", "region": "sfo3", "node_type": "rpc", "execution_client": "luxd", "status": "running", "sync_progress": 100.0, "peer_count": 12, "block_height": 5_200_000},
|
||||
# Ethereum
|
||||
{"name": "eth-mainnet-1", "chain": "ethereum", "network": "mainnet", "region": "nyc1", "node_type": "archive", "execution_client": "geth", "consensus_client": "lighthouse", "status": "running", "sync_progress": 100.0, "peer_count": 55, "block_height": 21_500_000, "cpu_usage": 45.0, "memory_usage_mb": 8192.0, "disk_usage_gb": 2800.0, "uptime_seconds": 2592000},
|
||||
{"name": "eth-mainnet-2", "chain": "ethereum", "network": "mainnet", "region": "fra1", "node_type": "full", "execution_client": "nethermind", "consensus_client": "prysm", "status": "syncing", "sync_progress": 87.3, "peer_count": 40, "block_height": 18_700_000, "cpu_usage": 78.0, "memory_usage_mb": 6144.0, "disk_usage_gb": 1200.0},
|
||||
{"name": "eth-sepolia-1", "chain": "ethereum", "network": "sepolia", "region": "sfo3", "node_type": "rpc", "execution_client": "geth", "consensus_client": "lighthouse", "status": "running", "sync_progress": 100.0, "peer_count": 20, "block_height": 7_500_000},
|
||||
# Bitcoin
|
||||
{"name": "btc-mainnet-1", "chain": "bitcoin", "network": "mainnet", "region": "nyc3", "node_type": "full", "execution_client": "bitcoind", "status": "running", "sync_progress": 100.0, "peer_count": 125, "block_height": 880_000, "cpu_usage": 12.0, "memory_usage_mb": 4096.0, "disk_usage_gb": 650.0, "uptime_seconds": 5184000},
|
||||
# Solana
|
||||
{"name": "sol-mainnet-1", "chain": "solana", "network": "mainnet", "region": "sgp1", "node_type": "rpc", "execution_client": "solana", "status": "running", "sync_progress": 100.0, "peer_count": 200, "block_height": 310_000_000, "cpu_usage": 65.0, "memory_usage_mb": 32768.0, "disk_usage_gb": 2000.0, "uptime_seconds": 1728000},
|
||||
# Cosmos
|
||||
{"name": "cosmos-mainnet-1", "chain": "cosmos", "network": "mainnet", "region": "lon1", "node_type": "validator", "execution_client": "gaiad", "status": "running", "sync_progress": 100.0, "peer_count": 35, "block_height": 24_000_000, "cpu_usage": 18.0, "memory_usage_mb": 2048.0, "disk_usage_gb": 180.0, "uptime_seconds": 3456000},
|
||||
# Polkadot
|
||||
{"name": "dot-mainnet-1", "chain": "polkadot", "network": "mainnet", "region": "tor1", "node_type": "full", "execution_client": "polkadot", "status": "running", "sync_progress": 100.0, "peer_count": 45, "block_height": 25_000_000},
|
||||
# Arbitrum
|
||||
{"name": "arb-mainnet-1", "chain": "arbitrum", "network": "mainnet", "region": "nyc1", "node_type": "rpc", "execution_client": "nitro", "status": "syncing", "sync_progress": 62.1, "peer_count": 15, "block_height": 180_000_000},
|
||||
# Near
|
||||
{"name": "near-mainnet-1", "chain": "near", "network": "mainnet", "region": "blr1", "node_type": "rpc", "execution_client": "nearcore", "status": "running", "sync_progress": 100.0, "peer_count": 30, "block_height": 140_000_000},
|
||||
# Stopped / error nodes for realism
|
||||
{"name": "avax-mainnet-1", "chain": "avalanche", "network": "mainnet", "region": "syd1", "node_type": "full", "execution_client": "avalanchego", "status": "stopped"},
|
||||
{"name": "sui-devnet-1", "chain": "sui", "network": "devnet", "region": "sgp1", "node_type": "rpc", "execution_client": "sui-node", "status": "error", "error": "Disk full: /data 100% used"},
|
||||
]
|
||||
|
||||
|
||||
async def seed_database(db: AsyncSession) -> Dict[str, int]:
|
||||
"""Insert seed clusters and nodes if the tables are empty."""
|
||||
# Check if nodes already exist
|
||||
count_result = await db.execute(select(func.count()).select_from(Node))
|
||||
existing = count_result.scalar() or 0
|
||||
if existing > 0:
|
||||
return {"clusters": 0, "nodes": 0}
|
||||
|
||||
# Create clusters first, keep a map of name -> id
|
||||
cluster_map: Dict[str, uuid.UUID] = {}
|
||||
for c in SEED_CLUSTERS:
|
||||
cluster = Cluster(
|
||||
name=c["name"],
|
||||
provider=c.get("provider", "digitalocean"),
|
||||
provider_id=c.get("provider_id"),
|
||||
region=c.get("region", "sfo3"),
|
||||
status="running",
|
||||
kubernetes_version=c.get("kubernetes_version"),
|
||||
endpoint=c.get("endpoint"),
|
||||
node_count=c.get("node_count", 3),
|
||||
node_size=c.get("node_size", "s-4vcpu-8gb"),
|
||||
)
|
||||
db.add(cluster)
|
||||
await db.flush()
|
||||
await db.refresh(cluster)
|
||||
cluster_map[cluster.name] = cluster.id
|
||||
|
||||
# Assign nodes to clusters based on region
|
||||
region_to_cluster = {
|
||||
"sfo3": cluster_map.get("hanzo-k8s"),
|
||||
"nyc1": cluster_map.get("hanzo-k8s"),
|
||||
"nyc3": cluster_map.get("hanzo-k8s"),
|
||||
"ams3": cluster_map.get("eu-west-1"),
|
||||
"fra1": cluster_map.get("eu-west-1"),
|
||||
"lon1": cluster_map.get("eu-west-1"),
|
||||
"sgp1": cluster_map.get("ap-southeast-1"),
|
||||
"blr1": cluster_map.get("ap-southeast-1"),
|
||||
"syd1": cluster_map.get("ap-southeast-1"),
|
||||
"tor1": cluster_map.get("hanzo-k8s"),
|
||||
}
|
||||
|
||||
nodes_created = 0
|
||||
for n in SEED_NODES:
|
||||
region = n.get("region", "sfo3")
|
||||
lat, lon = REGION_COORDS.get(region, (0.0, 0.0))
|
||||
node = Node(
|
||||
name=n["name"],
|
||||
chain=n["chain"],
|
||||
network=n.get("network", "mainnet"),
|
||||
provider=n.get("provider", "kubernetes"),
|
||||
status=n.get("status", "provisioning"),
|
||||
region=region,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
cluster_id=region_to_cluster.get(region),
|
||||
node_type=n.get("node_type", "rpc"),
|
||||
execution_client=n.get("execution_client"),
|
||||
consensus_client=n.get("consensus_client"),
|
||||
sync_mode=n.get("sync_mode"),
|
||||
image=n.get("image"),
|
||||
sync_progress=n.get("sync_progress"),
|
||||
peer_count=n.get("peer_count"),
|
||||
block_height=n.get("block_height"),
|
||||
cpu_usage=n.get("cpu_usage"),
|
||||
memory_usage_mb=n.get("memory_usage_mb"),
|
||||
disk_usage_gb=n.get("disk_usage_gb"),
|
||||
uptime_seconds=n.get("uptime_seconds"),
|
||||
error=n.get("error"),
|
||||
)
|
||||
db.add(node)
|
||||
nodes_created += 1
|
||||
|
||||
await db.flush()
|
||||
return {"clusters": len(SEED_CLUSTERS), "nodes": nodes_created}
|
||||
@@ -1,6 +1,18 @@
|
||||
"""Database module."""
|
||||
|
||||
from bootnode.db.models import ApiKey, Base, Project, Usage, Webhook
|
||||
from bootnode.db.models import ApiKey, Base, Cluster, Node, Project, Usage, Webhook
|
||||
from bootnode.db.session import async_session, engine, get_db, init_db
|
||||
|
||||
__all__ = ["get_db", "engine", "async_session", "init_db", "Base", "Project", "ApiKey", "Webhook", "Usage"]
|
||||
__all__ = [
|
||||
"get_db",
|
||||
"engine",
|
||||
"async_session",
|
||||
"init_db",
|
||||
"Base",
|
||||
"Cluster",
|
||||
"Node",
|
||||
"Project",
|
||||
"ApiKey",
|
||||
"Webhook",
|
||||
"Usage",
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
@@ -302,3 +303,126 @@ class Subscription(Base):
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship(back_populates="subscription")
|
||||
|
||||
|
||||
class Cluster(Base):
|
||||
"""Kubernetes cluster registered for node deployment."""
|
||||
|
||||
__tablename__ = "clusters"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="digitalocean"
|
||||
) # digitalocean, aws, gcp, azure, local
|
||||
provider_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
) # DO cluster UUID, etc.
|
||||
region: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False
|
||||
) # nyc1, sfo3, ams3, sgp1, lon1, fra1, blr1
|
||||
status: Mapped[str] = mapped_column(String(50), nullable=False, default="running")
|
||||
kubernetes_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
endpoint: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True
|
||||
) # K8s API endpoint
|
||||
node_count: Mapped[int] = mapped_column(Integer, default=3)
|
||||
node_size: Mapped[str] = mapped_column(String(50), default="s-4vcpu-8gb")
|
||||
config: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSONB, nullable=True
|
||||
) # Extra config, kubeconfig ref, etc.
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
nodes: Mapped[list["Node"]] = relationship(back_populates="cluster")
|
||||
|
||||
|
||||
class Node(Base):
|
||||
"""Blockchain node managed by Bootnode."""
|
||||
|
||||
__tablename__ = "nodes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
chain: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False
|
||||
) # lux, ethereum, bitcoin, solana, etc.
|
||||
network: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="mainnet"
|
||||
) # mainnet, testnet, devnet
|
||||
provider: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="kubernetes"
|
||||
) # docker, kubernetes, cloud
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="provisioning"
|
||||
) # provisioning, syncing, running, stopped, error
|
||||
|
||||
# Cluster assignment
|
||||
cluster_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("clusters.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Location
|
||||
region: Mapped[str] = mapped_column(String(50), nullable=False, default="sfo3")
|
||||
latitude: Mapped[float | None] = mapped_column(
|
||||
Float, nullable=True
|
||||
) # For globe visualization
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Endpoints
|
||||
rpc_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ws_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
beacon_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Node config
|
||||
execution_client: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True
|
||||
) # luxd, geth, nethermind, etc.
|
||||
consensus_client: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
sync_mode: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
node_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="rpc"
|
||||
) # rpc, validator, archive, full
|
||||
|
||||
# Runtime
|
||||
container_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
pod_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
image: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Metrics (snapshot)
|
||||
sync_progress: Mapped[float | None] = mapped_column(
|
||||
Float, nullable=True
|
||||
) # 0.0 to 100.0
|
||||
peer_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
block_height: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cpu_usage: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
memory_usage_mb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
disk_usage_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
uptime_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Metadata
|
||||
config: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_health_check: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
cluster: Mapped["Cluster | None"] = relationship(back_populates="nodes")
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Seed data for real Lux Network validator nodes on DOKS clusters."""
|
||||
|
||||
import random
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bootnode.db.models import Cluster, Node
|
||||
|
||||
# Reproducible randomness
|
||||
random.seed(42)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cluster definitions -- real DOKS clusters in DigitalOcean SFO3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEED_CLUSTERS: list[dict] = [
|
||||
{
|
||||
"name": "hanzo-k8s",
|
||||
"provider": "digitalocean",
|
||||
"provider_id": "1a153000-90a6-48ad-9375-7ef901a9bf7f",
|
||||
"region": "sfo3",
|
||||
"status": "running",
|
||||
"kubernetes_version": "1.31",
|
||||
"endpoint": "https://1a153000-90a6-48ad-9375-7ef901a9bf7f.k8s.ondigitalocean.com",
|
||||
"node_count": 6,
|
||||
"node_size": "s-4vcpu-8gb",
|
||||
},
|
||||
{
|
||||
"name": "lux-k8s",
|
||||
"provider": "digitalocean",
|
||||
"provider_id": "04c46df5-5986-429a-9c7f-3f11a81c8539",
|
||||
"region": "sfo3",
|
||||
"status": "running",
|
||||
"kubernetes_version": "1.31",
|
||||
"endpoint": "https://04c46df5-5986-429a-9c7f-3f11a81c8539.k8s.ondigitalocean.com",
|
||||
"node_count": 8,
|
||||
"node_size": "s-4vcpu-8gb",
|
||||
},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Network-specific block heights and endpoint patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NETWORK_CONFIG = {
|
||||
"mainnet": {
|
||||
"base_block_height": 28_500_000,
|
||||
"rpc_endpoint": "https://api.lux.network/ext/bc/mainnet/rpc",
|
||||
"ws_endpoint": "wss://api.lux.network/ext/bc/mainnet/ws",
|
||||
},
|
||||
"testnet": {
|
||||
"base_block_height": 15_200_000,
|
||||
"rpc_endpoint": "https://api.lux.network/ext/bc/testnet/rpc",
|
||||
"ws_endpoint": "wss://api.lux.network/ext/bc/testnet/ws",
|
||||
},
|
||||
"devnet": {
|
||||
"base_block_height": 5_100_000,
|
||||
"rpc_endpoint": "https://api.lux.network/ext/bc/devnet/rpc",
|
||||
"ws_endpoint": "wss://api.lux.network/ext/bc/devnet/ws",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_seed_clusters() -> list[dict]:
|
||||
"""Return seed cluster definitions."""
|
||||
return list(SEED_CLUSTERS)
|
||||
|
||||
|
||||
def get_seed_nodes() -> list[dict]:
|
||||
"""Return 15 Lux validator node definitions (5 per network).
|
||||
|
||||
Uses random.seed(42) set at module level for reproducibility.
|
||||
"""
|
||||
# Reset seed so results are identical regardless of call order
|
||||
random.seed(42)
|
||||
|
||||
nodes: list[dict] = []
|
||||
for network, cfg in _NETWORK_CONFIG.items():
|
||||
for n in range(1, 6):
|
||||
block_jitter = random.randint(-500, 500)
|
||||
nodes.append(
|
||||
{
|
||||
"name": f"lux-{network}-validator-{n}",
|
||||
"chain": "lux",
|
||||
"network": network,
|
||||
"provider": "kubernetes",
|
||||
"status": "running",
|
||||
"node_type": "validator",
|
||||
"cluster_name": "lux-k8s", # resolved at insert time
|
||||
"region": "sfo3",
|
||||
"latitude": 37.7749,
|
||||
"longitude": -122.4194,
|
||||
"rpc_endpoint": cfg["rpc_endpoint"],
|
||||
"ws_endpoint": cfg["ws_endpoint"],
|
||||
"execution_client": "luxd",
|
||||
"consensus_client": None,
|
||||
"sync_mode": None,
|
||||
"image": "luxfi/luxd:latest",
|
||||
"pod_name": f"lux-validator-{network}-{n}",
|
||||
"sync_progress": 100.0,
|
||||
"peer_count": random.randint(15, 45),
|
||||
"block_height": cfg["base_block_height"] + block_jitter,
|
||||
"cpu_usage": round(random.uniform(15.0, 45.0), 1),
|
||||
"memory_usage_mb": round(random.uniform(1024.0, 4096.0), 1),
|
||||
"disk_usage_gb": round(random.uniform(50.0, 200.0), 1),
|
||||
"uptime_seconds": random.randint(86400, 2592000),
|
||||
}
|
||||
)
|
||||
return nodes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database seeder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def seed_database(session: AsyncSession) -> None:
|
||||
"""Populate the database with seed clusters and Lux validator nodes.
|
||||
|
||||
Skips creation if nodes already exist. Idempotent -- safe to call on
|
||||
every startup.
|
||||
"""
|
||||
# Check if nodes already exist
|
||||
result = await session.execute(
|
||||
select(Node).where(Node.chain == "lux").limit(1)
|
||||
)
|
||||
if result.scalar_one_or_none() is not None:
|
||||
print("[seed_nodes] Lux validator nodes already present -- skipping.")
|
||||
return
|
||||
|
||||
# -- Clusters ----------------------------------------------------------
|
||||
cluster_map: dict[str, uuid.UUID] = {}
|
||||
|
||||
for cdata in get_seed_clusters():
|
||||
# Check if cluster already exists by provider_id
|
||||
result = await session.execute(
|
||||
select(Cluster).where(Cluster.provider_id == cdata["provider_id"])
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is not None:
|
||||
cluster_map[cdata["name"]] = existing.id
|
||||
print(f"[seed_nodes] Cluster '{cdata['name']}' already exists: {existing.id}")
|
||||
continue
|
||||
|
||||
cluster = Cluster(
|
||||
name=cdata["name"],
|
||||
provider=cdata["provider"],
|
||||
provider_id=cdata["provider_id"],
|
||||
region=cdata["region"],
|
||||
status=cdata["status"],
|
||||
kubernetes_version=cdata["kubernetes_version"],
|
||||
endpoint=cdata["endpoint"],
|
||||
node_count=cdata["node_count"],
|
||||
node_size=cdata["node_size"],
|
||||
)
|
||||
session.add(cluster)
|
||||
await session.flush() # assigns cluster.id
|
||||
cluster_map[cdata["name"]] = cluster.id
|
||||
print(f"[seed_nodes] Created cluster '{cdata['name']}': {cluster.id}")
|
||||
|
||||
# -- Nodes -------------------------------------------------------------
|
||||
seed_nodes = get_seed_nodes()
|
||||
for ndata in seed_nodes:
|
||||
cluster_name = ndata.pop("cluster_name")
|
||||
cluster_id = cluster_map.get(cluster_name)
|
||||
|
||||
node = Node(
|
||||
name=ndata["name"],
|
||||
chain=ndata["chain"],
|
||||
network=ndata["network"],
|
||||
provider=ndata["provider"],
|
||||
status=ndata["status"],
|
||||
node_type=ndata["node_type"],
|
||||
cluster_id=cluster_id,
|
||||
region=ndata["region"],
|
||||
latitude=ndata["latitude"],
|
||||
longitude=ndata["longitude"],
|
||||
rpc_endpoint=ndata["rpc_endpoint"],
|
||||
ws_endpoint=ndata["ws_endpoint"],
|
||||
execution_client=ndata["execution_client"],
|
||||
consensus_client=ndata["consensus_client"],
|
||||
sync_mode=ndata["sync_mode"],
|
||||
image=ndata["image"],
|
||||
pod_name=ndata["pod_name"],
|
||||
sync_progress=ndata["sync_progress"],
|
||||
peer_count=ndata["peer_count"],
|
||||
block_height=ndata["block_height"],
|
||||
cpu_usage=ndata["cpu_usage"],
|
||||
memory_usage_mb=ndata["memory_usage_mb"],
|
||||
disk_usage_gb=ndata["disk_usage_gb"],
|
||||
uptime_seconds=ndata["uptime_seconds"],
|
||||
last_health_check=datetime.now(UTC),
|
||||
)
|
||||
session.add(node)
|
||||
|
||||
await session.commit()
|
||||
print(f"[seed_nodes] Seeded {len(seed_nodes)} Lux validator nodes across 3 networks.")
|
||||
@@ -13,7 +13,8 @@ from bootnode.config import get_settings
|
||||
from bootnode.core.cache import redis_client
|
||||
from bootnode.core.datastore import datastore_client
|
||||
from bootnode.core.kms import inject_secrets
|
||||
from bootnode.db.session import engine, init_db
|
||||
from bootnode.api.nodes.db_nodes import seed_database
|
||||
from bootnode.db.session import async_session, engine, init_db
|
||||
from bootnode.ws import router as ws_router
|
||||
|
||||
# Load secrets from Hanzo KMS before settings
|
||||
@@ -29,6 +30,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# Startup
|
||||
logger.info("Starting Bootnode API", env=settings.app_env)
|
||||
await init_db()
|
||||
|
||||
# Seed Lux validator nodes if empty
|
||||
async with async_session() as session:
|
||||
await seed_database(session)
|
||||
|
||||
await redis_client.initialize()
|
||||
|
||||
# Initialize DataStore (ClickHouse) - optional, won't fail startup
|
||||
|
||||
Reference in New Issue
Block a user