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:
2026-02-10 18:43:03 -08:00
parent 28555bb20d
commit 108e2cada6
9 changed files with 1387 additions and 9 deletions
+1 -1
View File
@@ -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
+562
View File
@@ -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}
+14 -2
View File
@@ -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",
]
+124
View File
@@ -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")
+200
View File
@@ -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.")
+7 -1
View File
@@ -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