merge bootnode/bootnode into this history — one repo, both halves

github.com/bootnode/bootnode is the canonical home. These two diverged from
766da81c with real work on BOTH sides — 21 commits there, 26 here — so this is
a merge, not a fork-kill. Deleting either side would have destroyed shipped
work.

From CANONICAL (bootnode/bootnode):
  · Base/SQLite storage, Postgres coupling dropped (5650324)
  · Cluster + Node models — models this side never defined
  · native CI: hanzo.yml, zero .github/workflows (a698acc, aff0952)
  · images to ghcr.io/bootnode/bootnode, its own org (9d0957c)
  · luxd fleet dashboard, k8s validator detector

From HERE (hanzoai/bootnode):
  · HIP-0111 IAM: SPA PKCE, JWKS token validation (ed27226),
    JWT audience enforcement (49eb8cb) — security features canonical lacks
  · runtime white-label brand catalog (422f8f1 + today's work)
  · static-export build (852bb7c)
  · today: toolchain pinned, static export via hanzoai/static, one API
    resolver, honest error states

Seven conflicts, each resolved on evidence, not preference:

  web/lib/brand.ts        OURS   — canonical still hardcodes
                                   bootnode → iam.url "https://hanzo.id",
                                   which is the "Sign in to Hanzo ID" leak
                                   fixed today. It also carries a duplicate
                                   `skillsOrg` field.
  web/Dockerfile          OURS   — canonical runs `pnpm config set
                                   dangerously-allow-all-builds true` AND
                                   `--no-frozen-lockfile`: disables the
                                   supply-chain guard and ignores the lockfile.
  db/models.py            THEIRS — has Cluster + Node and no Postgres dialect.
  db/__init__.py          THEIRS — matches those models.
  .github/workflows/*     DELETED— canonical already went native.
  api/bootnode/main.py    BOTH   — took `__version__` from canonical (better
                                   than the hardcoded "2.0.0" here) and kept
                                   the conditional `docs` from b06f09f
                                   (canonical re-adds "docs": "/docs", a URL
                                   production disables, so it 404s).

This drops a386c9b, the Node/MPCWalletModel commit written earlier today. Its
problem did not exist on the canonical side: those models are already there,
and there is no MPC router to need MPCWalletModel. It was written against the
storage layer canonical replaced in June.

Verified on the merged tree, not assumed: `import bootnode.db` succeeds and
exports 14 models, all 27 API routers import, 0 postgresql dialect imports
remain, web tsc reports 0 errors and the production build exits 0 with
localhost:8000 in 0 chunks.
This commit is contained in:
2026-07-26 14:18:12 -07:00
52 changed files with 3302 additions and 532 deletions
-146
View File
@@ -1,146 +0,0 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test-api:
runs-on: hanzo-build-linux-amd64
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: bootnode
POSTGRES_PASSWORD: bootnode
POSTGRES_DB: bootnode_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Set up Python
run: uv python install 3.12
- name: Install dependencies
working-directory: ./api
run: |
uv venv
uv pip install -e ".[dev]"
- name: Run tests
working-directory: ./api
env:
DATABASE_URL: postgresql://bootnode:bootnode@localhost:5432/bootnode_test
REDIS_URL: redis://localhost:6379
JWT_SECRET: test-secret-key-for-ci
API_KEY_SALT: test-salt-for-ci
run: |
.venv/bin/pytest -v --tb=short
test-web:
runs-on: hanzo-build-linux-amd64
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Install dependencies
working-directory: ./web
run: pnpm install --frozen-lockfile
- name: Type check & Build
working-directory: ./web
run: pnpm run build
e2e-test:
runs-on: hanzo-build-linux-amd64
needs: [test-api, test-web]
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: bootnode
POSTGRES_PASSWORD: bootnode
POSTGRES_DB: bootnode
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Set up Python
run: uv python install 3.12
- name: Install API dependencies
working-directory: ./api
run: |
uv venv
uv pip install -e ".[dev]"
- name: Start API server
working-directory: ./api
env:
DATABASE_URL: postgresql+asyncpg://bootnode:bootnode@localhost:5432/bootnode
REDIS_URL: redis://localhost:6379
JWT_SECRET: test-secret-key-for-ci
API_KEY_SALT: test-salt-for-ci
run: |
.venv/bin/python -c "from bootnode.db import init_db; import asyncio; asyncio.run(init_db())"
.venv/bin/uvicorn bootnode.main:app --host 0.0.0.0 --port 8100 &
sleep 10
- name: Run E2E tests
run: bash test-e2e.sh
# Build, push, and deploy are handled by docker-release.yml
# which triggers on CI completion via workflow_run
-63
View File
@@ -1,63 +0,0 @@
# Deployment managed by hanzoai/universe — this workflow only builds and pushes images
name: Docker Release
on:
# Only build after CI passes on main
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
# Tags build directly (release cuts)
push:
tags: ["v*"]
# Manual override
workflow_dispatch:
concurrency:
group: docker-${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }}
cancel-in-progress: false
permissions:
contents: read
packages: write
id-token: write
jobs:
# Gate: only proceed if CI passed (skip check for tags/manual)
gate:
runs-on: hanzo-build-linux-amd64
if: >-
github.event_name != 'workflow_run' ||
github.event.workflow_run.conclusion == 'success'
steps:
- run: echo "CI passed — proceeding with Docker build"
# Build API image via standard Hanzo reusable workflow
# NOTE: pushed under the `bootnode` package with `-api` tag suffix so it
# doesn't collide with web's tags in the same package.
api:
needs: gate
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
with:
image: ghcr.io/hanzoai/bootnode
context: ./api
dockerfile: Dockerfile
platforms: "linux/amd64"
tag-suffix: -api
secrets: inherit
# Build Web image via standard Hanzo reusable workflow
# NOTE: pushed under the `bootnode` package (not `bootnode-web`) with `web-`
# tag prefix because GHCR auto-creation of `bootnode-web` package fails 403
# when the GH org has no policy allowing first-push package creation.
# Deployment image: ghcr.io/hanzoai/bootnode:web-<sha>|web-latest
web:
needs: gate
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
with:
image: ghcr.io/hanzoai/bootnode
context: ./web
dockerfile: Dockerfile
platforms: "linux/amd64"
tag-suffix: -web
secrets: inherit
+1 -1
View File
@@ -1,3 +1,3 @@
"""Bootnode - Blockchain Development Platform."""
__version__ = "2.0.0"
__version__ = "1.0.0"
+10
View File
@@ -21,6 +21,11 @@ from bootnode.api.networks import router as networks_router
from bootnode.api.observability import router as o11y_router
from bootnode.api.chat import router as chat_router
from bootnode.api.zap import router as zap_router
from bootnode.api.mpc import router as mpc_router
from bootnode.api.keys import router as keys_router
from bootnode.api.launch import router as launch_router
from bootnode.api.contracts import router as contracts_router
from bootnode.api.subgraphs import router as subgraphs_router
router = APIRouter()
@@ -44,3 +49,8 @@ router.include_router(team_router, prefix="/team", tags=["Team"])
router.include_router(networks_router, prefix="/networks", tags=["Networks"])
router.include_router(o11y_router, prefix="/o11y", tags=["Observability"])
router.include_router(chat_router, prefix="/chat", tags=["AI Chat"])
router.include_router(mpc_router, prefix="/mpc", tags=["MPC Wallets"])
router.include_router(keys_router, prefix="/keys", tags=["Key Management"])
router.include_router(launch_router, prefix="/launch", tags=["Launch"])
router.include_router(contracts_router, prefix="/contracts", tags=["Contracts"])
router.include_router(subgraphs_router, prefix="/subgraphs", tags=["Subgraphs"])
+105
View File
@@ -0,0 +1,105 @@
"""Contracts API — deploy and interact with smart contracts via chain RPC.
Deploy = broadcast a signed creation transaction; the mined receipt carries the
new contract address. Read = eth_call. Code check = eth_getCode. Signing stays
client-side (or via the MPC wallet API) — this module never holds private keys.
"""
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from bootnode.api.deps import ApiKeyDep
from bootnode.core.chains import ChainRegistry, RPCClient
from bootnode.core.chains.rpc import RPCError
router = APIRouter()
class DeployRequest(BaseModel):
"""A pre-signed contract-creation transaction to broadcast."""
signed_tx: str = Field(..., description="0x-prefixed signed creation transaction")
network: str = "mainnet"
class CallRequest(BaseModel):
"""A read-only contract call (eth_call)."""
to: str = Field(..., description="Contract address")
data: str = Field(..., description="0x-encoded calldata")
block: str = "latest"
network: str = "mainnet"
async def _rpc(chain: str, network: str, method: str, params: list[Any]) -> Any:
if not ChainRegistry.is_supported(chain, network):
raise HTTPException(400, f"Unsupported chain/network: {chain}/{network}")
try:
async with RPCClient(chain, network) as client:
return await client.call(method, params, 1)
except RPCError as e:
raise HTTPException(502, f"RPC error {e.code}: {e.message}")
except HTTPException:
raise
except Exception as e: # upstream RPC unreachable / retries exhausted
raise HTTPException(502, f"RPC call to {chain}/{network} failed: {type(e).__name__}")
@router.post("/{chain}/deploy")
async def deploy_contract(chain: str, body: DeployRequest, api_key: ApiKeyDep) -> dict:
"""Broadcast a signed contract-creation transaction.
Returns the transaction hash; poll the receipt to get the contract address
once the transaction is mined.
"""
tx_hash = await _rpc(chain, body.network, "eth_sendRawTransaction", [body.signed_tx])
return {
"chain": chain,
"network": body.network,
"txHash": tx_hash,
"receipt": f"/v1/contracts/{chain}/tx/{tx_hash}/receipt?network={body.network}",
}
@router.get("/{chain}/tx/{tx_hash}/receipt")
async def deploy_receipt(
chain: str, tx_hash: str, api_key: ApiKeyDep, network: str = "mainnet"
) -> dict:
"""Fetch a transaction receipt — `contractAddress` is set once a creation
transaction is mined."""
receipt = await _rpc(chain, network, "eth_getTransactionReceipt", [tx_hash])
if receipt is None:
return {"status": "pending", "txHash": tx_hash, "contractAddress": None}
return {
"status": "mined" if receipt.get("status") == "0x1" else "failed",
"txHash": tx_hash,
"contractAddress": receipt.get("contractAddress"),
"blockNumber": receipt.get("blockNumber"),
"gasUsed": receipt.get("gasUsed"),
}
@router.get("/{chain}/{address}/code")
async def contract_code(
chain: str, address: str, api_key: ApiKeyDep, network: str = "mainnet"
) -> dict:
"""eth_getCode — confirm a contract is deployed at an address."""
code = await _rpc(chain, network, "eth_getCode", [address, "latest"])
deployed = bool(code) and code != "0x"
return {
"chain": chain,
"address": address,
"deployed": deployed,
"codeSize": (len(code) - 2) // 2 if deployed else 0,
}
@router.post("/{chain}/call")
async def call_contract(chain: str, body: CallRequest, api_key: ApiKeyDep) -> dict:
"""eth_call — read-only contract method invocation."""
result = await _rpc(
chain, body.network, "eth_call", [{"to": body.to, "data": body.data}, body.block]
)
return {"chain": chain, "to": body.to, "result": result}
+25
View File
@@ -26,6 +26,8 @@ from bootnode.core.chains.fleet_models import (
FleetSummary,
FleetUpdate,
)
from bootnode.core.chains.lux_fleet import LuxFleetOverview, get_overview as get_lux_overview
from bootnode.core.iam import IAMUser, require_admin
from bootnode.db.models import OrgCluster, Project
from bootnode.db.session import get_db
@@ -247,6 +249,29 @@ async def list_chains() -> dict:
}
@router.get("/lux/overview", response_model=LuxFleetOverview)
async def lux_fleet_overview(
networks: Optional[str] = None,
_admin: IAMUser = Depends(require_admin),
) -> LuxFleetOverview:
"""Admin-only graphical fleet overview for the managed luxd fleet.
This is a cross-tenant INFRA view — validator counts, per-node block heights,
peer counts, node IDs, luxd version/plugin-set, and (MODE B) the operator CR
+ pod topology across devnet/testnet/mainnet. It is NOT customer data, so it
is gated SERVER-SIDE behind the IAM admin role (see ``require_admin``):
missing/invalid token → 401, authenticated non-admin → 403. Never expose it
on a customer-reachable surface without this gate.
``networks`` optionally narrows to a comma-separated subset
(e.g. ``?networks=mainnet,testnet``).
"""
selected = None
if networks:
selected = [n.strip() for n in networks.split(",") if n.strip()]
return await get_lux_overview(selected)
@router.get("/networks")
async def list_networks(chain: Optional[str] = None) -> dict:
"""List network configurations, optionally filtered by chain."""
+7 -7
View File
@@ -5,7 +5,7 @@ from sqlalchemy import select
from sqlalchemy import func as sa_func
from bootnode.api.deps import ApiKeyDep, DbDep, ProjectDep
from bootnode.db.models import MPCWalletModel
from bootnode.db.models import SmartWallet
router = APIRouter()
@@ -22,18 +22,18 @@ async def get_key_status(
Proxies to MPC wallet status data.
"""
result = await db.execute(
select(sa_func.count(MPCWalletModel.id)).where(
MPCWalletModel.project_id == project.id,
MPCWalletModel.status == "active",
select(sa_func.count(SmartWallet.id)).where(
SmartWallet.project_id == project.id,
SmartWallet.status == "active",
)
)
total = result.scalar() or 0
# Get first active wallet as default
first_result = await db.execute(
select(MPCWalletModel).where(
MPCWalletModel.project_id == project.id,
MPCWalletModel.status == "active",
select(SmartWallet).where(
SmartWallet.project_id == project.id,
SmartWallet.status == "active",
).limit(1)
)
default_wallet = first_result.scalar_one_or_none()
+6 -6
View File
@@ -145,8 +145,8 @@ def _generate_lux_network(cfg: ChainConfig, network: str, net: "NetworkSpec", na
lines += [
" ports:",
" http: 9650",
" staking: 9651",
" http: 9630",
" staking: 9631",
" consensus:",
f" sybilProtectionEnabled: {sybil}",
" storage:",
@@ -231,7 +231,7 @@ def _generate_lux_explorer(cfg: ChainConfig, network: str, net: "NetworkSpec", n
"""
ex = cfg.services.explorer
host = cfg.brand.domains.explorer or f"explorer.{network}.{cfg.chain.slug}.network"
rpc_url = net.rpc_url or f"http://{cfg.chain.slug}d-0.{cfg.chain.slug}d-headless.{namespace}.svc:9650/ext/bc/C/rpc"
rpc_url = net.rpc_url or f"http://{cfg.chain.slug}d-0.{cfg.chain.slug}d-headless.{namespace}.svc:9630/ext/bc/C/rpc"
port = ex.port
return f"""apiVersion: apps/v1
@@ -479,7 +479,7 @@ spec:
- name: CHAIN_ID
value: "{net.chain_id}"
- name: RPC_URL
value: "http://{cfg.chain.slug}d-0.{cfg.chain.slug}d:9650/ext/bc/C/rpc"
value: "http://{cfg.chain.slug}d-0.{cfg.chain.slug}d:9630/ext/bc/C/rpc"
- name: DRIP_AMOUNT
value: "{faucet.drip_amount}"
- name: RATE_LIMIT
@@ -584,8 +584,8 @@ def register_chain_networks(cfg: ChainConfig) -> None:
chain=slug,
network_id=net.network_id,
chain_id=net.chain_id,
http_port=9650,
staking_port=9651,
http_port=9630,
staking_port=9631,
namespace=cfg.namespace_for(name),
)
+26 -26
View File
@@ -18,7 +18,7 @@ from bootnode.core.mpc import (
decrypt_share,
encrypt_share,
)
from bootnode.db.models import MPCWalletModel
from bootnode.db.models import SmartWallet
logger = structlog.get_logger()
router = APIRouter()
@@ -85,7 +85,7 @@ async def create_mpc_wallet(
api_key: ApiKeyDep,
project: ProjectDep,
db: DbDep,
) -> MPCWalletModel:
) -> SmartWallet:
"""Create a new MPC wallet with threshold key shares."""
if request.threshold < 2:
raise HTTPException(400, "Threshold must be at least 2")
@@ -116,7 +116,7 @@ async def create_mpc_wallet(
"3": "backup",
}
wallet = MPCWalletModel(
wallet = SmartWallet(
project_id=project.id,
address=keypair.address,
chain=request.chain,
@@ -148,13 +148,13 @@ async def list_mpc_wallets(
project: ProjectDep,
db: DbDep,
chain: str | None = None,
) -> list[MPCWalletModel]:
) -> list[SmartWallet]:
"""List all MPC wallets for the project."""
query = select(MPCWalletModel).where(
MPCWalletModel.project_id == project.id
query = select(SmartWallet).where(
SmartWallet.project_id == project.id
)
if chain:
query = query.where(MPCWalletModel.chain == chain)
query = query.where(SmartWallet.chain == chain)
result = await db.execute(query)
return list(result.scalars().all())
@@ -168,19 +168,19 @@ async def get_key_status(
) -> MPCKeyStatusResponse:
"""Get MPC wallet status for the project (used by staking page)."""
result = await db.execute(
select(sa_func.count(MPCWalletModel.id)).where(
MPCWalletModel.project_id == project.id,
MPCWalletModel.status == "active",
select(sa_func.count(SmartWallet.id)).where(
SmartWallet.project_id == project.id,
SmartWallet.status == "active",
)
)
total = result.scalar() or 0
# Get first wallet as default
first = await db.execute(
select(MPCWalletModel)
select(SmartWallet)
.where(
MPCWalletModel.project_id == project.id,
MPCWalletModel.status == "active",
SmartWallet.project_id == project.id,
SmartWallet.status == "active",
)
.limit(1)
)
@@ -207,12 +207,12 @@ async def get_mpc_wallet(
api_key: ApiKeyDep,
project: ProjectDep,
db: DbDep,
) -> MPCWalletModel:
) -> SmartWallet:
"""Get MPC wallet details."""
result = await db.execute(
select(MPCWalletModel).where(
MPCWalletModel.address == address.lower(),
MPCWalletModel.project_id == project.id,
select(SmartWallet).where(
SmartWallet.address == address.lower(),
SmartWallet.project_id == project.id,
)
)
wallet = result.scalar_one_or_none()
@@ -231,9 +231,9 @@ async def sign_with_mpc(
) -> MPCSignResponse:
"""Sign a message or transaction using threshold shares."""
result = await db.execute(
select(MPCWalletModel).where(
MPCWalletModel.address == address.lower(),
MPCWalletModel.project_id == project.id,
select(SmartWallet).where(
SmartWallet.address == address.lower(),
SmartWallet.project_id == project.id,
)
)
db_wallet = result.scalar_one_or_none()
@@ -317,9 +317,9 @@ async def get_share_info(
) -> dict:
"""Get share holder info (not the actual shares)."""
result = await db.execute(
select(MPCWalletModel).where(
MPCWalletModel.address == address.lower(),
MPCWalletModel.project_id == project.id,
select(SmartWallet).where(
SmartWallet.address == address.lower(),
SmartWallet.project_id == project.id,
)
)
wallet = result.scalar_one_or_none()
@@ -345,9 +345,9 @@ async def decrypt_wallet_share(
) -> dict:
"""Decrypt a specific share for signing (requires auth)."""
result = await db.execute(
select(MPCWalletModel).where(
MPCWalletModel.address == address.lower(),
MPCWalletModel.project_id == project.id,
select(SmartWallet).where(
SmartWallet.address == address.lower(),
SmartWallet.project_id == project.id,
)
)
wallet = result.scalar_one_or_none()
+1 -1
View File
@@ -162,7 +162,7 @@ spec:
- name: ghcr-secret
containers:
- name: web
image: ghcr.io/hanzoai/bootnode:web-latest
image: ghcr.io/bootnode/bootnode:web-latest
imagePullPolicy: Always
ports:
- name: http
+204 -46
View File
@@ -11,8 +11,18 @@ from datetime import datetime
import docker
import os
import asyncio
import logging
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bootnode.db.session import get_db
from bootnode.db.models import Node as DBNode
from bootnode.core.k8s_nodes import detector
logger = logging.getLogger(__name__)
router = APIRouter()
# Node images by chain - execution and consensus clients
@@ -69,18 +79,20 @@ CHAIN_IMAGES = {
"blast": {
"execution": {"blast": "blastio/blast-geth:latest"},
},
# ============ HANZO ECOSYSTEM (Lux-based) ============
# ============ LUX ECOSYSTEM (luxd — Lux, Zoo, Pars, Hanzo) ============
# All four run the same luxd node (ghcr.io/luxfi/node). Only the primary
# network-id and genesis differ; Zoo/Pars/Hanzo are Lux-based sovereign L1s.
"lux": {
"execution": {"luxd": "luxfi/luxd:latest"},
"execution": {"luxd": "ghcr.io/luxfi/node:v1.30.14-dchain"},
},
"zoo": {
"execution": {"luxd": "luxfi/luxd:latest"}, # Zoo subnet on Lux
"execution": {"luxd": "ghcr.io/luxfi/node:v1.30.14-dchain"}, # Zoo: Lux-based sovereign L1
},
"pars": {
"execution": {"luxd": "luxfi/luxd:latest"}, # Pars subnet on Lux
"execution": {"luxd": "ghcr.io/luxfi/node:v1.30.14-dchain"}, # Pars: Lux-based sovereign L1
},
"hanzo": {
"execution": {"luxd": "luxfi/luxd:latest"}, # Hanzo subnet on Lux
"execution": {"luxd": "ghcr.io/luxfi/node:v1.30.14-dchain"}, # Hanzo: Lux-based sovereign L1
},
# ============ MAJOR L1s ============
"bitcoin": {
@@ -240,10 +252,10 @@ CHAIN_PORTS = {
"mantle": {"rpc": 8545, "ws": 8546},
"blast": {"rpc": 8545, "ws": 8546},
# Hanzo ecosystem
"lux": {"rpc": 9650, "ws": 9650, "p2p": 9651},
"zoo": {"rpc": 9650, "ws": 9650, "p2p": 9651},
"pars": {"rpc": 9650, "ws": 9650, "p2p": 9651},
"hanzo": {"rpc": 9650, "ws": 9650, "p2p": 9651},
"lux": {"rpc": 9630, "ws": 9630, "p2p": 9631},
"zoo": {"rpc": 9630, "ws": 9630, "p2p": 9631},
"pars": {"rpc": 9630, "ws": 9630, "p2p": 9631},
"hanzo": {"rpc": 9630, "ws": 9630, "p2p": 9631},
# Major L1s
"bitcoin": {"rpc": 8332, "p2p": 8333},
"solana": {"rpc": 8899, "ws": 8900, "p2p": 8001},
@@ -374,7 +386,12 @@ def allocate_ports() -> Dict[str, int]:
if bindings:
for binding in bindings:
host_port = int(binding.get("HostPort", 0))
if host_port >= _port_counter["rpc"]:
# Only seed the counter from the RPC/ws range.
# High p2p/beacon ports (30303+, 19000+) must not
# drive the counter up — otherwise each node's p2p
# port feeds back and inflates the next allocation
# until it overflows 65535.
if _port_counter["rpc"] <= host_port < 30000:
_port_counter["rpc"] = host_port + 10
_port_counter["ws"] = host_port + 11
except Exception:
@@ -820,10 +837,10 @@ async def start_node_container(node_id: str, node: dict):
_nodes[node_id]["container_id"] = exec_container.id[:12]
_nodes[node_id]["containers"] = [c.id[:12] for c in containers_created]
# ============ LUX ECOSYSTEM (Lux, Zoo, Pars, Hanzo) ============
# ============ LUX ECOSYSTEM (luxd — Lux, Zoo, Pars, Hanzo) ============
elif chain in ["lux", "zoo", "pars", "hanzo"]:
exec_images = chain_images.get("execution", {})
image = exec_images.get("luxd", "luxfi/luxd:latest")
image = exec_images.get("luxd", "ghcr.io/luxfi/node:v1.30.14-dchain")
try:
client.images.get(image)
@@ -835,47 +852,60 @@ async def start_node_container(node_id: str, node: dict):
try:
old = client.containers.get(container_name)
old.remove(force=True)
except:
except docker.errors.NotFound:
pass
# Configure luxd based on network
lux_network_id = "mainnet" if network == "mainnet" else "fuji" if network == "testnet" else "local"
# Canonical Lux primary network-id (matches luxd-boot.sh):
# localnet=1337, devnet=3, testnet=2, mainnet=1. Zoo/Pars/Hanzo are
# Lux-based sovereign L1s — same luxd boot; genesis selects the chain.
net_id = {"local": 1337, "localnet": 1337, "devnet": 3,
"testnet": 2, "mainnet": 1}.get(network, 1337)
# For local development, use special config
if network == "local":
lux_cmd = [
"--network-id=local",
"--http-host=0.0.0.0",
"--http-port=9650",
"--staking-enabled=false",
"--db-type=memdb",
# luxd serves C-Chain HTTP on 9630, staking on 9631 (not 9650).
# Custom VM plugins load from --plugin-dir (mount .so into /data/plugins).
lux_cmd = [
f"--network-id={net_id}",
"--http-host=0.0.0.0",
"--http-port=9630",
"--staking-port=9631",
"--data-dir=/data",
"--plugin-dir=/data/plugins",
]
# Single-node local/dev: run without a peer quorum. Real staking keys
# under /data/staking are picked up automatically when present (prod).
if network in ("local", "localnet", "devnet"):
lux_cmd += [
"--sybil-protection-enabled=false",
"--network-health-min-conn-peers=0",
"--skip-bootstrap=true",
]
else:
lux_cmd = [
f"--network-id={lux_network_id}",
"--http-host=0.0.0.0",
"--http-port=9650",
"--public-ip-resolution-service=opendns",
]
# Add subnet-specific flags for Zoo/Pars/Hanzo
if chain in ["zoo", "pars", "hanzo"]:
# These would have subnet-specific configs
lux_cmd.extend([
"--track-subnets=", # Add actual subnet IDs for production
])
lux_cmd.append("--public-ip-resolution-service=opendns")
# luxd loads VMs from --plugin-dir. The C-Chain EVM ships as a plugin
# (.so named by vmID under /luxd/build/plugins); custom VM plugins drop
# in the same dir. Stage the bundled plugins into the data plugin dir,
# then exec luxd — same shape as the canonical luxd-boot.sh. The image
# Cmd is ["./luxd"] with no Entrypoint, so we wrap in sh -c.
luxd_argv = " ".join(lux_cmd)
stage = "mkdir -p /data/plugins && cp -n /luxd/build/plugins/* /data/plugins/ 2>/dev/null; "
# Publish RPC always; publish staking only if it maps to a valid host
# port (guard against an out-of-range allocation).
lux_port_map = {"9630/tcp": ports["rpc"]}
staking_host = ports.get("p2p") or (ports["rpc"] + 1)
if 1024 < staking_host <= 65535:
lux_port_map["9631/tcp"] = staking_host
container = client.containers.run(
image,
command=lux_cmd,
entrypoint=["/bin/sh", "-c"],
# Single-list element: the whole script is one argv to `sh -c`
# (a bare string would be word-split by docker-py).
command=[f"{stage}exec /luxd/build/luxd {luxd_argv}"],
name=container_name,
detach=True,
ports={
"9650/tcp": ports["rpc"],
"9651/tcp": ports.get("p2p", 9651 + (ports["rpc"] - 9650)),
},
ports=lux_port_map,
volumes={
f"bootnode-data-{node_id[:8]}": {"bind": "/root/.luxd", "mode": "rw"},
f"bootnode-data-{node_id[:8]}": {"bind": "/data", "mode": "rw"},
},
network=network_name,
restart_policy={"Name": "unless-stopped"},
@@ -953,12 +983,62 @@ async def start_node_container(node_id: str, node: dict):
pass
def _db_node_to_dict(n: "DBNode") -> dict:
"""Map a persisted (K8s-adopted) Node row to the list_nodes() dict shape."""
return {
"id": str(n.id),
"name": n.name,
"chain": n.chain,
"network": n.network,
"provider": n.provider,
"status": n.status,
"endpoint": n.rpc_endpoint,
"ws_endpoint": n.ws_endpoint,
"container_id": n.container_id,
"execution_client": n.execution_client,
"consensus_client": n.consensus_client,
"created_at": n.created_at.isoformat() if n.created_at else datetime.now().isoformat(),
"last_health_check": (
n.last_health_check.isoformat() if n.last_health_check else None
),
"metrics": {},
}
async def _db_discovered_nodes(db: AsyncSession) -> List[dict]:
"""Load K8s-managed / adopted nodes persisted in the DB.
These are surfaced by the K8s detector (operator-managed validators) and
are not visible to Docker — merging them here is what makes attaching a
cluster's nodes appear in GET /v1/nodes alongside Docker-managed ones.
"""
try:
result = await db.execute(
select(DBNode).where(DBNode.provider == "kubernetes")
)
return [_db_node_to_dict(n) for n in result.scalars().all()]
except Exception as e: # noqa: BLE001 - DB optional; never break Docker listing
logger.debug("DB node discovery skipped: %s", e)
return []
@router.get("/", response_model=List[NodeResponse])
async def list_nodes():
"""List all managed blockchain nodes"""
async def list_nodes(db: AsyncSession = Depends(get_db)):
"""List all managed blockchain nodes.
Merges three sources: in-memory Docker state, live Docker containers, and
DB-persisted K8s-managed validators (adopted via the detector).
"""
client = get_docker_client()
nodes = list(_nodes.values())
# K8s-managed validators adopted into the DB (not visible to Docker).
seen_db_ids = {str(n.get("id")) for n in nodes}
for dbn in await _db_discovered_nodes(db):
if dbn["id"] not in seen_db_ids:
nodes.append(dbn)
seen_db_ids.add(dbn["id"])
# Also check for any bootnode-managed containers not in memory
if client:
try:
@@ -1006,6 +1086,84 @@ async def list_nodes():
return nodes
# ---------------------------------------------------------------------------
# K8s detection — discover & adopt operator-managed validators
# ---------------------------------------------------------------------------
class DetectRequest(BaseModel):
"""Scan a cluster for K8s-managed blockchain validators.
Supply either ``cluster_id`` (kubeconfig fetched via the infra/DO path) or
a raw ``kubeconfig`` YAML string. With neither, the in-cluster service
account is used.
"""
cluster_id: Optional[str] = None
kubeconfig: Optional[str] = None
cluster_name: str = ""
provider: str = "digitalocean"
region: str = "sfo3"
endpoint: Optional[str] = None
async def _resolve_kubeconfig(
cluster_id: Optional[str], kubeconfig: Optional[str]
) -> Optional[str]:
"""Resolve a kubeconfig YAML: explicit string wins, else fetch by cluster_id.
Reuses the fleets module's DO/in-cluster fetch so a cluster_id is turned
into a kubeconfig exactly one way across the codebase.
"""
if kubeconfig:
return kubeconfig
if cluster_id:
from bootnode.api.fleets import _get_kubeconfig
return await _get_kubeconfig(cluster_id)
return None
async def _run_detect(body: DetectRequest, db: AsyncSession) -> dict:
cfg = await _resolve_kubeconfig(body.cluster_id, body.kubeconfig)
result = await detector.scan_cluster(
db,
cluster_id=body.cluster_id or "incluster",
kubeconfig=cfg,
cluster_name=body.cluster_name,
provider=body.provider,
region=body.region,
endpoint=body.endpoint,
)
return result.as_dict()
@router.post("/detect")
async def detect_nodes(
body: DetectRequest,
db: AsyncSession = Depends(get_db),
):
"""Detect & adopt K8s-managed validators on a cluster.
Scans LuxNetwork + ZooChain CRs (and their luxd/zood pods), upserting each
as a Node so it appears in ``GET /v1/nodes``. Idempotent: re-running adopts
existing nodes rather than recreating them.
"""
return await _run_detect(body, db)
@router.post("/clusters/{cluster_id}/scan")
async def scan_cluster_nodes(
cluster_id: str,
body: Optional[DetectRequest] = None,
db: AsyncSession = Depends(get_db),
):
"""Scan a specific cluster by id for K8s-managed validators (on demand)."""
req = body or DetectRequest()
req.cluster_id = cluster_id
return await _run_detect(req, db)
@router.get("/presets")
async def list_presets():
"""List available node presets for simple mode"""
@@ -1325,13 +1483,13 @@ async def get_node_stats():
@router.get("/metrics/all")
async def get_all_node_metrics():
async def get_all_node_metrics(db: AsyncSession = Depends(get_db)):
"""Get metrics for all nodes"""
import httpx
nodes_metrics = {}
# First ensure we have discovered all containers
await list_nodes()
await list_nodes(db)
for node_id, node in _nodes.items():
metrics = {}
+91
View File
@@ -0,0 +1,91 @@
"""Subgraphs API — deploy and manage The Graph subgraphs on a graph-node.
Talks to graph-node's admin JSON-RPC (default http://graph-node:8020) for
create/deploy/remove, and its index-node status GraphQL (default
http://graph-node:8030/graphql) for indexing health. The graph-node + IPFS
stack runs via `docker compose --profile subgraph up` locally, or as a
launched-chain service in the cluster.
"""
import os
from typing import Any, Optional
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from bootnode.api.deps import ApiKeyDep
router = APIRouter()
ADMIN_URL = os.getenv("GRAPH_NODE_ADMIN_URL", "http://graph-node:8020")
STATUS_URL = os.getenv("GRAPH_NODE_STATUS_URL", "http://graph-node:8030/graphql")
class CreateRequest(BaseModel):
name: str = Field(..., description="Subgraph name, e.g. 'lux/dex'")
class DeployRequest(BaseModel):
name: str
ipfs_hash: str = Field(..., description="IPFS hash of the compiled subgraph manifest")
version_label: Optional[str] = None
async def _admin(method: str, params: dict) -> Any:
"""Call a graph-node admin JSON-RPC method."""
payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(ADMIN_URL, json=payload)
except httpx.HTTPError as e:
raise HTTPException(502, f"graph-node admin unreachable at {ADMIN_URL}: {e}")
data = r.json()
if data.get("error"):
raise HTTPException(400, f"graph-node error: {data['error']}")
return data.get("result")
@router.post("/create")
async def create_subgraph(body: CreateRequest, api_key: ApiKeyDep) -> dict:
"""Register a subgraph name on the graph-node (subgraph_create)."""
result = await _admin("subgraph_create", {"name": body.name})
return {"name": body.name, "created": True, "result": result}
@router.post("/deploy")
async def deploy_subgraph(body: DeployRequest, api_key: ApiKeyDep) -> dict:
"""Deploy a compiled subgraph by IPFS hash (subgraph_deploy)."""
params: dict[str, str] = {"name": body.name, "ipfs_hash": body.ipfs_hash}
if body.version_label:
params["version_label"] = body.version_label
result = await _admin("subgraph_deploy", params)
return {
"name": body.name,
"ipfsHash": body.ipfs_hash,
"deployed": True,
"endpoints": result,
}
@router.delete("/{name:path}")
async def remove_subgraph(name: str, api_key: ApiKeyDep) -> dict:
"""Remove a subgraph (subgraph_remove)."""
result = await _admin("subgraph_remove", {"name": name})
return {"name": name, "removed": True, "result": result}
@router.get("")
async def list_subgraphs(api_key: ApiKeyDep) -> dict:
"""List indexing status for all deployed subgraphs (index-node status API)."""
query = {
"query": "{ indexingStatuses { subgraph synced health "
"chains { network chainHeadBlock { number } latestBlock { number } } } }"
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(STATUS_URL, json=query)
statuses = r.json().get("data", {}).get("indexingStatuses", [])
except (httpx.HTTPError, ValueError) as e:
raise HTTPException(502, f"graph-node status unreachable at {STATUS_URL}: {e}")
return {"subgraphs": statuses, "count": len(statuses)}
+4 -3
View File
@@ -17,6 +17,7 @@ import structlog
from fastapi import APIRouter
from pydantic import BaseModel
from bootnode import __version__
from bootnode.api.deps import ApiKeyDep
from bootnode.config import get_settings
@@ -44,7 +45,7 @@ class ZapServerInfo(BaseModel):
"""ZAP server capabilities."""
name: str = "bootnode"
version: str = "2.0.0"
version: str = __version__
capabilities: dict[str, bool]
tools_count: int
resources_count: int
@@ -388,7 +389,7 @@ async def get_zap_schema(_api_key: ApiKeyDep) -> dict[str, Any]:
return {
"schema": schema_content,
"format": "zap",
"version": "2.0.0",
"version": __version__,
"interfaces": ["Bootnode"],
"compile": "zapc compile bootnode.zap --out=bootnode.capnp",
}
@@ -410,5 +411,5 @@ async def get_capnp_schema(_api_key: ApiKeyDep) -> dict[str, Any]:
return {
"schema": schema_content,
"format": "capnp",
"version": "2.0.0",
"version": __version__,
}
+6 -4
View File
@@ -3,7 +3,7 @@
from functools import lru_cache
from typing import Literal
from pydantic import AliasChoices, Field, PostgresDsn, RedisDsn
from pydantic import AliasChoices, Field, RedisDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -23,9 +23,11 @@ class Settings(BaseSettings):
debug: bool = False
api_prefix: str = "/v1"
# Database
database_url: PostgresDsn = Field(
default="postgresql+asyncpg://bootnode:bootnode@localhost:5432/bootnode"
# Database — Base/SQLite is canonical (VFS→SeaweedFS for durability);
# PostgreSQL DSNs are still accepted for legacy/multi-instance. Plain str
# (not PostgresDsn) so a `sqlite+aiosqlite:///…` URL validates.
database_url: str = Field(
default="sqlite+aiosqlite:////data/bootnode/bootnode.db"
)
db_pool_size: int = 20
db_max_overflow: int = 10
+1 -1
View File
@@ -203,7 +203,7 @@ def get_network_config(chain: str, network: str) -> NetworkConfig:
class ImageConfig(BaseModel):
"""Container image reference."""
repository: str = "ghcr.io/hanzoai/bootnode"
repository: str = "ghcr.io/bootnode/bootnode"
tag: str = "latest"
pull_policy: str = "IfNotPresent"
+575
View File
@@ -0,0 +1,575 @@
"""Read-only Lux fleet collector.
Builds a graphical-dashboard view of the live luxd fleet across
devnet / testnet / mainnet, observing it READ-ONLY in one of two modes:
MODE A — direct RPC (default; touches nothing but public luxd RPC):
Query each validator's public luxd JSON-RPC endpoint (per-pod LoadBalancer)
for ``info.getNodeVersion``, ``info.getNodeID``, ``info.peers``,
``info.isBootstrapped`` (C/P/X/D), ``eth_blockNumber`` (C-Chain) and
``platform.getHeight`` (P-Chain), plus a native D-Chain DEX summary. The
endpoint set is operational config (``LUX_FLEET_ENDPOINTS_FILE`` /
``LUX_FLEET_ENDPOINTS_JSON``), with built-in defaults.
MODE B — kubeconfig enrichment (optional; only if a READ-ONLY lux-k8s
kubeconfig is provided): additionally read the operator ``LuxNetwork`` CR
(``lux.cloud/v1``) for declared validator count, image tag, plugin-set and
phase, and the luxd pods/services for real pod phase + discovered IPs.
Both modes are strictly observational — no mutations, no writes, no secrets in
the response. But the aggregate is a cross-tenant INFRA view (whole-fleet health
+, in MODE B, operator CR and pod topology), so it is NOT customer data and must
NOT be served unauthenticated. The HTTP surfaces that expose it
(``bootnode.fleet_app`` and ``api/fleets:lux_fleet_overview``) gate it
server-side behind the IAM admin role (``bootnode.core.iam.require_admin``).
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import tempfile
from typing import Any, Optional
import httpx
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# The operator CRD that declares each managed network. This is the REAL group
# (verified against the live cluster) — not the legacy ``lux.network/v1alpha1``.
LUXNETWORK_GROUP = "lux.cloud"
LUXNETWORK_VERSION = "v1"
LUXNETWORK_PLURAL = "luxnetworks"
LUXNETWORK_NAME = "luxd"
# luxd P2P chains we surface bootstrap state + heights for, in display order.
TRACKED_CHAINS = ("C", "P", "X", "D")
# Per-network luxd HTTP RPC ports + namespaces (mirrors CHAIN_NETWORKS["lux"]).
LUX_NETWORKS: dict[str, dict[str, Any]] = {
"mainnet": {"namespace": "lux-mainnet", "http_port": 9630, "network_id": 1, "chain_id": 96369},
"testnet": {"namespace": "lux-testnet", "http_port": 9640, "network_id": 2, "chain_id": 96368},
"devnet": {"namespace": "lux-devnet", "http_port": 9650, "network_id": 3, "chain_id": 96370},
}
# Optional read-only lux-k8s kubeconfig (MODE B enrichment). File path
# (LUX_K8S_KUBECONFIG) or inline YAML (LUX_K8S_KUBECONFIG_DATA).
ENV_KUBECONFIG_PATH = "LUX_K8S_KUBECONFIG"
ENV_KUBECONFIG_DATA = "LUX_K8S_KUBECONFIG_DATA"
# Endpoint config (MODE A). JSON inline (LUX_FLEET_ENDPOINTS_JSON) or file
# (LUX_FLEET_ENDPOINTS_FILE). Shape:
# {"<network>": {"http_port": 9630, "nodes": [{"index": 0, "ip": "1.2.3.4"}, ...]}}
ENV_ENDPOINTS_JSON = "LUX_FLEET_ENDPOINTS_JSON"
ENV_ENDPOINTS_FILE = "LUX_FLEET_ENDPOINTS_FILE"
# Built-in default endpoints: the managed per-pod LoadBalancer RPC IPs.
# Operational config (public infra IPs, not secrets); overridable without a
# rebuild via the ConfigMap-mounted endpoint file.
DEFAULT_ENDPOINTS: dict[str, dict[str, Any]] = {
"mainnet": {"http_port": 9630, "nodes": [
{"index": 0, "ip": "129.212.164.46"},
{"index": 1, "ip": "137.184.246.133"},
{"index": 2, "ip": "143.244.210.43"},
{"index": 3, "ip": "143.244.210.71"},
{"index": 4, "ip": "24.144.70.85"},
]},
"testnet": {"http_port": 9640, "nodes": [
{"index": 0, "ip": "134.199.187.16"},
{"index": 1, "ip": "209.38.174.84"},
{"index": 2, "ip": "209.38.173.80"},
{"index": 3, "ip": "134.199.187.62"},
{"index": 4, "ip": "164.90.247.204"},
]},
"devnet": {"http_port": 9650, "nodes": [
{"index": 0, "ip": "164.90.247.220"},
{"index": 1, "ip": "146.190.14.112"},
{"index": 2, "ip": "24.199.68.130"},
{"index": 3, "ip": "134.199.184.181"},
{"index": 4, "ip": "209.38.172.213"},
]},
}
_RPC_TIMEOUT = httpx.Timeout(connect=2.5, read=4.0, write=2.5, pool=2.5)
def _load_endpoints() -> dict[str, dict[str, Any]]:
"""Resolve the endpoint config (MODE A). Env file/JSON override defaults."""
raw: Optional[str] = None
path = os.environ.get(ENV_ENDPOINTS_FILE)
if path and os.path.exists(path):
try:
raw = open(path).read()
except OSError as e:
logger.warning("endpoint file read failed (%s): %s", path, e)
if raw is None:
raw = os.environ.get(ENV_ENDPOINTS_JSON)
if raw:
try:
parsed = json.loads(raw)
if isinstance(parsed, dict) and parsed:
return parsed
except json.JSONDecodeError as e:
logger.warning("endpoint JSON parse failed: %s", e)
return DEFAULT_ENDPOINTS
# ---------------------------------------------------------------------------
# Response models
# ---------------------------------------------------------------------------
class ChainHeight(BaseModel):
"""Per-chain bootstrap + height for one node."""
chain: str
bootstrapped: Optional[bool] = None
height: Optional[int] = None
class LuxNode(BaseModel):
"""Live status of a single luxd validator pod."""
pod_name: str
pod_index: int
phase: str = "Unknown" # K8s pod phase: Running/Pending/...
ready: bool = False # K8s readiness
external_ip: Optional[str] = None
rpc_reachable: bool = False # did luxd RPC answer?
node_id: Optional[str] = None
version: Optional[str] = None # e.g. luxd/1.30.39
git_commit: Optional[str] = None
db_version: Optional[str] = None
peers: Optional[int] = None
healthy: Optional[bool] = None # luxd /ext/health (best-effort)
chains: list[ChainHeight] = Field(default_factory=list)
class DexSummary(BaseModel):
"""Native DEX (D-Chain) summary for a network."""
d_chain_bootstrapped: Optional[bool] = None
venue: str = "native-dchain"
markets: Optional[int] = None # from explorer graph, if available
note: Optional[str] = None
class LuxNetworkFleet(BaseModel):
"""One network's fleet (devnet/testnet/mainnet)."""
network: str
namespace: str
network_id: Optional[int] = None
chain_id: Optional[int] = None
phase: str = "Unknown" # LuxNetwork CR status.phase
image: Optional[str] = None # full repo:tag
version: Optional[str] = None # luxd version (image tag)
plugin_set: Optional[str] = None # spec.pluginSource.bucket
vm_plugins: list[str] = Field(default_factory=list)
validators: int = 0 # declared
nodes_total: int = 0 # observed pods
nodes_ready: int = 0 # ready pods
nodes_rpc_ok: int = 0 # nodes answering RPC
total_peers: int = 0
dex: DexSummary = Field(default_factory=DexSummary)
nodes: list[LuxNode] = Field(default_factory=list)
error: Optional[str] = None
class LuxFleetOverview(BaseModel):
"""All-network fleet overview — the single read the dashboard polls."""
cluster: str = "lux-k8s"
networks: list[LuxNetworkFleet] = Field(default_factory=list)
totals: dict[str, int] = Field(default_factory=dict)
generated_at: float = 0.0
# ---------------------------------------------------------------------------
# Kubernetes client (read-only, lux-k8s)
# ---------------------------------------------------------------------------
def _load_lux_k8s_clients():
"""Return (CustomObjectsApi, CoreV1Api) for lux-k8s, read-only.
Resolution order:
1. ``LUX_K8S_KUBECONFIG`` — path to a mounted kubeconfig file.
2. ``LUX_K8S_KUBECONFIG_DATA`` — inline kubeconfig YAML.
3. in-cluster config (only correct if the api runs inside lux-k8s).
"""
from kubernetes import client, config
path = os.environ.get(ENV_KUBECONFIG_PATH)
data = os.environ.get(ENV_KUBECONFIG_DATA)
if path and os.path.exists(path):
config.load_kube_config(config_file=path)
elif data:
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".kubeconfig", delete=False)
try:
tmp.write(data)
tmp.flush()
tmp.close()
config.load_kube_config(config_file=tmp.name)
finally:
os.unlink(tmp.name)
else:
# Last resort: in-cluster. Only valid if deployed inside lux-k8s.
config.load_incluster_config()
return client.CustomObjectsApi(), client.CoreV1Api()
def fleet_data_available() -> bool:
"""True if a lux-k8s kubeconfig is configured (or we're in-cluster)."""
if os.environ.get(ENV_KUBECONFIG_PATH) and os.path.exists(os.environ[ENV_KUBECONFIG_PATH]):
return True
if os.environ.get(ENV_KUBECONFIG_DATA):
return True
return os.path.exists("/var/run/secrets/kubernetes.io/serviceaccount/token")
# ---------------------------------------------------------------------------
# luxd RPC (read-only)
# ---------------------------------------------------------------------------
async def _rpc(http: httpx.AsyncClient, base: str, path: str, method: str,
params: Any = None) -> Optional[dict]:
"""POST a single JSON-RPC call to luxd; return ``result`` or None."""
body = {"jsonrpc": "2.0", "id": 1, "method": method}
if params is not None:
body["params"] = params
try:
r = await http.post(f"{base}{path}", json=body)
if r.status_code != 200:
return None
data = r.json()
return data.get("result")
except Exception:
return None
async def _probe_node_rpc(node: LuxNode, http_port: int) -> None:
"""Enrich ``node`` in-place with live luxd RPC data. Best-effort."""
if not node.external_ip:
return
base = f"http://{node.external_ip}:{http_port}"
async with httpx.AsyncClient(timeout=_RPC_TIMEOUT) as http:
# Version / nodeID / peers in parallel.
ver, nid, peers, health = await asyncio.gather(
_rpc(http, base, "/ext/info", "info.getNodeVersion"),
_rpc(http, base, "/ext/info", "info.getNodeID"),
_rpc(http, base, "/ext/info", "info.peers", {}),
_node_health(http, base),
)
if ver:
node.rpc_reachable = True
node.version = ver.get("version")
node.git_commit = ver.get("gitCommit")
node.db_version = ver.get("databaseVersion")
if nid:
node.node_id = nid.get("nodeID")
if isinstance(peers, dict) and isinstance(peers.get("peers"), list):
node.peers = len(peers["peers"])
node.healthy = health
# Per-chain bootstrap + heights.
chains: list[ChainHeight] = []
boot_results = await asyncio.gather(*[
_rpc(http, base, "/ext/info", "info.isBootstrapped", {"chain": c})
for c in TRACKED_CHAINS
])
c_height, p_height = await asyncio.gather(
_rpc(http, base, "/ext/bc/C/rpc", "eth_blockNumber", []),
_rpc(http, base, "/ext/bc/P", "platform.getHeight"),
)
for c, boot in zip(TRACKED_CHAINS, boot_results):
ch = ChainHeight(chain=c)
if isinstance(boot, dict):
ch.bootstrapped = boot.get("isBootstrapped")
if c == "C" and isinstance(c_height, str):
try:
ch.height = int(c_height, 16)
except ValueError:
pass
if c == "P" and isinstance(p_height, dict):
try:
ch.height = int(p_height.get("height"))
except (TypeError, ValueError):
pass
chains.append(ch)
node.chains = chains
async def _node_health(http: httpx.AsyncClient, base: str) -> Optional[bool]:
"""luxd /ext/health — returns healthy flag. (Known to encode-fail on some
builds; treat a non-200 / parse error as unknown, not unhealthy.)"""
try:
r = await http.get(f"{base}/ext/health")
if r.status_code not in (200, 503):
return None
return bool(r.json().get("healthy"))
except Exception:
return None
# ---------------------------------------------------------------------------
# Per-network collection
# ---------------------------------------------------------------------------
def _read_luxnetwork_cr(custom_api, namespace: str) -> dict:
"""Read the LuxNetwork CR for a namespace. Returns {} if absent."""
try:
return custom_api.get_namespaced_custom_object(
group=LUXNETWORK_GROUP, version=LUXNETWORK_VERSION,
namespace=namespace, plural=LUXNETWORK_PLURAL, name=LUXNETWORK_NAME,
) or {}
except Exception as e:
logger.warning("LuxNetwork CR read failed for %s: %s", namespace, e)
return {}
def _list_luxd_pods(core_api, namespace: str) -> dict[int, dict[str, Any]]:
"""Map pod index -> {phase, ready} for luxd-N pods."""
out: dict[int, dict[str, Any]] = {}
try:
pods = core_api.list_namespaced_pod(
namespace=namespace, label_selector="app.kubernetes.io/name=luxd",
).items
if not pods:
# Fallback: StatefulSet pods are named luxd-N regardless of labels.
pods = [p for p in core_api.list_namespaced_pod(namespace=namespace).items
if (p.metadata.name or "").startswith("luxd-")]
except Exception as e:
logger.warning("pod list failed for %s: %s", namespace, e)
return out
for p in pods:
name = p.metadata.name or ""
if not name.startswith("luxd-"):
continue
try:
idx = int(name.rsplit("-", 1)[1])
except (ValueError, IndexError):
continue
ready = False
conds = (p.status.conditions or []) if p.status else []
for c in conds:
if c.type == "Ready":
ready = (c.status == "True")
out[idx] = {"phase": (p.status.phase if p.status else "Unknown") or "Unknown",
"ready": ready}
return out
def _list_luxd_external_ips(core_api, namespace: str) -> dict[int, str]:
"""Map pod index -> external LB IP from per-pod luxd-N Services."""
out: dict[int, str] = {}
try:
svcs = core_api.list_namespaced_service(namespace=namespace).items
except Exception as e:
logger.warning("service list failed for %s: %s", namespace, e)
return out
for s in svcs:
name = s.metadata.name or ""
if not name.startswith("luxd-"):
continue
try:
idx = int(name.rsplit("-", 1)[1])
except (ValueError, IndexError):
continue
ing = (s.status.load_balancer.ingress if s.status and s.status.load_balancer else None) or []
for i in ing:
if i.ip:
out[idx] = i.ip
break
return out
def _parse_cr(cr: dict) -> dict[str, Any]:
"""Extract declared shape + phase from a LuxNetwork CR."""
spec = cr.get("spec", {}) or {}
status = cr.get("status", {}) or {}
image = spec.get("image", {}) or {}
repo = image.get("repository")
tag = image.get("tag")
full = f"{repo}:{tag}" if repo and tag else (tag or repo)
plugin = spec.get("pluginSource", {}) or {}
vm_plugins = [p.get("vmID") for p in (plugin.get("vmPlugins") or []) if p.get("vmID")]
return {
"phase": status.get("phase", "Unknown") or "Unknown",
"validators": int(spec.get("validators") or 0),
"image": full,
"version": tag,
"plugin_set": plugin.get("bucket"),
"vm_plugins": vm_plugins,
}
async def _dex_summary(nodes: list[LuxNode], network: str) -> DexSummary:
"""Build a DEX summary from D-Chain bootstrap state + explorer markets."""
d_boot: Optional[bool] = None
for n in nodes:
for ch in n.chains:
if ch.chain == "D" and ch.bootstrapped is not None:
d_boot = d_boot or ch.bootstrapped
summary = DexSummary(d_chain_bootstrapped=d_boot)
# Best-effort markets count from the native explorer graph.
host = {
"mainnet": "explorer.lux.network",
"testnet": "explorer.testnet.lux.network",
"devnet": "explorer.devnet.lux.network",
}.get(network)
if host:
try:
async with httpx.AsyncClient(timeout=_RPC_TIMEOUT) as http:
r = await http.post(
f"https://{host}/v1/graph/lux/dex/graphql",
json={"query": "{ markets { id } }"},
)
if r.status_code == 200:
mkts = (r.json().get("data") or {}).get("markets")
if isinstance(mkts, list):
summary.markets = len(mkts)
except Exception:
pass
if d_boot is False:
summary.note = "D-Chain not yet bootstrapped"
return summary
async def _collect_network(
network: str,
endpoints: dict[str, Any],
custom_api=None,
core_api=None,
) -> LuxNetworkFleet:
"""Collect one network. MODE A (default) queries configured RPC endpoints;
MODE B additionally reads the CR + pods/services when k8s clients are given."""
cfg = LUX_NETWORKS[network]
ns = cfg["namespace"]
ep = endpoints.get(network, {})
http_port = int(ep.get("http_port") or cfg["http_port"])
fleet = LuxNetworkFleet(
network=network, namespace=ns,
network_id=cfg["network_id"], chain_id=cfg["chain_id"],
)
# Endpoint config = the baseline node set (index -> external RPC IP).
ip_by_idx: dict[int, str] = {}
for node in ep.get("nodes", []) or []:
try:
ip_by_idx[int(node["index"])] = str(node["ip"])
except (KeyError, ValueError, TypeError):
continue
pods: dict[int, dict[str, Any]] = {}
# MODE B enrichment: CR (declared shape/phase) + observed pods/services.
if custom_api is not None and core_api is not None:
try:
cr, pods, disc_ips = await asyncio.gather(
asyncio.to_thread(_read_luxnetwork_cr, custom_api, ns),
asyncio.to_thread(_list_luxd_pods, core_api, ns),
asyncio.to_thread(_list_luxd_external_ips, core_api, ns),
)
parsed = _parse_cr(cr)
fleet.phase = parsed["phase"]
fleet.image = parsed["image"]
fleet.version = parsed["version"]
fleet.plugin_set = parsed["plugin_set"]
fleet.vm_plugins = parsed["vm_plugins"]
fleet.validators = parsed["validators"]
# Discovered IPs take precedence (current truth), then config.
ip_by_idx = {**ip_by_idx, **disc_ips}
except Exception as e:
logger.warning("lux-k8s enrichment failed for %s: %s", network, e)
# Build the node set: union of configured endpoints + observed pods.
indices = set(ip_by_idx) | set(pods)
if not indices and fleet.validators:
indices = set(range(fleet.validators))
nodes = [
LuxNode(
pod_name=f"luxd-{i}",
pod_index=i,
phase=pods.get(i, {}).get("phase", "Unknown"),
ready=pods.get(i, {}).get("ready", False),
external_ip=ip_by_idx.get(i),
)
for i in sorted(indices)
]
# Probe RPC concurrently for nodes with a reachable external IP.
await asyncio.gather(*[_probe_node_rpc(n, http_port) for n in nodes])
# In MODE A (no CR), derive declared validators + version from observation.
if not fleet.validators:
fleet.validators = len(nodes)
if not fleet.version:
ver = next((n.version for n in nodes if n.version), None)
if ver:
fleet.version = ver
fleet.image = ver
if fleet.phase == "Unknown" and any(n.rpc_reachable for n in nodes):
fleet.phase = "Running"
fleet.nodes = nodes
fleet.nodes_total = len(nodes)
# Without pod phase (MODE A), readiness = RPC reachability.
if pods:
fleet.nodes_ready = sum(1 for n in nodes if n.ready)
else:
fleet.nodes_ready = sum(1 for n in nodes if n.rpc_reachable)
for n in nodes:
if n.rpc_reachable:
n.ready = True
fleet.nodes_rpc_ok = sum(1 for n in nodes if n.rpc_reachable)
fleet.total_peers = sum(n.peers or 0 for n in nodes)
fleet.dex = await _dex_summary(nodes, network)
if fleet.nodes_total == 0:
fleet.error = "no luxd endpoints configured"
return fleet
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
async def get_overview(networks: Optional[list[str]] = None) -> LuxFleetOverview:
"""Collect the full read-only fleet overview across all networks.
Default = MODE A (direct luxd RPC over configured endpoints). If a read-only
lux-k8s kubeconfig is configured, MODE B enrichment (CR + pods) is layered on.
"""
import time
selected = networks or list(LUX_NETWORKS.keys())
overview = LuxFleetOverview(generated_at=time.time())
endpoints = _load_endpoints()
custom_api = core_api = None
if fleet_data_available():
try:
custom_api, core_api = await asyncio.to_thread(_load_lux_k8s_clients)
except Exception as e:
logger.warning("lux-k8s enrichment unavailable, using direct RPC: %s", e)
overview.networks = await asyncio.gather(*[
_collect_network(n, endpoints, custom_api, core_api) for n in selected
])
overview.totals = {
"networks": len(overview.networks),
"validators": sum(f.validators for f in overview.networks),
"nodes_total": sum(f.nodes_total for f in overview.networks),
"nodes_ready": sum(f.nodes_ready for f in overview.networks),
"nodes_rpc_ok": sum(f.nodes_rpc_ok for f in overview.networks),
"total_peers": sum(f.total_peers for f in overview.networks),
}
return overview
+1 -1
View File
@@ -11,7 +11,7 @@ Usage:
from bootnode.core.deploy import get_deployer, ServiceType
deployer = get_deployer()
await deployer.deploy(ServiceType.API, "ghcr.io/hanzoai/bootnode:api-latest", replicas=2)
await deployer.deploy(ServiceType.API, "ghcr.io/bootnode/bootnode:api-latest", replicas=2)
status = await deployer.status(ServiceType.API)
async for line in deployer.logs(ServiceType.API, tail=50):
print(line)
+34
View File
@@ -9,6 +9,7 @@ Supports login via:
Uses Casdoor-compatible OAuth2/OIDC flow.
"""
import os
from datetime import UTC, datetime
from functools import lru_cache
from typing import Any
@@ -205,3 +206,36 @@ def require_permission(permission: str):
)
return user
return _require_permission
def fleet_admin_allowlist() -> set[str]:
"""Bootstrap admin allowlist — comma-separated emails from ``FLEET_ADMIN_EMAILS``.
Lets the sole initial superuser (e.g. ``z@lux.network``) reach admin-only
infra views before the IAM ``admin`` role is provisioned on their token.
Empty by default → pure role-based gating. Read live from the environment so
it can be rotated without a process restart.
"""
raw = os.environ.get("FLEET_ADMIN_EMAILS", "")
return {e.strip().lower() for e in raw.split(",") if e.strip()}
async def require_admin(user: IAMUser = Depends(get_current_user)) -> IAMUser:
"""Require an authenticated IAM admin (deny-by-default).
The server-side gate for internal / cross-tenant infra surfaces (e.g. the
luxd fleet overview). Enforced in the API — never rely on UI hiding.
* missing / invalid credentials → 401 (raised by ``get_current_user``)
* authenticated non-admin → 403
* admin → passes through
Admin = the IAM ``admin`` role, or an email in the ``FLEET_ADMIN_EMAILS``
bootstrap allowlist (the sole initial superuser).
"""
if user.is_admin or user.email.lower() in fleet_admin_allowlist():
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required for internal fleet/infra views",
)
+490
View File
@@ -0,0 +1,490 @@
"""K8s node detector — discover & adopt cluster-managed blockchain validators.
Bootnode's Docker discovery (``api/nodes`` ``list_nodes``) can only see nodes it
launched as containers. Real Lux / Zoo validators run as K8s workloads behind
operator CRDs, invisible to Docker. This module closes that gap: given a
cluster's kubeconfig it scans
- **LuxNetwork** CRs (group ``lux.network``, v1alpha1, plural ``luxnetworks``)
- **ZooChain** CRs (group ``zoo.network``, v1alpha1, plural ``zoochains``)
across all namespaces, enriches each with its luxd / zood StatefulSet + pods for
live status, and upserts the result as ``Cluster`` + ``Node`` rows so the
validators surface in ``GET /v1/nodes``.
Adoption, not recreation: a node already in the DB (matched on its natural key
``(cluster_id, pod_name)``) is updated in place. Re-scanning is idempotent.
The CRD shapes mirror ``core.chains.fleet_manager`` (single source of truth for
LuxNetwork field names) — this detector reuses those constants and adds the Zoo
equivalent, rather than re-deriving them.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bootnode.core.chains.fleet_manager import (
LUX_CRD_GROUP,
LUX_CRD_PLURAL,
LUX_CRD_VERSION,
)
from bootnode.db.models import Cluster, Node
logger = logging.getLogger(__name__)
# Zoo operator CRD — mirrors the Lux constants in fleet_manager.
ZOO_CRD_GROUP = "zoo.network"
ZOO_CRD_VERSION = "v1alpha1"
ZOO_CRD_PLURAL = "zoochains"
# Map an operator CR phase onto a Node.status value.
_PHASE_TO_STATUS: dict[str, str] = {
"Pending": "provisioning",
"Creating": "provisioning",
"Bootstrapping": "syncing",
"Running": "running",
"Degraded": "degraded",
"Failed": "error",
"Error": "error",
}
# Per-chain CRD descriptor — one entry per operator we adopt from. Adding a new
# operator-managed chain is one row here, nothing else.
@dataclass(frozen=True)
class _CRDSpec:
chain: str
group: str
version: str
plural: str
execution_client: str
image_default: str
_CRDS: tuple[_CRDSpec, ...] = (
_CRDSpec(
chain="lux",
group=LUX_CRD_GROUP,
version=LUX_CRD_VERSION,
plural=LUX_CRD_PLURAL,
execution_client="luxd",
image_default="ghcr.io/luxfi/node:latest",
),
_CRDSpec(
chain="zoo",
group=ZOO_CRD_GROUP,
version=ZOO_CRD_VERSION,
plural=ZOO_CRD_PLURAL,
execution_client="zood",
image_default="ghcr.io/zooai/node:latest",
),
)
@dataclass
class ScanResult:
"""Outcome of scanning one cluster."""
cluster_id: str
cluster_db_id: Optional[str] = None
crs_found: int = 0
nodes_adopted: int = 0 # existing rows updated
nodes_created: int = 0 # new rows inserted
pods_seen: int = 0
errors: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return {
"cluster_id": self.cluster_id,
"cluster_db_id": self.cluster_db_id,
"crs_found": self.crs_found,
"nodes_adopted": self.nodes_adopted,
"nodes_created": self.nodes_created,
"nodes_total": self.nodes_adopted + self.nodes_created,
"pods_seen": self.pods_seen,
"errors": self.errors,
}
# ---------------------------------------------------------------------------
# K8s client
# ---------------------------------------------------------------------------
def _k8s_apis(kubeconfig: Optional[str]):
"""Return ``(CustomObjectsApi, CoreV1Api, AppsV1Api)`` for a kubeconfig.
Loads from the YAML string when given, else falls back to in-cluster
config. Same loading shape as ``FleetManager._get_k8s_clients`` (kept
local so the detector has no hidden coupling to that class).
"""
import os
import tempfile
from kubernetes import client, config
if kubeconfig:
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".kubeconfig", delete=False
)
try:
tmp.write(kubeconfig)
tmp.flush()
tmp.close()
config.load_kube_config(config_file=tmp.name)
finally:
os.unlink(tmp.name)
else:
config.load_incluster_config()
return client.CustomObjectsApi(), client.CoreV1Api(), client.AppsV1Api()
# ---------------------------------------------------------------------------
# CR / pod parsing helpers (pure — easy to unit test)
# ---------------------------------------------------------------------------
def _network_from_namespace(namespace: str, chain: str) -> str:
"""Derive network from a namespace like ``lux-mainnet`` → ``mainnet``."""
prefix = f"{chain}-"
if namespace.startswith(prefix):
return namespace[len(prefix):] or "mainnet"
# Fall back to a trailing known segment if present.
for net in ("mainnet", "testnet", "devnet", "localnet"):
if net in namespace:
return net
return namespace or "mainnet"
def _cr_status(cr: dict) -> str:
phase = (cr.get("status") or {}).get("phase", "Pending")
return _PHASE_TO_STATUS.get(phase, "provisioning")
def _cr_image(cr: dict, default: str) -> str:
img = (cr.get("spec") or {}).get("image") or {}
if isinstance(img, str):
return img
repo = img.get("repository")
tag = img.get("tag")
if repo:
return f"{repo}:{tag}" if tag else repo
return default
def _pod_phase_to_status(phase: Optional[str], ready: bool) -> str:
"""Translate a pod phase + readiness into a Node.status."""
if phase == "Running":
return "running" if ready else "syncing"
if phase == "Pending":
return "provisioning"
if phase in ("Failed", "Unknown"):
return "error"
if phase == "Succeeded":
return "stopped"
return "provisioning"
def _pod_ready(pod) -> bool:
conds = getattr(pod.status, "conditions", None) or []
return any(c.type == "Ready" and c.status == "True" for c in conds)
# ---------------------------------------------------------------------------
# Detector
# ---------------------------------------------------------------------------
class K8sNodeDetector:
"""Discovers operator-managed validators on a cluster and adopts them.
Stateless apart from the DB session passed per call. Reusable for both the
on-demand scan endpoints and the passive discovery path in ``list_nodes``.
"""
async def scan_cluster(
self,
db: AsyncSession,
cluster_id: str,
kubeconfig: Optional[str],
*,
cluster_name: str = "",
provider: str = "digitalocean",
region: str = "sfo3",
endpoint: Optional[str] = None,
) -> ScanResult:
"""Scan one cluster and upsert its CRs/pods as ``Node`` rows.
``cluster_id`` is the upstream id (DO cluster UUID, or ``"incluster"``)
— used as ``Cluster.provider_id`` so re-scans adopt the same row.
"""
result = ScanResult(cluster_id=cluster_id)
try:
custom_api, core_api, apps_api = _k8s_apis(kubeconfig)
except Exception as exc: # noqa: BLE001 - surface, don't crash discovery
logger.warning("k8s client init failed for cluster %s: %s", cluster_id, exc)
result.errors.append(f"client: {exc}")
return result
cluster = await self._upsert_cluster(
db,
provider_id=cluster_id,
name=cluster_name or cluster_id,
provider=provider,
region=region,
endpoint=endpoint,
)
result.cluster_db_id = str(cluster.id)
for spec in _CRDS:
try:
items = self._list_crs(custom_api, spec)
except Exception as exc: # noqa: BLE001
logger.debug("No %s CRs on cluster %s: %s", spec.plural, cluster_id, exc)
continue
for cr in items:
result.crs_found += 1
try:
await self._adopt_cr(
db, core_api, apps_api, cluster, spec, cr, region, result
)
except Exception as exc: # noqa: BLE001
metadata = cr.get("metadata", {})
ref = f"{metadata.get('namespace', '?')}/{metadata.get('name', '?')}"
logger.warning("Adopt failed for %s %s: %s", spec.chain, ref, exc)
result.errors.append(f"{spec.chain}/{ref}: {exc}")
await db.flush()
return result
# ---- CR listing ----
@staticmethod
def _list_crs(custom_api, spec: _CRDSpec) -> list[dict]:
"""List a CRD across all namespaces (cluster-scoped call)."""
resp = custom_api.list_cluster_custom_object(
group=spec.group, version=spec.version, plural=spec.plural,
)
return resp.get("items", [])
# ---- pod enrichment ----
@staticmethod
def _list_pods(core_api, namespace: str, cr_name: str) -> list:
"""Best-effort pod list for a CR's workload in its namespace.
Operators label pods with the CR name; we accept the common label keys
and otherwise fall back to all pods in the namespace whose name starts
with the CR name (StatefulSet pods are ``<sts>-<ordinal>``).
"""
selectors = (
f"app.kubernetes.io/instance={cr_name}",
f"lux.network/network={cr_name}",
f"zoo.network/chain={cr_name}",
)
for sel in selectors:
try:
pods = core_api.list_namespaced_pod(
namespace=namespace, label_selector=sel
).items
if pods:
return pods
except Exception: # noqa: BLE001
continue
try:
allp = core_api.list_namespaced_pod(namespace=namespace).items
return [p for p in allp if (p.metadata.name or "").startswith(cr_name)]
except Exception: # noqa: BLE001
return []
@staticmethod
def _cr_replicas(cr: dict) -> int:
spec = cr.get("spec") or {}
status = cr.get("status") or {}
return (
status.get("totalValidators")
or status.get("totalNodes")
or spec.get("validators")
or spec.get("replicas")
or 0
)
async def _adopt_cr(
self,
db: AsyncSession,
core_api,
apps_api,
cluster: Cluster,
spec: _CRDSpec,
cr: dict,
region: str,
result: ScanResult,
) -> None:
"""Upsert one CR's pods (or a synthetic node) as ``Node`` rows."""
metadata = cr.get("metadata", {})
cr_name = metadata.get("name", f"{spec.chain}d")
namespace = metadata.get("namespace", f"{spec.chain}-mainnet")
network = _network_from_namespace(namespace, spec.chain)
cr_spec = cr.get("spec") or {}
network_id = cr_spec.get("networkId")
image = _cr_image(cr, spec.image_default)
cr_status = _cr_status(cr)
node_type = "validator" # operator CRs manage validators
pods = self._list_pods(core_api, namespace, cr_name)
result.pods_seen += len(pods)
if pods:
for pod in pods:
pod_name = pod.metadata.name
ready = _pod_ready(pod)
status = _pod_phase_to_status(getattr(pod.status, "phase", None), ready)
node_ip = getattr(pod.status, "pod_ip", None)
await self._upsert_node(
db,
cluster=cluster,
chain=spec.chain,
network=network,
pod_name=pod_name,
name=pod_name,
status=status,
node_type=node_type,
execution_client=spec.execution_client,
image=image,
region=region,
network_id=network_id,
pod_ip=node_ip,
result=result,
)
else:
# No pods visible (RBAC or headless) — still surface the CR as a
# single logical node so the validator set is not invisible.
await self._upsert_node(
db,
cluster=cluster,
chain=spec.chain,
network=network,
pod_name=cr_name,
name=cr_name,
status=cr_status,
node_type=node_type,
execution_client=spec.execution_client,
image=image,
region=region,
network_id=network_id,
pod_ip=None,
result=result,
)
# ---- upserts ----
@staticmethod
async def _upsert_cluster(
db: AsyncSession,
*,
provider_id: str,
name: str,
provider: str,
region: str,
endpoint: Optional[str],
) -> Cluster:
"""Find-or-create the ``Cluster`` row keyed on ``provider_id``."""
existing = (
await db.execute(select(Cluster).where(Cluster.provider_id == provider_id))
).scalar_one_or_none()
if existing is not None:
if endpoint and not existing.endpoint:
existing.endpoint = endpoint
return existing
cluster = Cluster(
name=name,
provider=provider,
provider_id=provider_id,
region=region,
status="running",
endpoint=endpoint,
)
db.add(cluster)
await db.flush() # assigns cluster.id
return cluster
@staticmethod
async def _upsert_node(
db: AsyncSession,
*,
cluster: Cluster,
chain: str,
network: str,
pod_name: str,
name: str,
status: str,
node_type: str,
execution_client: str,
image: str,
region: str,
network_id: Any,
pod_ip: Optional[str],
result: ScanResult,
) -> None:
"""Adopt-or-create a ``Node`` keyed on ``(cluster_id, pod_name)``.
Existing nodes are updated in place (status / metrics / image) — never
recreated. New nodes are inserted.
"""
rpc_endpoint = None
if pod_ip:
port = 9650 if chain == "lux" else 8545
rpc_endpoint = f"http://{pod_ip}:{port}/ext/bc/C/rpc"
existing = (
await db.execute(
select(Node).where(
Node.cluster_id == cluster.id,
Node.pod_name == pod_name,
)
)
).scalar_one_or_none()
now = datetime.now(UTC)
if existing is not None:
# ADOPT: refresh mutable status, never duplicate.
existing.status = status
existing.chain = chain
existing.network = network
existing.node_type = node_type
existing.execution_client = execution_client
existing.image = image
if rpc_endpoint:
existing.rpc_endpoint = rpc_endpoint
existing.last_health_check = now
result.nodes_adopted += 1
return
node = Node(
name=name,
chain=chain,
network=network,
provider="kubernetes",
status=status,
node_type=node_type,
cluster_id=cluster.id,
region=region,
rpc_endpoint=rpc_endpoint,
execution_client=execution_client,
image=image,
pod_name=pod_name,
last_health_check=now,
)
db.add(node)
result.nodes_created += 1
# Module-level singleton — stateless, safe to share.
detector = K8sNodeDetector()
+3 -1
View File
@@ -18,6 +18,8 @@ from typing import Any
import structlog
from fastapi import FastAPI, Request, Response
from bootnode import __version__
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
logger = structlog.get_logger()
@@ -166,7 +168,7 @@ def setup_otel(app: FastAPI) -> None:
"http://otel-collector.hanzo.svc:4317",
)
service_name = os.getenv("OTEL_SERVICE_NAME", "bootnode-api")
service_version = os.getenv("OTEL_SERVICE_VERSION", "2.0.0")
service_version = os.getenv("OTEL_SERVICE_VERSION", __version__)
# Always add metrics middleware (no external deps)
app.add_middleware(MetricsMiddleware)
+19 -7
View File
@@ -3,12 +3,18 @@
from bootnode.db.models import (
ApiKey,
Base,
OrgCluster,
MPCWalletModel,
Cluster,
GasPolicy,
Node,
OrgCluster,
Project,
SmartWallet,
Subscription,
TeamMember,
Usage,
User,
Webhook,
WebhookDelivery,
)
from bootnode.db.session import async_session, engine, get_db, init_db
@@ -18,11 +24,17 @@ __all__ = [
"async_session",
"init_db",
"Base",
"OrgCluster",
"Node",
"Project",
"ApiKey",
"MPCWalletModel",
"Webhook",
"Cluster",
"GasPolicy",
"Node",
"OrgCluster",
"Project",
"SmartWallet",
"Subscription",
"TeamMember",
"Usage",
"User",
"Webhook",
"WebhookDelivery",
]
+129 -176
View File
@@ -2,35 +2,33 @@
import uuid
from datetime import datetime
from enum import Enum
from typing import Any
from sqlalchemy import (
JSON,
Boolean,
DateTime,
Enum as SAEnum,
Float,
ForeignKey,
Integer,
String,
Text,
Uuid,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
# One vocabulary for nodes, defined once in the CRD layer and imported here.
# Duplicating these as loose strings is what let the seed write "running"
# while NodeStatus offered only pending/…/healthy — two disjoint sets for
# one concept, so nothing could ever match.
from bootnode.core.chains.fleet_models import NodeStatus, NodeType, SyncMode
class Base(DeclarativeBase):
"""Base class for all models."""
"""Base class for all models.
Uses portable SQLAlchemy types (JSON, Uuid) rather than the postgres
dialect (JSON, UUID) so the same models run on Base/SQLite — the canonical
store (VFS→SeaweedFS for durability) — as well as PostgreSQL.
"""
type_annotation_map = {
dict[str, Any]: JSONB,
dict[str, Any]: JSON,
}
@@ -40,14 +38,14 @@ class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
avatar: Mapped[str | None] = mapped_column(String(500), nullable=True)
roles: Mapped[list[str] | None] = mapped_column(JSONB, default=["user"])
permissions: Mapped[list[str] | None] = mapped_column(JSONB, default=["read", "write"])
roles: Mapped[list[str] | None] = mapped_column(JSON, default=["user"])
permissions: Mapped[list[str] | None] = mapped_column(JSON, default=["read", "write"])
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
@@ -68,14 +66,14 @@ class Project(Base):
__tablename__ = "projects"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
owner_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), nullable=False)
org_id: Mapped[str] = mapped_column(String(100), nullable=False, default="hanzo")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
settings: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
allowed_chains: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
allowed_chains: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
@@ -96,18 +94,18 @@ class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
key_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
key_prefix: Mapped[str] = mapped_column(String(12), nullable=False) # First chars for display
rate_limit: Mapped[int] = mapped_column(Integer, default=100) # requests per minute
compute_units_limit: Mapped[int] = mapped_column(Integer, default=1000) # CU per minute
allowed_origins: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
allowed_chains: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
allowed_origins: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
allowed_chains: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
@@ -124,17 +122,17 @@ class Webhook(Base):
__tablename__ = "webhooks"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
url: Mapped[str] = mapped_column(Text, nullable=False)
chain: Mapped[str] = mapped_column(String(50), nullable=False)
network: Mapped[str] = mapped_column(String(50), nullable=False, default="mainnet")
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
filters: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
filters: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
secret: Mapped[str] = mapped_column(String(255), nullable=False) # For HMAC signing
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
failure_count: Mapped[int] = mapped_column(Integer, default=0)
@@ -156,12 +154,12 @@ class WebhookDelivery(Base):
__tablename__ = "webhook_deliveries"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
webhook_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False
)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
response_body: Mapped[str | None] = mapped_column(Text, nullable=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=1)
@@ -182,10 +180,10 @@ class Usage(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
api_key_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True
Uuid(as_uuid=True), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True
)
chain: Mapped[str] = mapped_column(String(50), nullable=False)
network: Mapped[str] = mapped_column(String(50), nullable=False)
@@ -204,10 +202,10 @@ class SmartWallet(Base):
__tablename__ = "smart_wallets"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
address: Mapped[str] = mapped_column(String(42), nullable=False, unique=True)
owner_address: Mapped[str] = mapped_column(String(42), nullable=False)
@@ -216,7 +214,7 @@ class SmartWallet(Base):
network: Mapped[str] = mapped_column(String(50), nullable=False, default="mainnet")
salt: Mapped[str] = mapped_column(String(66), nullable=False) # bytes32 hex
is_deployed: Mapped[bool] = mapped_column(Boolean, default=False)
wallet_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
wallet_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
@@ -228,20 +226,20 @@ class TeamMember(Base):
__tablename__ = "team_members"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
email: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
invite_token: Mapped[str | None] = mapped_column(String(255), nullable=True)
invited_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
invited_by: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True)
joined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
@@ -254,19 +252,19 @@ class GasPolicy(Base):
__tablename__ = "gas_policies"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
chain: Mapped[str] = mapped_column(String(50), nullable=False)
network: Mapped[str] = mapped_column(String(50), nullable=False, default="mainnet")
rules: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
rules: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
max_gas_per_op: Mapped[int] = mapped_column(Integer, default=1000000) # gas units
max_spend_per_day_usd: Mapped[int] = mapped_column(Integer, default=100) # in cents
allowed_contracts: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
allowed_methods: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
allowed_contracts: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
allowed_methods: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
@@ -279,10 +277,10 @@ class Subscription(Base):
__tablename__ = "subscriptions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, unique=True
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, unique=True
)
tier: Mapped[str] = mapped_column(String(50), nullable=False, default="free")
hanzo_subscription_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
@@ -331,10 +329,10 @@ class OrgCluster(Base):
__tablename__ = "org_clusters"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
cluster_id: Mapped[str] = mapped_column(
String(255), nullable=False,
@@ -344,8 +342,8 @@ class OrgCluster(Base):
String(50), nullable=False, default="digitalocean",
)
region: Mapped[str] = mapped_column(String(50), nullable=False, default="sfo3")
allowed_chains: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
allowed_namespaces: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
allowed_chains: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
allowed_namespaces: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
@@ -355,154 +353,109 @@ class OrgCluster(Base):
project: Mapped["Project"] = relationship(back_populates="clusters")
class Cluster(Base):
"""Kubernetes cluster hosting blockchain nodes.
A physical/managed K8s cluster (DOKS, EKS, …) whose running blockchain
workloads Bootnode discovers and adopts. Distinct from ``OrgCluster``
(a project→cluster access-grant mapping): ``Cluster`` is the resource,
``OrgCluster`` is the grant. ``provider_id`` is the upstream cluster id
(e.g. the DigitalOcean cluster UUID) and is the natural key the k8s
detector upserts against.
"""
__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")
provider_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
region: Mapped[str] = mapped_column(String(50), nullable=False, default="sfo3")
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(String(500), nullable=True)
node_count: Mapped[int] = mapped_column(Integer, default=3)
node_size: Mapped[str] = mapped_column(String(100), default="s-4vcpu-8gb")
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", cascade="all, delete-orphan"
)
class Node(Base):
"""A blockchain node this platform is asked to run.
"""A blockchain node — discovered (Docker/K8s) or provisioned by Bootnode.
DESIRED STATE ONLY. What the node *is doing right now* — sync progress,
peer count, block height, cpu/memory, uptime, the pod that currently backs
it — is deliberately NOT stored here. Those are facts about the cluster,
and the cluster already knows them: fleet_manager reads them from the
Cluster CR. A copy in this table would be a second source of truth that is
stale the instant it is written, and the two would drift silently. Read
live state from the fleet; store intent here.
That split is the reason this model is small. It is the spec half of a
spec/status pair, which is the shape the CRD layer (core/chains/fleet_models)
already uses — not a new idea introduced here.
Single persistent record behind ``GET /v1/nodes``. K8s-managed validators
(luxd / zood StatefulSet pods adopted from LuxNetwork / ZooChain CRs) and
Docker-managed nodes both land here; the ``provider`` field distinguishes
them. ``(cluster_id, pod_name)`` and ``(provider, container_id)`` are the
natural keys the detectors upsert against so re-scans ADOPT rather than
duplicate.
"""
__tablename__ = "nodes"
# ── identity ────────────────────────────────────────────────────────────
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
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(64), nullable=False)
network: Mapped[str] = mapped_column(String(64), nullable=False)
# ── placement ───────────────────────────────────────────────────────────
cluster_id: Mapped[str] = mapped_column(String(255), nullable=False)
chain: Mapped[str] = mapped_column(String(50), nullable=False)
network: Mapped[str] = mapped_column(String(50), nullable=False, default="mainnet")
provider: Mapped[str] = mapped_column(String(50), nullable=False, default="kubernetes")
status: Mapped[str] = mapped_column(String(50), nullable=False, default="provisioning")
node_type: Mapped[str] = mapped_column(String(50), nullable=False, default="rpc")
# Placement
cluster_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid(as_uuid=True), ForeignKey("clusters.id", ondelete="SET NULL"), nullable=True
)
region: Mapped[str] = mapped_column(String(50), nullable=False, default="sfo3")
# Coordinates are for the map view and are genuinely optional — a node in a
# region we have no fix for renders without a pin rather than at (0, 0).
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
# ── desired configuration ───────────────────────────────────────────────
node_type: Mapped[NodeType] = mapped_column(
SAEnum(NodeType, name="node_type", native_enum=False, length=32),
nullable=False,
default=NodeType.FULL,
)
sync_mode: Mapped[SyncMode] = mapped_column(
SAEnum(SyncMode, name="sync_mode", native_enum=False, length=32),
nullable=False,
default=SyncMode.SNAP,
)
image: Mapped[str] = mapped_column(String(512), nullable=False, default="")
execution_client: Mapped[str | None] = mapped_column(String(64), nullable=True)
consensus_client: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Endpoints
rpc_endpoint: Mapped[str | None] = mapped_column(String(500), nullable=True)
ws_endpoint: Mapped[str | None] = mapped_column(String(500), nullable=True)
beacon_endpoint: Mapped[str | None] = mapped_column(String(500), nullable=True)
# ── last known status ───────────────────────────────────────────────────
# ONE vocabulary, the NodeStatus the CRD layer already defines. Before this
# model existed the seed wrote "running" while NodeStatus offers
# pending/init/starting/bootstrapping/healthy/unhealthy/terminated — two
# disjoint sets for one concept, so a frontend filtering `status ===
# "running"` could never match a backend value. A closed Enum column makes
# that class of drift unrepresentable rather than merely discouraged.
status: Mapped[NodeStatus] = mapped_column(
SAEnum(NodeStatus, name="node_status", native_enum=False, length=32),
nullable=False,
default=NodeStatus.PENDING,
)
# Clients / image
execution_client: Mapped[str | None] = mapped_column(String(100), nullable=True)
consensus_client: Mapped[str | None] = mapped_column(String(100), nullable=True)
sync_mode: Mapped[str | None] = mapped_column(String(50), nullable=True)
image: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
# Runtime handles
container_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
pod_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# ── the whole interface ─────────────────────────────────────────────────
# Three questions callers actually ask. Everything else is a column read.
# Live metrics (snapshot; real-time lives in Redis/Prometheus)
sync_progress: Mapped[float | None] = mapped_column(Float, nullable=True)
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)
@property
def is_serving(self) -> bool:
"""Ready to take traffic. The one predicate the UI needs."""
return self.status is NodeStatus.HEALTHY
@property
def slug(self) -> str:
"""Stable human handle, e.g. `ethereum-mainnet-validator`."""
return f"{self.chain}-{self.network}-{self.node_type.value}"
def __repr__(self) -> str:
return f"<Node {self.slug} {self.status.value}>"
class MPCWalletStatus(str, Enum):
"""Lifecycle of an MPC wallet. Closed set — a wallet is usable or it is not."""
ACTIVE = "active"
REVOKED = "revoked"
class MPCWalletModel(Base):
"""A threshold-signature wallet: `threshold`-of-`num_shares` to sign.
Distinct from SmartWallet, which is ERC-4337 account abstraction. This is
multi-party computation — no single holder can sign alone. Keeping them as
two models is the point; collapsing them would braid two unrelated custody
schemes into one row.
⚠️ `encrypted_shares` holds key material. It is encrypted at rest here, but
the standing rule is that secrets live in KMS and nowhere else. Treat this
column as a migration target, not a pattern to copy: the shares belong in
~/work/hanzo/kms with only a reference stored here. Flagged rather than
silently reproduced.
"""
__tablename__ = "mpc_wallets"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
# Lowercased on write by the API; unique so one address maps to one wallet.
address: Mapped[str] = mapped_column(String(42), nullable=False, unique=True)
chain: Mapped[str] = mapped_column(String(50), nullable=False)
network: Mapped[str] = mapped_column(String(50), nullable=False, default="mainnet")
# The threshold scheme. Both required: a wallet with an unknown threshold
# cannot be signed with, so there is no meaningful null here.
threshold: Mapped[int] = mapped_column(Integer, nullable=False)
num_shares: Mapped[int] = mapped_column(Integer, nullable=False)
share_holders: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
encrypted_shares: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
status: Mapped[MPCWalletStatus] = mapped_column(
SAEnum(MPCWalletStatus, name="mpc_wallet_status", native_enum=False, length=16),
nullable=False,
default=MPCWalletStatus.ACTIVE,
)
signing_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_signed_at: Mapped[datetime | None] = mapped_column(
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(), nullable=False
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
@property
def is_usable(self) -> bool:
"""Signable: active, and enough shares exist to reach the threshold."""
return self.status is MPCWalletStatus.ACTIVE and self.num_shares >= self.threshold
def __repr__(self) -> str:
return f"<MPCWallet {self.address} {self.threshold}/{self.num_shares} {self.status.value}>"
# Relationships
cluster: Mapped["Cluster | None"] = relationship(back_populates="nodes")
+20 -12
View File
@@ -10,27 +10,35 @@ settings = get_settings()
def _ensure_async_dsn(dsn: str) -> str:
"""Ensure the database DSN uses the asyncpg driver.
Converts ``postgresql://`` or ``postgres://`` to
``postgresql+asyncpg://`` so that SQLAlchemy's async engine
uses the correct driver regardless of how DATABASE_URL is set.
"""
"""Normalize the DSN to an async driver: asyncpg for PostgreSQL, aiosqlite
for Base/SQLite (the canonical store). Idempotent."""
if dsn.startswith("postgresql+asyncpg://"):
return dsn
if dsn.startswith("postgresql://"):
return dsn.replace("postgresql://", "postgresql+asyncpg://", 1)
if dsn.startswith("postgres://"):
return dsn.replace("postgres://", "postgresql+asyncpg://", 1)
if dsn.startswith("sqlite+aiosqlite://"):
return dsn
if dsn.startswith("sqlite://"):
return dsn.replace("sqlite://", "sqlite+aiosqlite://", 1)
return dsn
engine = create_async_engine(
_ensure_async_dsn(str(settings.database_url)),
pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow,
echo=settings.debug,
)
_dsn = _ensure_async_dsn(str(settings.database_url))
# Base/SQLite doesn't use a sized connection pool — the default pool rejects
# pool_size/max_overflow, and async cross-task access needs check_same_thread
# off. PostgreSQL keeps the sized async pool.
if _dsn.startswith("sqlite"):
_engine_kwargs: dict = {"connect_args": {"check_same_thread": False}}
else:
_engine_kwargs = {
"pool_size": settings.db_pool_size,
"max_overflow": settings.db_max_overflow,
}
engine = create_async_engine(_dsn, echo=settings.debug, **_engine_kwargs)
async_session = async_sessionmaker(
engine,
+76
View File
@@ -0,0 +1,76 @@
"""Standalone read-only Lux fleet API.
A minimal FastAPI surface that exposes the live luxd fleet overview for the
lux.cloud dashboard. It depends ONLY on the self-contained fleet collector
(``bootnode.core.chains.lux_fleet``) — it does not import the main monolith,
its database, or any mutating routers. This keeps the read-only fleet view
fully isolated (separation of concerns) and independently deployable.
Run: uvicorn bootnode.fleet_app:app --host 0.0.0.0 --port 8000
Serves:
GET /health -> liveness (open)
GET /v1/fleets/lux/overview -> full fleet overview (all networks); ADMIN-ONLY
"""
from __future__ import annotations
import os
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from bootnode.core.chains.lux_fleet import (
LuxFleetOverview,
fleet_data_available,
get_overview,
)
from bootnode.core.iam import IAMUser, require_admin
app = FastAPI(
title="Lux Fleet API",
version="1.0.0",
description="Read-only live overview of the managed luxd fleet (devnet/testnet/mainnet).",
)
# White-label cloud surfaces that consume this read-only API.
_DEFAULT_ORIGINS = [
"https://lux.cloud",
"https://www.lux.cloud",
"https://cloud.lux.network",
]
_extra = [o.strip() for o in os.environ.get("ALLOWED_ORIGINS", "").split(",") if o.strip()]
_origins = _DEFAULT_ORIGINS + [o for o in _extra if o not in _DEFAULT_ORIGINS]
app.add_middleware(
CORSMiddleware,
allow_origins=_origins,
allow_credentials=False,
allow_methods=["GET"],
allow_headers=["*"],
)
@app.get("/health")
async def health() -> dict:
# MODE A (direct luxd RPC over configured endpoints) is always available;
# k8s enrichment (CR + pods) is on only when a lux-k8s kubeconfig is set.
return {"status": "ok", "k8s_enrichment": fleet_data_available()}
@app.get("/v1/fleets/lux/overview", response_model=LuxFleetOverview)
async def lux_overview(
networks: str | None = None,
_admin: IAMUser = Depends(require_admin),
) -> LuxFleetOverview:
"""Admin-only fleet overview across devnet/testnet/mainnet.
Cross-tenant infra view, gated server-side behind the IAM admin role
(``require_admin``): missing/invalid token → 401, non-admin → 403.
``networks`` optionally narrows to a comma-separated subset.
"""
selected = None
if networks:
selected = [n.strip() for n in networks.split(",") if n.strip()]
return await get_overview(selected)
+4 -3
View File
@@ -8,6 +8,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import ORJSONResponse
from bootnode import __version__
from bootnode.api import router as api_router
from bootnode.config import get_settings
from bootnode.core.cache import redis_client
@@ -86,7 +87,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI(
title="Bootnode API",
description="Blockchain Development Platform - Multi-chain RPC, Token API, NFT API, Smart Wallets, and more",
version="2.0.0",
version=__version__,
docs_url="/docs" if not settings.is_production else None,
redoc_url="/redoc" if not settings.is_production else None,
openapi_url="/openapi.json" if not settings.is_production else None,
@@ -141,7 +142,7 @@ app.include_router(ws_router, prefix=f"{settings.api_prefix}/ws", tags=["WebSock
@app.get("/health")
async def health_check() -> dict:
"""Health check endpoint."""
return {"status": "healthy", "version": "2.0.0"}
return {"status": "healthy", "version": __version__}
@app.get("/")
@@ -157,7 +158,7 @@ async def root() -> dict:
"""
body = {
"name": "Bootnode API",
"version": "2.0.0",
"version": __version__,
"status": "operational",
}
if app.docs_url:
+3 -2
View File
@@ -15,6 +15,7 @@ from typing import Any
import capnp
import structlog
from bootnode import __version__
from bootnode.config import get_settings
logger = structlog.get_logger()
@@ -219,7 +220,7 @@ class BootnodeZapImpl(bootnode_capnp.Bootnode.Server):
return bootnode_capnp.ServerInfo.new_message(
name="bootnode",
version="2.0.0",
version=__version__,
capabilities=bootnode_capnp.ServerInfo.Capabilities.new_message(
tools=True,
resources=True,
@@ -385,7 +386,7 @@ class BootnodeZapImpl(bootnode_capnp.Bootnode.Server):
mimeType="application/json",
)
content_msg.content.text = json.dumps({
"version": "2.0.0",
"version": __version__,
"rate_limit": 1000,
})
return content_msg
+1
View File
@@ -24,6 +24,7 @@ dependencies = [
"pydantic-settings>=2.6.0",
"sqlalchemy[asyncio]>=2.0.36",
"asyncpg>=0.30.0",
"aiosqlite>=0.20.0",
"alembic>=1.14.0",
"redis>=5.2.0",
"httpx>=0.28.0",
+2 -2
View File
@@ -60,12 +60,12 @@ class TestServiceStatus:
ready_replicas=2,
cpu_usage=25.5,
memory_usage=512.0,
image="ghcr.io/hanzoai/bootnode:api-latest",
image="ghcr.io/bootnode/bootnode:api-latest",
started_at="2024-01-15T10:00:00Z",
)
assert status.cpu_usage == 25.5
assert status.memory_usage == 512.0
assert status.image == "ghcr.io/hanzoai/bootnode:api-latest"
assert status.image == "ghcr.io/bootnode/bootnode:api-latest"
class TestDeployTarget:
+195
View File
@@ -0,0 +1,195 @@
"""Adversarial tests for the server-side admin gate on the luxd fleet overview.
Proves the ONE cross-tenant infra route is deny-by-default on BOTH surfaces
(the standalone ``bootnode.fleet_app`` and the mounted ``api/fleets`` router):
unauthenticated -> 401
invalid token -> 401
authenticated non-admin -> 403
admin (role) -> 200
admin (bootstrap email allowlist, e.g. the sole initial superuser) -> 200
The gate is exercised for real end-to-end via HTTP; only the IAM token
verification (JWKS network call) and the luxd RPC collector are stubbed.
"""
import os
# Base/SQLite so the mounted router's db.session engine imports without Postgres.
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
import pytest
from fastapi import FastAPI, HTTPException, status
from fastapi.testclient import TestClient
import bootnode.fleet_app as fleet_app
from bootnode.core.chains.lux_fleet import LuxFleetOverview
from bootnode.core.iam import IAMUser, get_iam_client, require_admin
OVERVIEW_PATH = "/v1/fleets/lux/overview"
class _FakeIAM:
"""Stand-in IAMClient — maps a presented bearer token to a fixed decision,
with no JWKS/network. ``verify_token`` is only reached when a credential is
actually presented (missing credentials are rejected earlier, at 401)."""
def __init__(self, user: IAMUser | None = None, raise_401: bool = False):
self._user = user
self._raise = raise_401
async def verify_token(self, token: str) -> IAMUser:
if self._raise:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
assert self._user is not None
return self._user
def _user(email: str = "[email protected]", roles: list[str] | None = None) -> IAMUser:
return IAMUser(id="u1", name="U", email=email, org="lux", roles=roles or [])
async def _empty_overview(selected=None) -> LuxFleetOverview:
return LuxFleetOverview()
# --------------------------------------------------------------------------- #
# Fixtures: one TestClient per (app, iam-decision), with clean overrides.
# --------------------------------------------------------------------------- #
def _client_for(app: FastAPI, iam: _FakeIAM) -> TestClient:
app.dependency_overrides[get_iam_client] = lambda: iam
return TestClient(app)
@pytest.fixture
def standalone(monkeypatch):
# Never touch a real luxd fleet from the gate tests.
monkeypatch.setattr(fleet_app, "get_overview", _empty_overview)
yield fleet_app.app
fleet_app.app.dependency_overrides.clear()
def _import_fleets_router():
"""Import ``bootnode.api.fleets`` WITHOUT executing ``bootnode.api/__init__``.
The package __init__ eagerly wires ~24 production routers (web3, bcrypt,
mpc, …); the fleets submodule itself only needs deps/db/iam. We register a
lightweight ``bootnode.api`` package object (correct ``__path__``) so the
submodule loads directly — the same route object the app serves, minus the
heavy tail.
"""
import importlib
import pathlib
import sys
import types
import bootnode
if "bootnode.api" not in sys.modules:
pkg = types.ModuleType("bootnode.api")
pkg.__path__ = [str(pathlib.Path(bootnode.__file__).parent / "api")]
sys.modules["bootnode.api"] = pkg
return importlib.import_module("bootnode.api.fleets")
@pytest.fixture
def mounted(monkeypatch):
fleets = _import_fleets_router()
monkeypatch.setattr(fleets, "get_lux_overview", _empty_overview)
app = FastAPI()
app.include_router(fleets.router, prefix="/v1/fleets")
yield app
app.dependency_overrides.clear()
# Run the identical adversarial matrix against BOTH surfaces.
_APPS = ["standalone", "mounted"]
def _app(name, standalone, mounted):
return standalone if name == "standalone" else mounted
# --------------------------------------------------------------------------- #
# The adversarial matrix
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("app_name", _APPS)
def test_unauthenticated_is_401(app_name, standalone, mounted):
app = _app(app_name, standalone, mounted)
client = _client_for(app, _FakeIAM(_user())) # iam unused: no creds presented
r = client.get(OVERVIEW_PATH)
assert r.status_code == 401, r.text
assert "networks" not in r.text # no fleet data leaked in the body
@pytest.mark.parametrize("app_name", _APPS)
def test_invalid_token_is_401(app_name, standalone, mounted):
app = _app(app_name, standalone, mounted)
client = _client_for(app, _FakeIAM(raise_401=True))
r = client.get(OVERVIEW_PATH, headers={"Authorization": "Bearer garbage"})
assert r.status_code == 401, r.text
@pytest.mark.parametrize("app_name", _APPS)
def test_authenticated_non_admin_is_403(app_name, standalone, mounted):
app = _app(app_name, standalone, mounted)
client = _client_for(app, _FakeIAM(_user(roles=[]))) # a normal customer
r = client.get(OVERVIEW_PATH, headers={"Authorization": "Bearer good"})
assert r.status_code == 403, r.text
assert "networks" not in r.text # denied before any collection
@pytest.mark.parametrize("app_name", _APPS)
def test_admin_role_is_200(app_name, standalone, mounted):
app = _app(app_name, standalone, mounted)
client = _client_for(app, _FakeIAM(_user(roles=["admin"])))
r = client.get(OVERVIEW_PATH, headers={"Authorization": "Bearer good"})
assert r.status_code == 200, r.text
assert "networks" in r.json()
@pytest.mark.parametrize("app_name", _APPS)
def test_bootstrap_allowlist_admin_is_200(app_name, standalone, mounted, monkeypatch):
# Sole initial superuser reaches the view before the IAM admin role is wired.
monkeypatch.setenv("FLEET_ADMIN_EMAILS", "[email protected]")
app = _app(app_name, standalone, mounted)
client = _client_for(app, _FakeIAM(_user(email="[email protected]", roles=[])))
r = client.get(OVERVIEW_PATH, headers={"Authorization": "Bearer good"})
assert r.status_code == 200, r.text
@pytest.mark.parametrize("app_name", _APPS)
def test_non_allowlisted_email_still_403(app_name, standalone, mounted, monkeypatch):
monkeypatch.setenv("FLEET_ADMIN_EMAILS", "[email protected]")
app = _app(app_name, standalone, mounted)
client = _client_for(app, _FakeIAM(_user(email="[email protected]", roles=[])))
r = client.get(OVERVIEW_PATH, headers={"Authorization": "Bearer good"})
assert r.status_code == 403, r.text
def test_standalone_health_stays_open(standalone):
# Liveness must remain unauthenticated (no gate regression on /health).
client = TestClient(standalone)
assert client.get("/health").status_code == 200
# --------------------------------------------------------------------------- #
# Gate primitive, tested directly (defense in depth, no HTTP layer).
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_require_admin_primitive_role():
admin = _user(roles=["admin"])
assert await require_admin(admin) is admin
with pytest.raises(HTTPException) as exc:
await require_admin(_user(roles=[]))
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_require_admin_primitive_allowlist(monkeypatch):
monkeypatch.setenv("FLEET_ADMIN_EMAILS", "[email protected], [email protected]")
ok = _user(email="[email protected]", roles=[]) # case-insensitive match
assert await require_admin(ok) is ok
+255
View File
@@ -0,0 +1,255 @@
"""Tests for the read-only Lux fleet overview collector (lux_fleet.py).
Pure-logic + mocked-IO coverage:
- CR parsing (lux.cloud/v1 shape): version, plugin-set, validators, phase.
- Node-set union from pods + per-pod LB services.
- RPC enrichment (mocked httpx) → version/peers/heights/bootstrapped.
- Overview aggregation + totals.
- Graceful "no kubeconfig" shaped-empty response.
"""
import os
from unittest.mock import patch
import pytest
from bootnode.core.chains import lux_fleet
from bootnode.core.chains.lux_fleet import (
ChainHeight,
DexSummary,
LuxNode,
LuxNetworkFleet,
_parse_cr,
get_overview,
)
def _dex_stub() -> DexSummary:
return DexSummary(d_chain_bootstrapped=False, note="D-Chain not yet bootstrapped")
# --- Sample data mirroring the live devnet LuxNetwork CR (lux.cloud/v1) -----
SAMPLE_CR = {
"apiVersion": "lux.cloud/v1",
"kind": "LuxNetwork",
"spec": {
"validators": 5,
"image": {"repository": "ghcr.io/luxfi/node", "tag": "v1.30.40-d69d3257"},
"pluginSource": {
"bucket": "s3://lux-plugins-devnet/v1.3.1/",
"vmPlugins": [
{"objectKey": "dexvm-linux-amd64", "vmID": "dexvm"},
{"objectKey": "evm-linux-amd64", "vmID": "mgj786NP"},
{"objectKey": "no-id"}, # missing vmID is skipped
],
},
},
"status": {"phase": "Unknown", "observedGeneration": 27},
}
class TestParseCR:
def test_extracts_declared_shape(self):
p = _parse_cr(SAMPLE_CR)
assert p["validators"] == 5
assert p["image"] == "ghcr.io/luxfi/node:v1.30.40-d69d3257"
assert p["version"] == "v1.30.40-d69d3257"
assert p["plugin_set"] == "s3://lux-plugins-devnet/v1.3.1/"
assert p["phase"] == "Unknown"
def test_vm_plugins_skip_missing_ids(self):
p = _parse_cr(SAMPLE_CR)
assert p["vm_plugins"] == ["dexvm", "mgj786NP"]
def test_empty_cr_safe(self):
p = _parse_cr({})
assert p["validators"] == 0
assert p["phase"] == "Unknown"
assert p["version"] is None
assert p["plugin_set"] is None
assert p["vm_plugins"] == []
class TestEndpointConfig:
def test_defaults_present(self):
eps = lux_fleet.DEFAULT_ENDPOINTS
assert set(eps) == {"mainnet", "testnet", "devnet"}
for net, cfg in eps.items():
assert cfg["http_port"] in (9630, 9640, 9650)
assert len(cfg["nodes"]) == 5
def test_env_json_overrides_defaults(self, monkeypatch):
monkeypatch.delenv(lux_fleet.ENV_ENDPOINTS_FILE, raising=False)
monkeypatch.setenv(
lux_fleet.ENV_ENDPOINTS_JSON,
'{"devnet": {"http_port": 9650, "nodes": [{"index": 0, "ip": "9.9.9.9"}]}}',
)
eps = lux_fleet._load_endpoints()
assert eps["devnet"]["nodes"][0]["ip"] == "9.9.9.9"
def test_bad_json_falls_back_to_defaults(self, monkeypatch):
monkeypatch.delenv(lux_fleet.ENV_ENDPOINTS_FILE, raising=False)
monkeypatch.setenv(lux_fleet.ENV_ENDPOINTS_JSON, "{not json")
assert lux_fleet._load_endpoints() == lux_fleet.DEFAULT_ENDPOINTS
class TestModeA_DirectRPC:
@pytest.mark.asyncio
async def test_overview_direct_rpc_no_kubeconfig(self, monkeypatch):
# No kubeconfig → MODE A: nodes come from endpoint config, health from RPC.
monkeypatch.delenv(lux_fleet.ENV_KUBECONFIG_PATH, raising=False)
monkeypatch.delenv(lux_fleet.ENV_KUBECONFIG_DATA, raising=False)
async def fake_probe(node: LuxNode, http_port: int):
node.rpc_reachable = True
node.version = "luxd/1.30.40"
node.peers = 4
node.chains = [ChainHeight(chain="C", bootstrapped=True, height=500)]
with patch("os.path.exists", return_value=False), \
patch.object(lux_fleet, "_probe_node_rpc", side_effect=fake_probe), \
patch.object(lux_fleet, "_dex_summary",
side_effect=lambda n, net: _dex_stub()):
ov = await get_overview(["mainnet"])
f = ov.networks[0]
assert f.network == "mainnet"
assert f.nodes_total == 5 # from DEFAULT_ENDPOINTS
assert f.nodes_ready == 5 # MODE A: ready == rpc_reachable
assert f.nodes_rpc_ok == 5
assert f.validators == 5 # derived from node count
assert f.version == "luxd/1.30.40" # derived from RPC
assert f.phase == "Running"
assert ov.totals["nodes_rpc_ok"] == 5
class TestCollectNetwork:
@pytest.mark.asyncio
async def test_mode_b_enrichment_with_mocked_io(self):
"""MODE B: CR + pods/svcs enrichment merged with RPC. luxd-0/1/2 expose
RPC (have LB IP); luxd-3/4 have pods only."""
pods = {i: {"phase": "Running", "ready": True} for i in range(5)}
ips = {0: "10.0.0.1", 1: "10.0.0.2", 2: "10.0.0.3"}
async def fake_probe(node: LuxNode, http_port: int):
if node.external_ip:
node.rpc_reachable = True
node.version = "luxd/1.30.39"
node.node_id = f"NodeID-{node.pod_index}"
node.peers = 4
node.healthy = True
node.chains = [
ChainHeight(chain="C", bootstrapped=True, height=108),
ChainHeight(chain="P", bootstrapped=True, height=0),
ChainHeight(chain="X", bootstrapped=True),
ChainHeight(chain="D", bootstrapped=False),
]
with patch.object(lux_fleet, "_read_luxnetwork_cr", return_value=SAMPLE_CR), \
patch.object(lux_fleet, "_list_luxd_pods", return_value=pods), \
patch.object(lux_fleet, "_list_luxd_external_ips", return_value=ips), \
patch.object(lux_fleet, "_probe_node_rpc", side_effect=fake_probe), \
patch.object(lux_fleet, "_dex_summary", side_effect=lambda n, net: _dex_stub()):
# endpoints={} so the node set comes purely from k8s discovery.
fleet = await lux_fleet._collect_network(
"devnet", {}, custom_api=object(), core_api=object())
assert fleet.network == "devnet"
assert fleet.namespace == "lux-devnet"
assert fleet.version == "v1.30.40-d69d3257" # from CR
assert fleet.plugin_set == "s3://lux-plugins-devnet/v1.3.1/"
assert fleet.validators == 5
assert fleet.nodes_total == 5 # union of pods + svcs
assert fleet.nodes_ready == 5 # MODE B: pod readiness
assert fleet.nodes_rpc_ok == 3 # only LB-exposed nodes answer
assert fleet.total_peers == 12 # 3 nodes * 4 peers
assert fleet.dex.d_chain_bootstrapped is False
n3 = next(n for n in fleet.nodes if n.pod_index == 3)
assert n3.rpc_reachable is False
assert n3.phase == "Running"
@pytest.mark.asyncio
async def test_mode_a_uses_endpoint_config(self):
"""MODE A: node set + IPs come from the endpoint config (no k8s)."""
endpoints = {"mainnet": {"http_port": 9630, "nodes": [
{"index": 0, "ip": "1.1.1.1"}, {"index": 1, "ip": "2.2.2.2"},
]}}
async def fake_probe(node: LuxNode, http_port: int):
assert http_port == 9630
node.rpc_reachable = True
node.version = "luxd/1.30.40"
node.peers = 7
node.chains = [ChainHeight(chain="D", bootstrapped=True)]
with patch.object(lux_fleet, "_probe_node_rpc", side_effect=fake_probe), \
patch.object(lux_fleet, "_dex_summary", side_effect=lambda n, net: _dex_stub()):
fleet = await lux_fleet._collect_network("mainnet", endpoints)
assert fleet.nodes_total == 2
assert [n.external_ip for n in fleet.nodes] == ["1.1.1.1", "2.2.2.2"]
assert fleet.nodes_ready == 2 # ready == rpc_reachable in MODE A
assert fleet.validators == 2 # derived from node count
assert fleet.version == "luxd/1.30.40"
assert fleet.total_peers == 14
class TestRpcParsing:
@pytest.mark.asyncio
async def test_probe_parses_rpc_results(self):
"""_probe_node_rpc maps luxd JSON-RPC into the node model."""
import httpx
def handler(request: httpx.Request) -> httpx.Response:
body = request.read().decode()
if "info.getNodeVersion" in body:
return httpx.Response(200, json={"result": {
"version": "luxd/1.30.39", "gitCommit": "abc123",
"databaseVersion": "v1.4.5"}})
if "info.getNodeID" in body:
return httpx.Response(200, json={"result": {"nodeID": "NodeID-XYZ"}})
if "info.peers" in body:
return httpx.Response(200, json={"result": {"peers": [1, 2, 3, 4]}})
if "info.isBootstrapped" in body:
ch = "C" if '"C"' in body else ("D" if '"D"' in body else "?")
return httpx.Response(200, json={"result": {"isBootstrapped": ch != "D"}})
if "eth_blockNumber" in body:
return httpx.Response(200, json={"result": "0x6c"})
if "platform.getHeight" in body:
return httpx.Response(200, json={"result": {"height": "42"}})
return httpx.Response(404)
def health_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"healthy": True})
def router(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/ext/health"):
return health_handler(request)
return handler(request)
transport = httpx.MockTransport(router)
node = LuxNode(pod_name="luxd-0", pod_index=0, external_ip="10.0.0.1")
# Patch AsyncClient to use the mock transport.
orig = httpx.AsyncClient
def patched(*args, **kwargs):
kwargs["transport"] = transport
kwargs.pop("timeout", None)
return orig(*args, **kwargs)
with patch("bootnode.core.chains.lux_fleet.httpx.AsyncClient", patched):
await lux_fleet._probe_node_rpc(node, 9650)
assert node.rpc_reachable is True
assert node.version == "luxd/1.30.39"
assert node.git_commit == "abc123"
assert node.node_id == "NodeID-XYZ"
assert node.peers == 4
assert node.healthy is True
heights = {c.chain: c for c in node.chains}
assert heights["C"].height == 108
assert heights["C"].bootstrapped is True
assert heights["P"].height == 42
assert heights["D"].bootstrapped is False
+3 -6
View File
@@ -79,11 +79,8 @@ services:
environment:
QDRANT__SERVICE__GRPC_PORT: 6334
QDRANT__LOG_LEVEL: INFO
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:6333/"]
interval: 5s
timeout: 5s
retries: 5
# qdrant image ships no wget/curl; readiness is gated via depends_on:
# service_started + the api's own startup checks instead of a broken healthcheck.
# Meilisearch - Full-text search for addresses, tokens, contracts
meilisearch:
@@ -151,7 +148,7 @@ services:
redis:
condition: service_healthy
qdrant:
condition: service_healthy
condition: service_started
meilisearch:
condition: service_healthy
healthcheck:
+55
View File
@@ -0,0 +1,55 @@
# Hanzo CI/CD — one declarative build + test config.
#
# Read by platform.hanzo.ai, the sole orchestrator for this repo. On every
# push / PR the platform GitHub App delivers the event to platform, which reads
# THIS file and builds each image on the shared in-cluster `hanzo-build-*`
# arcbuild pool (bootnode has no self-hosted runners of its own — the reason the
# old GitHub-Actions `.github/workflows/cicd.yml` dead-ended and was removed).
# Registry credentials live in the cluster (kaniko-ghcr), never in GitHub.
#
# Images publish to ghcr.io/bootnode/bootnode as `<sha>-amd64-<tag-suffix>`.
# Rollout is GitOps, not platform: luxfi/universe pins the freshly-built tag and
# Argo applies it. The `deploy:` block below is therefore advisory (platform
# treats the Deployment-style `services:` form as build-only).
images:
- name: api
context: ./api
dockerfile: ./api/Dockerfile
repo: ghcr.io/bootnode/bootnode
tag-suffix: api
- name: web
context: ./web
dockerfile: ./web/Dockerfile
repo: ghcr.io/bootnode/bootnode
tag-suffix: web
# Commands run on the arc runner after the image builds. Non-zero fails the run.
test:
- name: api
run: >-
docker compose -f compose.yml up -d postgres redis >/dev/null 2>&1;
cd api &&
DATABASE_URL=postgresql+asyncpg://bootnode:bootnode@localhost:5432/bootnode
JWT_SECRET=ci API_KEY_SALT=ci
uv run pytest -q --tb=short
# Roll the freshly-built images onto the cluster. Only on the listed branches /
# on v* tags. The operator (or kubectl, via the KMS kubeconfig) patches each
# Service to the new image. Omit this block for build-only repos.
deploy:
cluster: bootnode-k8s
namespace: bootnode
on:
- main
services:
- name: web3-platform-api
image: api
- name: web3-platform-web-bootnode
image: web
# KMS source for /deploy secrets (GHCR_TOKEN, KUBECONFIG). workspace + endpoint
# are supplied as repo vars so this file carries no secrets.
kms:
path: /deploy
environment: prod
+5 -5
View File
@@ -92,7 +92,7 @@ services:
build:
context: ../api
dockerfile: Dockerfile
image: ghcr.io/hanzoai/bootnode:api-latest
image: ghcr.io/bootnode/bootnode:api-latest
container_name: bootnode-api
restart: unless-stopped
environment:
@@ -134,7 +134,7 @@ services:
build:
context: ../indexer
dockerfile: Dockerfile
image: ghcr.io/hanzoai/bootnode:indexer-latest
image: ghcr.io/bootnode/bootnode:indexer-latest
container_name: bootnode-indexer
restart: unless-stopped
environment:
@@ -173,7 +173,7 @@ services:
build:
context: ../api
dockerfile: Dockerfile
image: ghcr.io/hanzoai/bootnode:api-latest
image: ghcr.io/bootnode/bootnode:api-latest
container_name: bootnode-webhook-worker
restart: unless-stopped
command: ["python", "-m", "bootnode.workers.webhook"]
@@ -195,7 +195,7 @@ services:
build:
context: ../bundler
dockerfile: Dockerfile
image: ghcr.io/hanzoai/bootnode:bundler-latest
image: ghcr.io/bootnode/bootnode:bundler-latest
container_name: bootnode-bundler
restart: unless-stopped
environment:
@@ -220,7 +220,7 @@ services:
build:
context: ../web
dockerfile: Dockerfile
image: ghcr.io/hanzoai/bootnode:web-latest
image: ghcr.io/bootnode/bootnode:web-latest
container_name: bootnode-web
restart: unless-stopped
environment:
+1 -1
View File
@@ -29,7 +29,7 @@ spec:
serviceAccountName: bootnode-api
containers:
- name: api
image: ghcr.io/hanzoai/bootnode:latest
image: ghcr.io/bootnode/bootnode:latest
imagePullPolicy: Always
ports:
- name: http
+3 -3
View File
@@ -214,7 +214,7 @@ spec:
spec:
initContainers:
- name: db-migrate
image: ghcr.io/hanzoai/bootnode:api-latest
image: ghcr.io/bootnode/bootnode:api-latest
command: ["python", "-m", "alembic", "upgrade", "head"]
envFrom:
- configMapRef:
@@ -223,7 +223,7 @@ spec:
name: bootnode-secrets
containers:
- name: api
image: ghcr.io/hanzoai/bootnode:api-latest
image: ghcr.io/bootnode/bootnode:api-latest
ports:
- containerPort: 8000
name: http
@@ -290,7 +290,7 @@ spec:
spec:
containers:
- name: web
image: ghcr.io/hanzoai/bootnode:web-latest
image: ghcr.io/bootnode/bootnode:web-latest
ports:
- containerPort: 3000
env:
+1 -1
View File
@@ -28,7 +28,7 @@ spec:
serviceAccountName: bootnode-operator
containers:
- name: operator
image: ghcr.io/hanzoai/bootnode:operator-2.0.0
image: ghcr.io/bootnode/bootnode:operator-2.0.0
imagePullPolicy: Always
env:
- name: OPERATOR_NAMESPACE
+1 -1
View File
@@ -26,7 +26,7 @@ spec:
spec:
containers:
- name: docs
image: ghcr.io/hanzoai/bootnode-docs:latest
image: ghcr.io/bootnode/bootnode-docs:latest
imagePullPolicy: Always
ports:
- name: http
+1 -1
View File
@@ -26,7 +26,7 @@ spec:
spec:
containers:
- name: api
image: ghcr.io/hanzoai/bootnode:latest
image: ghcr.io/bootnode/bootnode:latest
imagePullPolicy: Always
ports:
- name: http
+1 -1
View File
@@ -26,7 +26,7 @@ spec:
serviceAccountName: bootnode-indexer
containers:
- name: indexer
image: ghcr.io/hanzoai/bootnode:indexer-latest
image: ghcr.io/bootnode/bootnode:indexer-latest
imagePullPolicy: Always
ports:
- name: http
+165
View File
@@ -0,0 +1,165 @@
# Lux Fleet API — read-only live overview of the managed luxd fleet.
#
# A minimal, isolated FastAPI surface (bootnode.fleet_app) reusing the bootnode
# api image. It observes the luxd fleet on do-sfo3-lux-k8s strictly READ-ONLY by
# querying each validator's public luxd JSON-RPC endpoint (MODE A): node version,
# node ID, peers, per-chain bootstrap (C/P/X/D), C-Chain eth_blockNumber, P-Chain
# platform.getHeight, and a native D-Chain DEX summary. It serves
# GET /v1/fleets/lux/overview — the data source for the lux.cloud fleet dashboard.
#
# No lux-k8s credential is needed and nothing on the luxd nets is touched: the
# per-pod RPC endpoints are public infra. Endpoints are operational config in a
# ConfigMap (updatable without a rebuild). If a READ-ONLY lux-k8s kubeconfig is
# later provisioned (LUX_K8S_KUBECONFIG), the service auto-enriches with CR data
# (declared validators, image tag, plugin-set) — MODE B.
#
# Why a separate deployment: the read-only fleet view is orthogonal to the api
# monolith and depends only on the self-contained collector, so it ships and
# scales independently behind the same api.cloud.lux.network ingress.
---
# Per-pod luxd RPC endpoints (public LoadBalancer IPs). Operational config, not
# secrets. Edit + rollout to update without rebuilding the image.
apiVersion: v1
kind: ConfigMap
metadata:
name: lux-fleet-endpoints
namespace: bootnode
data:
endpoints.json: |
{
"mainnet": {"http_port": 9630, "nodes": [
{"index": 0, "ip": "129.212.164.46"},
{"index": 1, "ip": "137.184.246.133"},
{"index": 2, "ip": "143.244.210.43"},
{"index": 3, "ip": "143.244.210.71"},
{"index": 4, "ip": "24.144.70.85"}
]},
"testnet": {"http_port": 9640, "nodes": [
{"index": 0, "ip": "134.199.187.16"},
{"index": 1, "ip": "209.38.174.84"},
{"index": 2, "ip": "209.38.173.80"},
{"index": 3, "ip": "134.199.187.62"},
{"index": 4, "ip": "164.90.247.204"}
]},
"devnet": {"http_port": 9650, "nodes": [
{"index": 0, "ip": "164.90.247.220"},
{"index": 1, "ip": "146.190.14.112"},
{"index": 2, "ip": "24.199.68.130"},
{"index": 3, "ip": "134.199.184.181"},
{"index": 4, "ip": "209.38.172.213"}
]}
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: lux-fleet
namespace: bootnode
labels:
app: lux-fleet
app.kubernetes.io/part-of: bootnode
spec:
replicas: 2
selector:
matchLabels:
app: lux-fleet
template:
metadata:
labels:
app: lux-fleet
app.kubernetes.io/part-of: bootnode
spec:
# GHCR pull creds come from the default ServiceAccount (ghcr-pull),
# matching the other bootnode deployments.
containers:
- name: lux-fleet
# Reuses the bootnode api image (built by cicd.yml → ghcr.io/bootnode/bootnode).
# Pin to the api tag carrying bootnode.fleet_app at deploy time.
image: ghcr.io/bootnode/bootnode:api
imagePullPolicy: Always
command: ["uvicorn", "bootnode.fleet_app:app", "--host", "0.0.0.0", "--port", "8000"]
ports:
- containerPort: 8000
name: http
env:
- name: LUX_FLEET_ENDPOINTS_FILE
value: /etc/lux-fleet/endpoints.json
- name: ALLOWED_ORIGINS
value: "https://lux.cloud,https://www.lux.cloud,https://cloud.lux.network"
volumeMounts:
- name: endpoints
mountPath: /etc/lux-fleet
readOnly: true
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 15
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
periodSeconds: 30
resources:
requests:
cpu: 100m
memory: 192Mi
limits:
cpu: 750m
memory: 512Mi
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
volumes:
- name: endpoints
configMap:
name: lux-fleet-endpoints
---
apiVersion: v1
kind: Service
metadata:
name: lux-fleet
namespace: bootnode
labels:
app: lux-fleet
spec:
type: ClusterIP
selector:
app: lux-fleet
ports:
- port: 80
targetPort: 8000
name: http
---
# Route only the read-only overview path to the fleet service. All other
# api.cloud.lux.network paths continue to the main api (web3-platform-api).
# Higher-specificity path wins under nginx ingress. TLS for this host is
# already owned/terminated by the existing api ingress (cloud-lux-network-api),
# so this object only adds the path rule and CORS — no duplicate cert.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: lux-fleet
namespace: bootnode
# CORS is handled by the app (fleet_app CORSMiddleware) to avoid duplicate
# Access-Control-* headers — do not also enable nginx CORS here.
spec:
ingressClassName: nginx
rules:
- host: api.cloud.lux.network
http:
paths:
- path: /v1/fleets/lux/overview
pathType: Prefix
backend:
service:
name: lux-fleet
port:
number: 80
+1 -1
View File
@@ -19,7 +19,7 @@ spec:
serviceAccountName: bootnode-operator
containers:
- name: operator
image: ghcr.io/hanzoai/bootnode:operator-latest
image: ghcr.io/bootnode/bootnode:operator-latest
args:
- "--namespace"
- "" # watch all namespaces
+4 -4
View File
@@ -30,7 +30,7 @@ spec:
spec:
containers:
- name: web
image: ghcr.io/hanzoai/bootnode-web:latest
image: ghcr.io/bootnode/bootnode-web:latest
imagePullPolicy: Always
ports:
- name: http
@@ -126,7 +126,7 @@ spec:
spec:
containers:
- name: web
image: ghcr.io/hanzoai/bootnode-web:latest
image: ghcr.io/bootnode/bootnode-web:latest
imagePullPolicy: Always
ports:
- name: http
@@ -222,7 +222,7 @@ spec:
spec:
containers:
- name: web
image: ghcr.io/hanzoai/bootnode-web:latest
image: ghcr.io/bootnode/bootnode-web:latest
imagePullPolicy: Always
ports:
- name: http
@@ -318,7 +318,7 @@ spec:
spec:
containers:
- name: web
image: ghcr.io/hanzoai/bootnode-web:latest
image: ghcr.io/bootnode/bootnode-web:latest
imagePullPolicy: Always
ports:
- name: http
+26
View File
@@ -0,0 +1,26 @@
# Build outputs and caches — regenerated inside the Dockerfile (pnpm install + next build).
# Excluding them keeps the build context small so the buildx kubernetes-driver
# context transfer is fast and reliable. Does NOT alter the produced artifact.
node_modules
.next
.turbo
out
dist
# VCS / editor / OS
.git
.gitignore
.vscode
.idea
.DS_Store
# Logs / local env / test artifacts
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env*.local
coverage
playwright-report
test-results
*.tsbuildinfo
+1
View File
@@ -0,0 +1 @@
*.tsbuildinfo
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next"
import { Navbar } from "@/components/navbar"
import { Footer } from "@/components/footer"
import { FleetDashboard } from "@/components/lux/fleet-dashboard"
import { getBrand } from "@/lib/brand"
export const metadata: Metadata = {
title: "Node Fleet — Lux Cloud",
description:
"Live status of the managed luxd validator fleet across devnet, testnet and mainnet: health, peers, per-chain block heights, version, plugin set and DEX.",
}
export default function FleetPage() {
const brand = getBrand()
return (
<div className="flex min-h-screen flex-col">
<Navbar />
<section className="relative border-b bg-gradient-to-b from-background to-muted/20">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#4f4f4f2e_1px,transparent_1px),linear-gradient(to_bottom,#4f4f4f2e_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]" />
<div className="container relative py-12 md:py-16">
<div className="max-w-3xl">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
{brand.name}{" "}
<span
className="bg-clip-text text-transparent"
style={{ backgroundImage: `linear-gradient(to right, ${brand.colors.accent}, ${brand.colors.accentEnd})` }}
>
Fleet
</span>
</h1>
<p className="mt-3 text-muted-foreground">
Real-time view of the managed luxd node fleet. Health, peer counts,
per-chain block heights, software version and the native DEX — read
live from each node, across every network.
</p>
</div>
</div>
</section>
<main className="container flex-1 py-10">
<FleetDashboard />
</main>
<Footer />
</div>
)
}
+3
View File
@@ -6,6 +6,7 @@ import { Navbar } from "@/components/navbar"
import { Footer } from "@/components/footer"
import { ChainGrid } from "@/components/chain-grid"
import { CodeDemo } from "@/components/code-demo"
import { FleetStrip } from "@/components/lux/fleet-strip"
import { getBrand } from "@/lib/brand"
import {
ArrowRight,
@@ -62,6 +63,8 @@ export default function HomePage() {
<Link href="/docs">View Documentation</Link>
</Button>
</div>
{/* Live fleet status — surfaces the managed node fleet on the landing */}
<FleetStrip />
</div>
</div>
</section>
+444
View File
@@ -0,0 +1,444 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Activity,
AlertTriangle,
ChevronDown,
ChevronRight,
Circle,
Cpu,
Heart,
Loader2,
Network as NetworkIcon,
Package,
RefreshCcw,
Server,
Users,
} from "lucide-react"
import { FleetMap, type FleetRegion } from "@/components/lux/fleet-map"
import {
CHAIN_LABEL,
NETWORK_LABEL,
TRACKED_CHAINS,
bestHeight,
chainBootstrapped,
fetchFleetOverview,
fleetStatus,
nodeHealth,
type FleetNetworkName,
type LuxFleetOverview,
type LuxNetworkFleet,
type LuxNode,
} from "@/lib/fleet"
const REFRESH_MS = 15_000
const NETWORK_ACCENT: Record<FleetNetworkName, 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",
}
const STATUS_BADGE: Record<string, { cls: string; label: string }> = {
operational: { cls: "border-green-500/40 text-green-400 bg-green-500/10", label: "Operational" },
degraded: { cls: "border-yellow-500/40 text-yellow-400 bg-yellow-500/10", label: "Degraded" },
down: { cls: "border-red-500/40 text-red-400 bg-red-500/10", label: "Down" },
}
function StatNumber({
icon: Icon,
label,
value,
suffix,
accent,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value: number | string | null | undefined
suffix?: string
accent?: string
}) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Icon className={`h-5 w-5 ${accent ?? "text-muted-foreground"}`} />
<span className={`text-2xl font-bold ${accent ?? ""}`}>
{value ?? "—"}
{suffix && value != null ? <span className="text-base text-muted-foreground">{suffix}</span> : null}
</span>
</div>
</CardContent>
</Card>
)
}
function healthDot(h: ReturnType<typeof nodeHealth>) {
const cls =
h === "healthy"
? "text-green-500"
: h === "syncing"
? "text-yellow-500"
: h === "down"
? "text-red-500"
: "text-zinc-500"
return <Circle className={`h-2.5 w-2.5 fill-current ${cls}`} />
}
function boolBadge(v: boolean | null, trueLabel = "Yes", falseLabel = "No") {
if (v == null) return <span className="text-xs text-muted-foreground">—</span>
return (
<Badge variant={v ? "success" : "warning"} className="text-[10px]">
{v ? trueLabel : falseLabel}
</Badge>
)
}
function NodeTable({ network, nodes }: { network: FleetNetworkName; nodes: LuxNode[] }) {
if (nodes.length === 0) {
return (
<p className="p-6 text-center text-sm text-muted-foreground">
No luxd pods reported for {NETWORK_LABEL[network]}.
</p>
)
}
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Node</TableHead>
<TableHead>Node ID</TableHead>
<TableHead>Version</TableHead>
<TableHead className="text-right">Peers</TableHead>
<TableHead className="text-right">C-Chain</TableHead>
<TableHead className="text-center">C / P / X / D</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{nodes.map((n) => {
const c = n.chains.find((x) => x.chain === "C")
return (
<TableRow key={n.pod_name}>
<TableCell>
<div className="flex items-center gap-2">
{healthDot(nodeHealth(n))}
<span className="font-mono text-xs">{n.pod_name}</span>
{!n.rpc_reachable && n.ready ? (
<span className="text-[10px] text-muted-foreground">(internal RPC)</span>
) : null}
</div>
</TableCell>
<TableCell>
<span className="font-mono text-[11px] text-muted-foreground">
{n.node_id ? `${n.node_id.slice(0, 18)}…` : "—"}
</span>
</TableCell>
<TableCell>
<span className="font-mono text-xs">{n.version ?? "—"}</span>
</TableCell>
<TableCell className="text-right font-mono text-sm">{n.peers ?? "—"}</TableCell>
<TableCell className="text-right font-mono text-sm">
{c?.height != null ? c.height.toLocaleString() : "—"}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
{TRACKED_CHAINS.map((ch) => {
const cc = n.chains.find((x) => x.chain === ch)
const cls =
cc?.bootstrapped === true
? "bg-green-500"
: cc?.bootstrapped === false
? "bg-yellow-500"
: "bg-zinc-700"
return (
<span
key={ch}
title={`${CHAIN_LABEL[ch]}: ${
cc?.bootstrapped === true
? "bootstrapped"
: cc?.bootstrapped === false
? "syncing"
: "unknown"
}`}
className={`inline-block h-2.5 w-2.5 rounded-sm ${cls}`}
/>
)
})}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
function NetworkCard({ fleet }: { fleet: LuxNetworkFleet }) {
const [open, setOpen] = useState(false)
const status = fleetStatus(fleet)
const sb = STATUS_BADGE[status]
const cHeight = bestHeight(fleet, "C")
const dexBoot = fleet.dex.d_chain_bootstrapped ?? chainBootstrapped(fleet, "D")
return (
<Card className="border-zinc-800">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setOpen((v) => !v)}
aria-label={open ? "Collapse nodes" : "Expand nodes"}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
<div>
<CardTitle className="flex items-center gap-2">
{NETWORK_LABEL[fleet.network]}
<Badge variant="outline" className={NETWORK_ACCENT[fleet.network]}>
network {fleet.network_id ?? "—"}
</Badge>
</CardTitle>
<CardDescription className="mt-1 font-mono text-xs">
{fleet.version ?? fleet.image ?? "version unknown"}
</CardDescription>
</div>
</div>
<div className="flex items-center gap-4">
<Badge variant="outline" className={sb.cls}>
{status === "operational" ? (
<Circle className="mr-1 h-2 w-2 fill-green-500" />
) : status === "degraded" ? (
<AlertTriangle className="mr-1 h-3 w-3" />
) : (
<Circle className="mr-1 h-2 w-2 fill-red-500" />
)}
{sb.label}
</Badge>
<div className="text-right text-sm">
<span className="font-bold">{fleet.nodes_ready}</span>
<span className="text-muted-foreground">/{fleet.validators || fleet.nodes_total} validators</span>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Quick facts row */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Fact icon={Server} label="Nodes ready" value={`${fleet.nodes_ready}/${fleet.nodes_total}`} />
<Fact icon={Users} label="Peers" value={fleet.total_peers} />
<Fact icon={Activity} label="C-Chain height" value={cHeight != null ? cHeight.toLocaleString() : "—"} />
<Fact
icon={Cpu}
label="DEX (D-Chain)"
value={dexBoot === true ? "Live" : dexBoot === false ? "Syncing" : "—"}
valueClass={dexBoot === true ? "text-green-400" : dexBoot === false ? "text-yellow-400" : ""}
/>
</div>
{/* Plugin set + chains bootstrapped */}
<div className="flex flex-wrap items-center gap-2 border-t border-zinc-800 pt-3">
{fleet.plugin_set ? (
<Badge variant="secondary" className="font-mono text-[10px]">
<Package className="mr-1 h-3 w-3" />
{fleet.plugin_set.replace(/^s3:\/\//, "")}
</Badge>
) : null}
{TRACKED_CHAINS.map((ch) => {
const b = chainBootstrapped(fleet, ch)
return (
<span key={ch} className="flex items-center gap-1 text-xs">
<span
className={`inline-block h-2 w-2 rounded-full ${
b === true ? "bg-green-500" : b === false ? "bg-yellow-500" : "bg-zinc-700"
}`}
/>
<span className="text-muted-foreground">{CHAIN_LABEL[ch]}</span>
</span>
)
})}
{fleet.dex.markets != null ? (
<Badge variant="outline" className="text-[10px]">
{fleet.dex.markets} markets
</Badge>
) : null}
</div>
{fleet.error ? (
<div className="rounded-md bg-yellow-500/10 p-3 text-xs text-yellow-500">{fleet.error}</div>
) : null}
{open ? (
<div className="border-t border-zinc-800 pt-3">
<NodeTable network={fleet.network} nodes={fleet.nodes} />
</div>
) : null}
</CardContent>
</Card>
)
}
function Fact({
icon: Icon,
label,
value,
valueClass,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value: React.ReactNode
valueClass?: string
}) {
return (
<div className="rounded-lg border border-zinc-800 p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{label}
</div>
<div className={`mt-1 font-mono text-lg font-semibold ${valueClass ?? ""}`}>{value}</div>
</div>
)
}
/** Build map regions from the fleet (all luxd nodes shown at the sfo3 region). */
function toRegions(overview: LuxFleetOverview | null): FleetRegion[] {
if (!overview) return []
const total = overview.networks.reduce((s, f) => s + f.nodes_total, 0)
const healthy = overview.networks.reduce((s, f) => s + f.nodes_ready, 0)
if (total === 0) return []
// The managed fleet runs in DigitalOcean SFO3 (San Francisco).
return [
{
slug: "sfo3",
name: "SFO3 · San Francisco",
lat: 37.77,
lon: -122.42,
active: healthy > 0,
nodeCount: total,
healthyNodes: healthy,
networks: overview.networks.map((f) => f.network),
},
]
}
export function FleetDashboard({ compact = false }: { compact?: boolean }) {
const [overview, setOverview] = useState<LuxFleetOverview | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [updatedAt, setUpdatedAt] = useState<Date | null>(null)
const load = useCallback(async (silent = false) => {
if (!silent) setRefreshing(true)
const ctrl = new AbortController()
try {
const data = await fetchFleetOverview(ctrl.signal)
setOverview(data)
setError(null)
setUpdatedAt(new Date())
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
load()
const t = setInterval(() => load(true), REFRESH_MS)
return () => clearInterval(t)
}, [load])
const totals = overview?.totals ?? {}
const regions = useMemo(() => toRegions(overview), [overview])
if (loading && !overview) {
return (
<div className="flex items-center justify-center p-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-2xl font-bold tracking-tight">Node Fleet</h2>
<p className="text-sm text-muted-foreground">
Live status of the managed luxd fleet across devnet, testnet and mainnet
{updatedAt ? ` · updated ${updatedAt.toLocaleTimeString()}` : ""}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => load()} disabled={refreshing}>
<RefreshCcw className={`mr-2 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
{error && !overview ? (
<Card>
<CardContent className="p-6">
<div className="rounded-md bg-destructive/10 p-4 text-sm text-destructive">{error}</div>
</CardContent>
</Card>
) : null}
{/* Totals */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatNumber icon={NetworkIcon} label="Networks" value={totals.networks ?? overview?.networks.length} />
<StatNumber icon={Server} label="Validators" value={totals.validators} />
<StatNumber
icon={Heart}
label="Nodes ready"
value={totals.nodes_ready}
suffix={totals.nodes_total ? ` / ${totals.nodes_total}` : undefined}
accent="text-green-500"
/>
<StatNumber icon={Users} label="Peer connections" value={totals.total_peers} />
</div>
{/* World map (only when not compact) */}
{!compact && regions.length > 0 ? (
<Card className="border-zinc-800 overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Fleet topology</CardTitle>
</CardHeader>
<CardContent className="p-0">
<FleetMap regions={regions} />
</CardContent>
</Card>
) : null}
{/* Per-network cards */}
<div className="space-y-4">
{overview?.networks.map((f) => (
<NetworkCard key={f.network} fleet={f} />
))}
</div>
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowRight, Circle } from "lucide-react"
import {
NETWORK_LABEL,
fetchFleetOverview,
fleetStatus,
type LuxFleetOverview,
} from "@/lib/fleet"
const DOT: Record<string, string> = {
operational: "text-green-500",
degraded: "text-yellow-500",
down: "text-red-500",
}
/** Compact live fleet status strip for the landing hero. Public, read-only. */
export function FleetStrip() {
const [ov, setOv] = useState<LuxFleetOverview | null>(null)
useEffect(() => {
let alive = true
const ctrl = new AbortController()
const load = () =>
fetchFleetOverview(ctrl.signal)
.then((d) => alive && setOv(d))
.catch(() => {})
load()
const t = setInterval(load, 20_000)
return () => {
alive = false
ctrl.abort()
clearInterval(t)
}
}, [])
if (!ov) return null
const totals = ov.totals ?? {}
return (
<Link
href="/fleet"
className="group mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 rounded-xl border border-zinc-800 bg-background/60 px-5 py-3 backdrop-blur transition-colors hover:border-zinc-700"
>
<span className="flex items-center gap-2 text-sm font-medium">
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-60" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-green-500" />
</span>
Live fleet
</span>
{ov.networks.map((f) => {
const s = fleetStatus(f)
return (
<span key={f.network} className="flex items-center gap-1.5 text-sm">
<Circle className={`h-2 w-2 fill-current ${DOT[s]}`} />
<span className="text-muted-foreground">{NETWORK_LABEL[f.network]}</span>
<span className="font-mono">
{f.nodes_ready}/{f.validators || f.nodes_total}
</span>
</span>
)
})}
<span className="hidden items-center gap-1.5 text-sm text-muted-foreground sm:flex">
<span className="font-mono text-foreground">{totals.total_peers ?? 0}</span> peers
</span>
<span className="flex items-center gap-1 text-sm text-primary opacity-80 group-hover:opacity-100">
View fleet
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
</span>
</Link>
)
}
+10
View File
@@ -375,6 +375,13 @@ export function Navbar() {
{(closeMenu) => <SolutionsContent closeMenu={closeMenu} />}
</NavMenu>
<Link
href="/fleet"
className="text-muted-foreground hover:text-foreground transition-colors text-sm font-medium"
>
Fleet
</Link>
<Link
href="/docs"
className="text-muted-foreground hover:text-foreground transition-colors text-sm font-medium"
@@ -439,6 +446,9 @@ export function Navbar() {
Webhooks
</Link>
<div className="border-t pt-4 mt-2" />
<Link href="/fleet" className="text-sm font-medium" onClick={() => setIsOpen(false)}>
Fleet
</Link>
<Link href="/docs" className="text-sm font-medium" onClick={() => setIsOpen(false)}>
Docs
</Link>
+1
View File
@@ -91,6 +91,7 @@ export function UnifiedNavigation() {
icon: Server,
description: "Manage nodes, networks, services, and infrastructure",
children: [
{ name: "Lux Fleet", href: "/dashboard/infrastructure/lux", icon: Shield },
{ name: "Nodes", href: "/dashboard/infrastructure/nodes", icon: Server },
{ name: "Networks", href: "/dashboard/infrastructure/networks", icon: Network },
{ name: "Services", href: "/dashboard/infrastructure/services", icon: Boxes },
+152
View File
@@ -0,0 +1,152 @@
// Read-only Lux fleet overview client.
//
// Fetches the public fleet overview (GET /v1/fleets/lux/overview) served by the
// read-only fleet API. The endpoint exposes only public chain data — no auth,
// no secrets — so the lux.cloud landing + fleet dashboard can render it freely.
import { getApiUrl } from "@/lib/api"
import { getBrand } from "@/lib/brand"
/**
* Resolve the API base for the active brand.
*
* The web image is brand-agnostic, so the build-time NEXT_PUBLIC_API_URL bakes
* the wrong host for the browser. The brand config (resolved at runtime by
* hostname) is the canonical per-brand API URL — e.g. lux.cloud → api.cloud.lux.network.
* Fall back to getApiUrl() (build-time env) when no brand URL is present.
*/
function apiBase(): string {
const brandUrl = getBrand().apiUrl
if (brandUrl) return brandUrl.replace(/\/$/, "")
return getApiUrl()
}
export interface ChainHeight {
chain: string
bootstrapped: boolean | null
height: number | null
}
export interface LuxNode {
pod_name: string
pod_index: number
phase: string
ready: boolean
external_ip: string | null
rpc_reachable: boolean
node_id: string | null
version: string | null
git_commit: string | null
db_version: string | null
peers: number | null
healthy: boolean | null
chains: ChainHeight[]
}
export interface DexSummary {
d_chain_bootstrapped: boolean | null
venue: string
markets: number | null
note: string | null
}
export type FleetNetworkName = "mainnet" | "testnet" | "devnet"
export interface LuxNetworkFleet {
network: FleetNetworkName
namespace: string
network_id: number | null
chain_id: number | null
phase: string
image: string | null
version: string | null
plugin_set: string | null
vm_plugins: string[]
validators: number
nodes_total: number
nodes_ready: number
nodes_rpc_ok: number
total_peers: number
dex: DexSummary
nodes: LuxNode[]
error: string | null
}
export interface LuxFleetOverview {
cluster: string
networks: LuxNetworkFleet[]
totals: Record<string, number>
generated_at: number
}
/** Fetch the read-only fleet overview. Unauthenticated. */
export async function fetchFleetOverview(signal?: AbortSignal): Promise<LuxFleetOverview> {
const res = await fetch(`${apiBase()}/v1/fleets/lux/overview`, {
signal,
headers: { Accept: "application/json" },
cache: "no-store",
})
if (!res.ok) throw new Error(`fleet overview: HTTP ${res.status}`)
return res.json()
}
// ---------------------------------------------------------------------------
// Derived display helpers
// ---------------------------------------------------------------------------
export type NodeHealth = "healthy" | "syncing" | "down" | "unknown"
/** Classify a node for the status dot. */
export function nodeHealth(n: LuxNode): NodeHealth {
if (n.rpc_reachable) {
const c = n.chains.find((x) => x.chain === "C")
if (c?.bootstrapped === false) return "syncing"
return "healthy"
}
if (n.phase === "Running" && n.ready) return "unknown" // up but RPC not exposed
if (n.phase === "Pending" || n.phase === "ContainerCreating") return "syncing"
return "down"
}
/** Network-level rollup status from its nodes. */
export function fleetStatus(f: LuxNetworkFleet): "operational" | "degraded" | "down" {
if (f.nodes_total === 0) return "down"
if (f.nodes_ready === 0) return "down"
if (f.nodes_ready < f.validators || f.nodes_ready < f.nodes_total) return "degraded"
return "operational"
}
/** Best per-chain height across the network's RPC-reachable nodes. */
export function bestHeight(f: LuxNetworkFleet, chain: string): number | null {
let best: number | null = null
for (const n of f.nodes) {
const c = n.chains.find((x) => x.chain === chain)
if (c?.height != null) best = best == null ? c.height : Math.max(best, c.height)
}
return best
}
/** Whether any node reports the chain bootstrapped. */
export function chainBootstrapped(f: LuxNetworkFleet, chain: string): boolean | null {
let seen = false
for (const n of f.nodes) {
const c = n.chains.find((x) => x.chain === chain)
if (c?.bootstrapped === true) return true
if (c?.bootstrapped != null) seen = true
}
return seen ? false : null
}
export const NETWORK_LABEL: Record<FleetNetworkName, string> = {
mainnet: "Mainnet",
testnet: "Testnet",
devnet: "Devnet",
}
export const TRACKED_CHAINS = ["C", "P", "X", "D"] as const
export const CHAIN_LABEL: Record<string, string> = {
C: "C-Chain",
P: "P-Chain",
X: "X-Chain",
D: "D-Chain (DEX)",
}
+7
View File
@@ -62,5 +62,12 @@
"postcss": "^8.5.23",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"@tailwindcss/oxide",
"esbuild",
"sharp"
]
}
}