mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
merge bootnode/bootnode into this history — one repo, both halves
github.com/bootnode/bootnode is the canonical home. These two diverged from766da81cwith 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` fromb06f09f(canonical re-adds "docs": "/docs", a URL production disables, so it 404s). This dropsa386c9b, 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:
@@ -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
|
||||
@@ -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,3 +1,3 @@
|
||||
"""Bootnode - Blockchain Development Platform."""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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}
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)}
|
||||
@@ -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__,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
*.tsbuildinfo
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)",
|
||||
}
|
||||
@@ -62,5 +62,12 @@
|
||||
"postcss": "^8.5.23",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
"esbuild",
|
||||
"sharp"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user