feat: add Explorer + MPC product pages, fleet management, operator, Helm charts, and CI/CD

- Add Explorer product page with branded block explorer deployment, contract
  verification, Etherscan-compatible API, and multi-chain support
- Add MPC product page with deep Lux MPC integration: CGGMP21 (ECDSA),
  FROST (EdDSA), LSS resharing, 15+ chain support, threshold signing API
- Update landing page with 6-product grid (Node, Data, Wallets, Explorer,
  MPC, Rollups) and 8 API cards including Explorer API and MPC Signing
- Update navbar and command menu with Explorer and MPC products
- Add Lux fleet management dashboard, observability, and services pages
- Add AI chat component and unified navigation with org switcher
- Add Kubernetes operator (Rust) for multi-chain node lifecycle management
- Add Lux Helm charts with genesis configs for devnet/testnet/mainnet
- Add docker-release.yml CI/CD pipeline for automated DOKS deployment
- Add monitoring, network policies, web deployments, and ingress updates
- Refactor API: fleet management, billing tracker, observability endpoints
- Update brand config, API client, and infrastructure manifests
This commit is contained in:
2026-03-12 14:48:40 -07:00
parent 167f08bca7
commit 0aa9c4e346
83 changed files with 27043 additions and 1828 deletions
+1 -7
View File
@@ -6,11 +6,6 @@ on:
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: hanzoai/bootnode
CANONICAL_IMAGE: bootnode/bootnode
jobs:
test-api:
runs-on: ubuntu-latest
@@ -267,8 +262,7 @@ jobs:
run: |
kubectl apply -f infra/k8s/namespace.yaml
kubectl apply -f infra/k8s/api-deployment.yaml
kubectl apply -f infra/k8s/hanzo-web3-deployment.yaml
kubectl apply -f infra/k8s/bootnode-web-deployment.yaml
kubectl apply -f infra/k8s/web-deployments.yaml
kubectl apply -f infra/k8s/ingress.yaml
# Restart API
+120
View File
@@ -0,0 +1,120 @@
name: Docker Release
on:
# Only deploy after CI passes on main
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
# Tags deploy 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
jobs:
# Gate: only proceed if CI passed (skip check for tags/manual)
gate:
runs-on: ubuntu-latest
if: >-
github.event_name != 'workflow_run' ||
github.event.workflow_run.conclusion == 'success'
steps:
- run: echo "CI passed — proceeding with Docker build and deploy"
# Build API image via standard Hanzo reusable workflow
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"
deploy: false # Custom deploy below (bootnode-k8s, not hanzo-k8s)
secrets: inherit
# Build Web image via standard Hanzo reusable workflow
web:
needs: gate
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
with:
image: ghcr.io/hanzoai/bootnode-web
context: ./web
dockerfile: Dockerfile
platforms: "linux/amd64"
deploy: false # Custom deploy below
secrets: inherit
# Deploy to bootnode-k8s + lux-k8s (separate DOKS clusters, need doctl)
deploy:
runs-on: ubuntu-latest
needs: [api, web]
if: >-
github.event_name != 'workflow_run' ||
github.event.workflow_run.head_branch == 'main'
environment: production
steps:
- uses: actions/checkout@v4
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
# --- bootnode-k8s cluster ---
- name: Save kubeconfig (bootnode-k8s)
run: doctl kubernetes cluster kubeconfig save ${{ secrets.K8S_CLUSTER_NAME }}
- name: Apply manifests (bootnode-k8s)
run: |
kubectl apply -f infra/k8s/namespace.yaml
kubectl apply -f infra/k8s/external-secrets.yaml
kubectl apply -f infra/k8s/secrets.yaml
kubectl apply -f infra/k8s/api-deployment.yaml
kubectl apply -f infra/k8s/web-deployments.yaml
kubectl apply -f infra/k8s/ingress.yaml
- name: Rollout API (bootnode-k8s)
run: |
kubectl rollout restart deployment/bootnode-api -n bootnode
kubectl rollout status deployment/bootnode-api -n bootnode --timeout=300s
- name: Rollout web portals (bootnode-k8s)
run: |
for brand in hanzo lux zoo pars; do
kubectl rollout restart deployment/bootnode-web-${brand} -n bootnode
kubectl rollout status deployment/bootnode-web-${brand} -n bootnode --timeout=300s
done
- name: Cleanup legacy deployments (bootnode-k8s)
run: |
# Remove old web3-platform-* resources after new bootnode-* are running
for old in $(kubectl get deployments -n bootnode -o name | grep 'web3-platform'); do
echo "Deleting legacy $old"
kubectl delete $old -n bootnode --ignore-not-found
done
for old in $(kubectl get svc -n bootnode -o name | grep 'web3-platform'); do
echo "Deleting legacy $old"
kubectl delete $old -n bootnode --ignore-not-found
done
for old in $(kubectl get ingress -n bootnode -o name | grep 'web3-platform'); do
echo "Deleting legacy $old"
kubectl delete $old -n bootnode --ignore-not-found
done
# --- lux-k8s cluster ---
- name: Save kubeconfig (lux-k8s)
run: doctl kubernetes cluster kubeconfig save ${{ secrets.LUX_K8S_CLUSTER_NAME }}
- name: Deploy operator to lux-k8s
run: |
kubectl apply -f infra/k8s/lux/crds.yaml
kubectl apply -f infra/k8s/lux/rbac.yaml
kubectl apply -f infra/k8s/lux/operator-deployment.yaml
kubectl rollout status deployment/bootnode-operator -n bootnode-system --timeout=120s
+3 -3
View File
@@ -1234,9 +1234,9 @@ Never set `issuer` on a `type: "oauth"` NextAuth provider pointing at Casdoor. T
| `/analytics/{path}` | analytics.hanzo.svc:80 | Analytics |
| `/bot/{path}` | bot-gateway.hanzo.svc:80 | Bot/Operative |
| `/operative/{path}` | operative.hanzo.svc:80 | Operative |
| `/kms/{path}` | kms.hanzo.svc:8080 | Key Management |
| `/agents/{path}` | agents.hanzo.svc:8000 | Agent Framework |
| `/console/{path}` | console.hanzo.svc:3000 | Console |
| `/kms/{path}` | kms.hanzo.svc:80 | Key Management |
| `/agents/{path}` | agents.hanzo.svc:8080 | Agent Framework |
| `/console/{path}` | console.hanzo.svc:80 | Console |
| `/web3/{path}` | bootnode-api.bootnode.svc:80 | Web3/Blockchain |
### Console Billing — Provider-Agnostic (2026-02-12)
+4 -1
View File
@@ -3,13 +3,16 @@ FROM python:3.12-slim
WORKDIR /app
# Install system dependencies + kubectl for fleet management
# Install system dependencies + kubectl + helm for fleet management
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
curl \
&& curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \
&& install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl \
&& rm kubectl \
&& curl -fsSL https://get.helm.sh/helm-v3.17.2-linux-amd64.tar.gz | tar xz \
&& install -o root -g root -m 0755 linux-amd64/helm /usr/local/bin/helm \
&& rm -rf linux-amd64 \
&& rm -rf /var/lib/apt/lists/*
# Install uv for faster dependency installation
+6 -2
View File
@@ -6,6 +6,7 @@ from bootnode.api.auth import router as auth_router
from bootnode.api.billing import router as billing_router
from bootnode.api.bundler import router as bundler_router
from bootnode.api.chains import router as chains_router
from bootnode.api.fleets import router as fleets_router
from bootnode.api.gas import router as gas_router
from bootnode.api.infra import router as infra_router
from bootnode.api.nfts import router as nfts_router
@@ -16,8 +17,9 @@ from bootnode.api.transfers import router as transfers_router
from bootnode.api.wallets import router as wallets_router
from bootnode.api.webhooks import router as webhooks_router
from bootnode.api.team import router as team_router
from bootnode.api.lux import router as lux_router
from bootnode.api.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
router = APIRouter()
@@ -26,6 +28,7 @@ router = APIRouter()
router.include_router(auth_router, prefix="/auth", tags=["Authentication"])
router.include_router(billing_router, prefix="/billing", tags=["Billing"])
router.include_router(chains_router, prefix="/chains", tags=["Chains"])
router.include_router(fleets_router, prefix="/fleets", tags=["Fleets"])
router.include_router(nodes_router, prefix="/nodes", tags=["Nodes"])
router.include_router(rpc_router, prefix="/rpc", tags=["RPC"])
router.include_router(tokens_router, prefix="/tokens", tags=["Tokens"])
@@ -38,5 +41,6 @@ router.include_router(gas_router, prefix="/gas", tags=["Gas Manager"])
router.include_router(zap_router, prefix="/zap", tags=["ZAP Protocol"])
router.include_router(infra_router, prefix="/infra", tags=["Infrastructure"])
router.include_router(team_router, prefix="/team", tags=["Team"])
router.include_router(lux_router, prefix="/lux", tags=["Lux Fleet"])
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"])
+205
View File
@@ -0,0 +1,205 @@
"""AI Chat API — streaming chat with infrastructure control via ZAP/MCP tools.
Provides /v1/chat/* endpoints for the embedded AI assistant in the dashboard.
Proxies to Hanzo LLM gateway with bootnode-specific system prompt and tool definitions.
"""
from __future__ import annotations
import os
from typing import Any
import httpx
import structlog
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from bootnode.api.deps import ProjectDep
from bootnode.config import get_settings
from bootnode.core.cache import redis_client
router = APIRouter()
settings = get_settings()
logger = structlog.get_logger()
# =============================================================================
# Models
# =============================================================================
class ChatMessage(BaseModel):
role: str # "user" | "assistant" | "system"
content: str
@property
def safe_content(self) -> str:
"""Truncate content to prevent abuse."""
return self.content[:8192]
class ChatRequest(BaseModel):
messages: list[ChatMessage]
stream: bool = True
model: str | None = None
def validated_messages(self) -> list[dict[str, str]]:
"""Return messages with validation applied."""
# Cap conversation length to prevent context abuse
recent = self.messages[-20:] # max 20 turns
return [{"role": m.role, "content": m.safe_content} for m in recent]
# =============================================================================
# System prompt for infrastructure-aware AI
# =============================================================================
SYSTEM_PROMPT = """\
You are the Bootnode AI assistant — an expert in blockchain infrastructure management.
You help users manage their blockchain nodes, services, fleets, and observability.
You have access to the following infrastructure tools:
**RPC & Blockchain**
- rpc_call: Execute JSON-RPC on any supported chain
- get_token_balances: Get ERC-20 balances for an address
- get_nfts_owned: Get NFTs owned by an address
- create_smart_wallet: Create ERC-4337 smart wallet
- estimate_gas: Get gas prices for a chain
**Fleet Management**
- fleet_list: List validator fleets across clusters
- fleet_get: Get fleet status including nodes and endpoints
- fleet_scale: Scale a fleet to target replica count
- fleet_restart: Rolling restart fleet pods
**Service Management**
- service_list: List all deployed infrastructure services
- service_deploy: Deploy or update a service (explorer, bridge, safe, etc.)
- service_scale: Scale a service
- service_logs: Get logs from a service
**Observability**
- o11y_health: Aggregate health across all services
- o11y_metrics: Platform metrics (rps, latency, error rate)
- o11y_logs: Search aggregated logs via Loki
- o11y_alerts: Active alerts and incidents
When users ask about their infrastructure, use the appropriate tools.
When users ask to perform actions (scale, restart, deploy), confirm before executing.
Be concise and direct. Use code blocks for RPC responses and log output.
Reference specific services, chains, and networks by name.
"""
# =============================================================================
# Endpoints
# =============================================================================
CHAT_RATE_LIMIT = 20 # requests per minute per project
@router.post("/completions")
async def chat_completions(
request: ChatRequest,
project: ProjectDep,
) -> Any:
"""AI chat completions with infrastructure context.
Proxies to Hanzo AI gateway (api.hanzo.ai) with bootnode system prompt prepended.
Supports streaming (SSE) and non-streaming responses.
Rate limited per project: 20 req/min.
"""
# Per-project rate limiting for chat (expensive LLM calls)
rate_key = f"chat_rate:{project.id}"
allowed, remaining = await redis_client.rate_limit_check(
rate_key, CHAT_RATE_LIMIT
)
if not allowed:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Chat rate limit exceeded (20 req/min)",
headers={"X-RateLimit-Remaining": "0", "Retry-After": "60"},
)
llm_url = os.getenv("LLM_GATEWAY_URL", "https://api.hanzo.ai")
llm_key = os.getenv("HANZO_AI_API_KEY", "")
if not llm_key:
logger.error("HANZO_AI_API_KEY not configured — chat disabled")
return {"error": "AI chat not configured", "status": 503}
model = request.model or os.getenv("BOOTNODE_CHAT_MODEL", "zen-70b")
# Prepend system prompt with validated/truncated messages
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
*request.validated_messages(),
]
payload = {
"model": model,
"messages": messages,
"stream": request.stream,
"max_tokens": 4096,
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {llm_key}",
}
if request.stream:
return StreamingResponse(
_stream_response(llm_url, payload, headers),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# Non-streaming
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
f"{llm_url}/v1/chat/completions",
json=payload,
headers=headers,
)
if resp.status_code != 200:
logger.error("LLM upstream error", status=resp.status_code)
return {"error": "AI service unavailable", "status": resp.status_code}
return resp.json()
async def _stream_response(
llm_url: str,
payload: dict,
headers: dict,
) -> Any:
"""Stream SSE chunks from LLM gateway."""
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream(
"POST",
f"{llm_url}/v1/chat/completions",
json=payload,
headers=headers,
) as resp:
async for chunk in resp.aiter_bytes():
yield chunk
@router.get("/models")
async def list_chat_models(
project: ProjectDep,
) -> dict[str, Any]:
"""List available chat models."""
return {
"models": [
{"id": "zen-70b", "name": "Zen 70B", "description": "Fast, capable general model", "default": True},
{"id": "zen-405b", "name": "Zen 405B", "description": "Most capable model for complex tasks"},
{"id": "zen-8b", "name": "Zen 8B", "description": "Lightweight model for simple queries"},
],
"default": "zen-70b",
}
+472
View File
@@ -0,0 +1,472 @@
"""Fleet API -- chain-agnostic REST endpoints for managing blockchain node fleets.
Unified surface: /v1/fleets
- Manages any chain (lux, hanzo, zoo, pars, avalanche, ethereum, bitcoin, solana, filecoin)
- Multi-tenant: fleets scoped to IAM org via project
- Cross-cluster management via DO API kubeconfig
- Full CRUD + scale/restart/logs/health/stats
"""
import logging
import os
import re
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bootnode.api.deps import ApiKeyDep, ProjectDep, DbDep
from bootnode.core.chains.fleet_manager import FleetManager
from bootnode.core.chains.fleet_models import (
CHAIN_NETWORKS,
FleetCreate,
FleetResponse,
FleetStats,
FleetSummary,
FleetUpdate,
)
from bootnode.db.models import OrgCluster, Project
from bootnode.db.session import get_db
logger = logging.getLogger(__name__)
router = APIRouter()
_manager = FleetManager()
_K8S_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$")
_INCLUSTER_ID = "incluster"
# ---- Helpers ----
def _validate_k8s_name(name: str, field: str = "name") -> None:
if not _K8S_NAME_RE.match(name):
raise HTTPException(400, f"Invalid {field}: must match RFC 1123 DNS label")
def _validate_chain(chain: str) -> None:
if chain not in CHAIN_NETWORKS:
raise HTTPException(400, f"Unknown chain: {chain}. Supported: {list(CHAIN_NETWORKS.keys())}")
def _validate_network(chain: str, network: str) -> None:
_validate_chain(chain)
nets = CHAIN_NETWORKS[chain]
if network not in nets:
raise HTTPException(400, f"Unknown network {network} for chain {chain}. Available: {list(nets.keys())}")
def _is_incluster() -> bool:
return os.path.exists("/var/run/secrets/kubernetes.io/serviceaccount/token")
async def _get_kubeconfig(cluster_id: str) -> Optional[str]:
if cluster_id == _INCLUSTER_ID:
return None
try:
from bootnode.api.infra import do_get_kubeconfig, get_do_client
client = get_do_client()
if not client:
if _is_incluster():
return None
raise HTTPException(500, "DigitalOcean client not configured")
return await do_get_kubeconfig(client, cluster_id)
except ImportError:
if _is_incluster():
return None
raise HTTPException(500, "Infrastructure module not available")
except HTTPException:
raise
except Exception:
logger.error("Failed to fetch kubeconfig for cluster %s", cluster_id)
if _is_incluster():
return None
raise HTTPException(502, "Failed to fetch kubeconfig for cluster")
async def _get_org_clusters(project: Project, db: AsyncSession) -> list[OrgCluster]:
"""Get clusters accessible to this project's org."""
result = await db.execute(
select(OrgCluster).where(
OrgCluster.project_id == project.id,
OrgCluster.is_active,
)
)
return list(result.scalars().all())
async def _check_cluster_access(
project: Project, cluster_id: str, db: AsyncSession,
) -> None:
"""Verify the project has access to the given cluster.
Admin projects (role in settings) bypass this check.
Projects with no OrgCluster entries also bypass (open access for bootstrapping).
"""
if project.settings and project.settings.get("admin"):
return
clusters = await _get_org_clusters(project, db)
if not clusters:
return # No cluster restrictions configured
allowed_ids = {c.cluster_id for c in clusters}
if cluster_id not in allowed_ids:
raise HTTPException(
403, f"Project does not have access to cluster {cluster_id}",
)
def _check_chain_access(project: Project, chain: str) -> None:
"""Verify the project is allowed to use this chain."""
if project.allowed_chains and chain not in project.allowed_chains:
raise HTTPException(
403, f"Project does not have access to chain {chain}. Allowed: {project.allowed_chains}",
)
# ---- Fleet CRUD ----
@router.post("", response_model=FleetResponse)
async def create_fleet(
params: FleetCreate,
background_tasks: BackgroundTasks,
project: ProjectDep,
db: DbDep,
) -> FleetResponse:
"""Create a new node fleet on a cluster."""
_validate_network(params.chain, params.network)
_check_chain_access(project, params.chain)
await _check_cluster_access(project, params.cluster_id, db)
kubeconfig = await _get_kubeconfig(params.cluster_id)
return await _manager.create(kubeconfig, params)
@router.get("", response_model=list[FleetSummary])
async def list_fleets(
project: ProjectDep,
db: DbDep,
cluster_id: Optional[str] = None,
chain: Optional[str] = None,
) -> list[FleetSummary]:
"""List all node fleets, optionally filtered by cluster and/or chain."""
if chain:
_validate_chain(chain)
# If cluster_id provided, use it directly (with access check)
if cluster_id:
await _check_cluster_access(project, cluster_id, db)
kubeconfig = await _get_kubeconfig(cluster_id)
return await _manager.list_fleets(kubeconfig, cluster_id, chain=chain)
# Otherwise, list across all org clusters
org_clusters = await _get_org_clusters(project, db)
if org_clusters:
all_fleets = []
for oc in org_clusters:
try:
kubeconfig = await _get_kubeconfig(oc.cluster_id)
fleets = await _manager.list_fleets(kubeconfig, oc.cluster_id, chain=chain)
all_fleets.extend(fleets)
except Exception as e:
logger.warning("Failed to list fleets on cluster %s: %s", oc.cluster_id, e)
return all_fleets
# No clusters configured - check in-cluster
if _is_incluster():
return await _manager.list_fleets(None, _INCLUSTER_ID, chain=chain)
return []
@router.get("/stats", response_model=FleetStats)
async def get_stats(
project: ProjectDep,
db: DbDep,
cluster_id: Optional[str] = None,
chain: Optional[str] = None,
) -> FleetStats:
"""Get aggregate fleet statistics across all chains."""
if chain:
_validate_chain(chain)
# Determine which clusters to query
cluster_ids_to_check: list[str] = []
if cluster_id:
await _check_cluster_access(project, cluster_id, db)
cluster_ids_to_check = [cluster_id]
else:
org_clusters = await _get_org_clusters(project, db)
cluster_ids_to_check = [oc.cluster_id for oc in org_clusters]
if not cluster_ids_to_check and _is_incluster():
cluster_ids_to_check = [_INCLUSTER_ID]
if not cluster_ids_to_check:
return FleetStats()
all_fleets: list[FleetSummary] = []
for cid in cluster_ids_to_check:
try:
kubeconfig = await _get_kubeconfig(cid)
fleets = await _manager.list_fleets(kubeconfig, cid, chain=chain)
all_fleets.extend(fleets)
except Exception as e:
logger.warning("Failed to get stats from cluster %s: %s", cid, e)
total_nodes = sum(f.replicas for f in all_fleets)
healthy_nodes = sum(f.ready_replicas for f in all_fleets)
fleets_by_network: dict[str, int] = {}
fleets_by_chain: dict[str, int] = {}
fleets_by_status: dict[str, int] = {}
for f in all_fleets:
fleets_by_network[f.network] = fleets_by_network.get(f.network, 0) + 1
fleets_by_chain[f.chain] = fleets_by_chain.get(f.chain, 0) + 1
fleets_by_status[f.status.value] = fleets_by_status.get(f.status.value, 0) + 1
return FleetStats(
total_fleets=len(all_fleets),
total_nodes=total_nodes,
healthy_nodes=healthy_nodes,
fleets_by_chain=fleets_by_chain,
fleets_by_network=fleets_by_network,
fleets_by_status=fleets_by_status,
)
@router.get("/chains")
async def list_chains() -> dict:
"""List all supported chains and their available networks."""
return {
chain: list(networks.keys())
for chain, networks in CHAIN_NETWORKS.items()
}
@router.get("/networks")
async def list_networks(chain: Optional[str] = None) -> dict:
"""List network configurations, optionally filtered by chain."""
chains_to_show = (
{chain: CHAIN_NETWORKS[chain]} if chain and chain in CHAIN_NETWORKS
else CHAIN_NETWORKS
)
result = {}
for ch, networks in chains_to_show.items():
result[ch] = {
name: {
"network_id": cfg.network_id,
"chain_id": cfg.chain_id,
"http_port": cfg.http_port,
"staking_port": cfg.staking_port,
"namespace": cfg.namespace,
}
for name, cfg in networks.items()
}
return {"chains": result}
# ---- Org cluster management ----
@router.get("/clusters")
async def list_org_clusters(project: ProjectDep, db: DbDep) -> list[dict]:
"""List clusters accessible to this project."""
clusters = await _get_org_clusters(project, db)
return [
{
"id": str(c.id),
"cluster_id": c.cluster_id,
"cluster_name": c.cluster_name,
"provider": c.provider,
"region": c.region,
"allowed_chains": c.allowed_chains,
"allowed_namespaces": c.allowed_namespaces,
"is_active": c.is_active,
}
for c in clusters
]
@router.post("/clusters")
async def add_org_cluster(
cluster_id: str,
cluster_name: str,
project: ProjectDep,
db: DbDep,
provider: str = "digitalocean",
region: str = "sfo3",
allowed_chains: Optional[list[str]] = None,
) -> dict:
"""Add a cluster to this project's accessible clusters."""
oc = OrgCluster(
project_id=project.id,
cluster_id=cluster_id,
cluster_name=cluster_name,
provider=provider,
region=region,
allowed_chains=allowed_chains,
)
db.add(oc)
await db.commit()
return {"id": str(oc.id), "cluster_id": cluster_id, "status": "added"}
@router.delete("/clusters/{cluster_id}")
async def remove_org_cluster(
cluster_id: str,
project: ProjectDep,
db: DbDep,
) -> dict:
"""Remove a cluster from this project's access."""
result = await db.execute(
select(OrgCluster).where(
OrgCluster.project_id == project.id,
OrgCluster.cluster_id == cluster_id,
)
)
oc = result.scalar_one_or_none()
if not oc:
raise HTTPException(404, "Cluster not found in project")
await db.delete(oc)
await db.commit()
return {"cluster_id": cluster_id, "status": "removed"}
# ---- Fleet detail + operations ----
@router.get("/{chain}/{cluster_id}/{network}", response_model=FleetResponse)
async def get_fleet(
chain: str,
cluster_id: str,
network: str,
project: ProjectDep,
db: DbDep,
) -> FleetResponse:
"""Get full status of a fleet including nodes and endpoints."""
_validate_network(chain, network)
_check_chain_access(project, chain)
await _check_cluster_access(project, cluster_id, db)
kubeconfig = await _get_kubeconfig(cluster_id)
return await _manager.get_status(kubeconfig, chain, network, cluster_id)
@router.patch("/{chain}/{cluster_id}/{network}", response_model=FleetResponse)
async def update_fleet(
chain: str,
cluster_id: str,
network: str,
params: FleetUpdate,
project: ProjectDep,
db: DbDep,
) -> FleetResponse:
"""Update a fleet. Only provided fields are changed."""
_validate_network(chain, network)
_check_chain_access(project, chain)
await _check_cluster_access(project, cluster_id, db)
kubeconfig = await _get_kubeconfig(cluster_id)
return await _manager.update(kubeconfig, chain, network, cluster_id, params)
@router.delete("/{chain}/{cluster_id}/{network}")
async def destroy_fleet(
chain: str,
cluster_id: str,
network: str,
project: ProjectDep,
db: DbDep,
) -> dict:
"""Destroy a fleet. PVCs are preserved."""
_validate_network(chain, network)
_check_chain_access(project, chain)
await _check_cluster_access(project, cluster_id, db)
kubeconfig = await _get_kubeconfig(cluster_id)
success = await _manager.destroy(kubeconfig, chain, network)
return {"success": success, "message": f"Fleet {chain}-{network} destroyed on {cluster_id}"}
@router.post("/{chain}/{cluster_id}/{network}/scale", response_model=FleetResponse)
async def scale_fleet(
chain: str,
cluster_id: str,
network: str,
replicas: int,
project: ProjectDep,
db: DbDep,
) -> FleetResponse:
"""Scale fleet to the specified replica count."""
_validate_network(chain, network)
_check_chain_access(project, chain)
await _check_cluster_access(project, cluster_id, db)
if replicas < 1 or replicas > 100:
raise HTTPException(400, "Replicas must be between 1 and 100")
kubeconfig = await _get_kubeconfig(cluster_id)
return await _manager.scale(kubeconfig, chain, network, cluster_id, replicas)
@router.post("/{chain}/{cluster_id}/{network}/restart")
async def restart_fleet(
chain: str,
cluster_id: str,
network: str,
background_tasks: BackgroundTasks,
project: ProjectDep,
db: DbDep,
) -> dict:
"""Rolling restart all pods."""
_validate_network(chain, network)
_check_chain_access(project, chain)
await _check_cluster_access(project, cluster_id, db)
kubeconfig = await _get_kubeconfig(cluster_id)
async def _do_restart():
try:
restarted = await _manager.restart(kubeconfig, chain, network)
logger.info("Rolling restart complete: %s", restarted)
except Exception as e:
logger.error("Rolling restart failed: %s", e)
background_tasks.add_task(_do_restart)
return {"message": f"Rolling restart initiated for {chain}-{network}", "status": "started"}
@router.get("/{chain}/{cluster_id}/{network}/pods/{pod_name}/logs")
async def get_pod_logs(
chain: str,
cluster_id: str,
network: str,
pod_name: str,
project: ProjectDep,
db: DbDep,
tail: int = 100,
) -> dict:
"""Get logs from a specific pod."""
_validate_network(chain, network)
_check_chain_access(project, chain)
await _check_cluster_access(project, cluster_id, db)
_validate_k8s_name(pod_name, "pod_name")
if tail < 1 or tail > 10000:
raise HTTPException(400, "tail must be between 1 and 10000")
kubeconfig = await _get_kubeconfig(cluster_id)
logs = await _manager.get_pod_logs(kubeconfig, chain, network, pod_name, tail=tail)
return {"pod": pod_name, "lines": logs.splitlines()}
@router.get("/{chain}/{cluster_id}/{network}/pods/{pod_name}/health")
async def get_pod_health(
chain: str,
cluster_id: str,
network: str,
pod_name: str,
project: ProjectDep,
db: DbDep,
) -> dict:
"""Probe a node's health."""
_validate_network(chain, network)
_check_chain_access(project, chain)
await _check_cluster_access(project, cluster_id, db)
_validate_k8s_name(pod_name, "pod_name")
kubeconfig = await _get_kubeconfig(cluster_id)
return await _manager.probe_node(kubeconfig, chain, network, pod_name)
+1 -309
View File
@@ -1,309 +1 @@
"""Lux Fleet API -- REST endpoints for managing Lux validator fleets.
Provides CRUD operations for Lux blockchain validator fleets deployed
via Helm charts to DOKS clusters.
Supports two modes:
1. Remote: cluster_id provided → fetch kubeconfig from DigitalOcean API
2. In-cluster: no cluster_id or no DO token → use pod ServiceAccount
"""
import logging
import os
import re
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException
from bootnode.api.deps import ApiKeyDep
from bootnode.core.chains.lux import LuxFleetManager
from bootnode.core.chains.lux_models import (
LuxFleetCreate,
LuxFleetResponse,
LuxFleetStats,
LuxFleetStatus,
LuxFleetSummary,
LuxFleetUpdate,
LuxNetwork,
NETWORK_CONFIGS,
)
logger = logging.getLogger(__name__)
router = APIRouter()
# In-memory fleet registry (source of truth is Helm releases on clusters)
_fleets: dict[str, dict] = {}
# Singleton manager
_manager = LuxFleetManager()
# K8s name validation: RFC 1123 DNS label
_K8S_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$")
# In-cluster sentinel -- used when no cluster_id is provided
_INCLUSTER_ID = "incluster"
def _validate_k8s_name(name: str, field: str = "name") -> None:
"""Validate a string is a valid K8s name (RFC 1123 DNS label)."""
if not _K8S_NAME_RE.match(name):
raise HTTPException(400, f"Invalid {field}: must match RFC 1123 DNS label")
def _has_do_client() -> bool:
"""Check if DigitalOcean client is available."""
try:
from bootnode.api.infra import get_do_client
return get_do_client() is not None
except Exception:
return False
def _is_incluster() -> bool:
"""Detect if running inside a Kubernetes pod."""
return os.path.exists("/var/run/secrets/kubernetes.io/serviceaccount/token")
async def _get_kubeconfig(cluster_id: str) -> Optional[str]:
"""Fetch kubeconfig for a DOKS cluster via the infra API.
Returns None if cluster_id is the in-cluster sentinel.
"""
if cluster_id == _INCLUSTER_ID:
return None
try:
from bootnode.api.infra import do_get_kubeconfig, get_do_client
client = get_do_client()
if not client:
if _is_incluster():
return None # fall back to in-cluster
raise HTTPException(500, "DigitalOcean client not configured")
return await do_get_kubeconfig(client, cluster_id)
except ImportError:
if _is_incluster():
return None
raise HTTPException(500, "Infrastructure module not available")
except HTTPException:
raise
except Exception:
logger.error("Failed to fetch kubeconfig for cluster %s", cluster_id)
if _is_incluster():
return None
raise HTTPException(502, "Failed to fetch kubeconfig for cluster")
# ---- Fleet CRUD ----
@router.post("/fleets", response_model=LuxFleetResponse)
async def create_fleet(
params: LuxFleetCreate,
background_tasks: BackgroundTasks,
_api_key: ApiKeyDep,
) -> LuxFleetResponse:
"""Create a new Lux validator fleet on a DOKS cluster."""
fleet_id = f"{params.name}-{params.network.value}"
if fleet_id in _fleets:
raise HTTPException(409, f"Fleet {fleet_id} already exists")
kubeconfig = await _get_kubeconfig(params.cluster_id)
result = await _manager.create(kubeconfig, params)
_fleets[fleet_id] = {
"name": params.name,
"cluster_id": params.cluster_id,
"network": params.network.value,
}
return result
@router.get("/fleets", response_model=list[LuxFleetSummary])
async def list_fleets(_api_key: ApiKeyDep, cluster_id: Optional[str] = None) -> list[LuxFleetSummary]:
"""List all Lux fleets, optionally filtered by cluster.
When running in-cluster and no cluster_id is provided, discovers
fleets on the local cluster using the pod's ServiceAccount.
"""
effective_cluster_id = cluster_id or (_INCLUSTER_ID if _is_incluster() else None)
if effective_cluster_id:
kubeconfig = await _get_kubeconfig(effective_cluster_id)
if kubeconfig is None:
# In-cluster mode
return await _manager.list_fleets_incluster(effective_cluster_id)
return await _manager.list_fleets(kubeconfig, effective_cluster_id)
# List across all known clusters
all_fleets = []
cluster_ids = {f["cluster_id"] for f in _fleets.values()}
for cid in cluster_ids:
try:
kubeconfig = await _get_kubeconfig(cid)
if kubeconfig is None:
fleets = await _manager.list_fleets_incluster(cid)
else:
fleets = await _manager.list_fleets(kubeconfig, cid)
all_fleets.extend(fleets)
except Exception as e:
logger.warning("Failed to list fleets on cluster %s: %s", cid, e)
return all_fleets
@router.get("/fleets/{cluster_id}/{network}", response_model=LuxFleetResponse)
async def get_fleet(cluster_id: str, network: LuxNetwork, _api_key: ApiKeyDep) -> LuxFleetResponse:
"""Get full status of a fleet including pods and endpoints."""
kubeconfig = await _get_kubeconfig(cluster_id)
# Find fleet name from registry
name = f"luxd-{network.value}"
for fid, fdata in _fleets.items():
if fdata["cluster_id"] == cluster_id and fdata["network"] == network.value:
name = fdata["name"]
break
if kubeconfig is None:
return await _manager.get_status_incluster(name, network, cluster_id)
return await _manager.get_status(kubeconfig, name, network, cluster_id)
@router.patch("/fleets/{cluster_id}/{network}", response_model=LuxFleetResponse)
async def update_fleet(
cluster_id: str,
network: LuxNetwork,
params: LuxFleetUpdate,
_api_key: ApiKeyDep,
) -> LuxFleetResponse:
"""Update a fleet (helm upgrade). Only provided fields are changed."""
kubeconfig = await _get_kubeconfig(cluster_id)
name = f"luxd-{network.value}"
for fid, fdata in _fleets.items():
if fdata["cluster_id"] == cluster_id and fdata["network"] == network.value:
name = fdata["name"]
break
return await _manager.update(kubeconfig, name, network, cluster_id, params)
@router.delete("/fleets/{cluster_id}/{network}")
async def destroy_fleet(cluster_id: str, network: LuxNetwork, _api_key: ApiKeyDep) -> dict:
"""Destroy a fleet (helm uninstall). PVCs are preserved."""
kubeconfig = await _get_kubeconfig(cluster_id)
success = await _manager.destroy(kubeconfig, network)
# Remove from registry
to_remove = [
fid for fid, fdata in _fleets.items()
if fdata["cluster_id"] == cluster_id and fdata["network"] == network.value
]
for fid in to_remove:
del _fleets[fid]
return {"success": success, "message": f"Fleet {network.value} destroyed on {cluster_id}"}
# ---- Fleet operations ----
@router.post("/fleets/{cluster_id}/{network}/scale", response_model=LuxFleetResponse)
async def scale_fleet(
cluster_id: str,
network: LuxNetwork,
replicas: int,
_api_key: ApiKeyDep,
) -> LuxFleetResponse:
"""Scale fleet to the specified replica count."""
if replicas < 1 or replicas > 20:
raise HTTPException(400, "Replicas must be between 1 and 20")
kubeconfig = await _get_kubeconfig(cluster_id)
name = f"luxd-{network.value}"
return await _manager.scale(kubeconfig, name, network, cluster_id, replicas)
@router.post("/fleets/{cluster_id}/{network}/restart")
async def restart_fleet(
cluster_id: str,
network: LuxNetwork,
background_tasks: BackgroundTasks,
_api_key: ApiKeyDep,
) -> dict:
"""Rolling restart all pods (one at a time, OnDelete strategy)."""
kubeconfig = await _get_kubeconfig(cluster_id)
async def _do_restart():
try:
restarted = await _manager.rolling_restart(kubeconfig, network)
logger.info("Rolling restart complete: %s", restarted)
except Exception as e:
logger.error("Rolling restart failed: %s", e)
background_tasks.add_task(_do_restart)
return {"message": f"Rolling restart initiated for {network.value}", "status": "started"}
@router.get("/fleets/{cluster_id}/{network}/pods/{pod_name}/logs")
async def get_pod_logs(
cluster_id: str,
network: LuxNetwork,
pod_name: str,
_api_key: ApiKeyDep,
tail: int = 100,
) -> dict:
"""Get logs from a specific pod."""
_validate_k8s_name(pod_name, "pod_name")
if tail < 1 or tail > 10000:
raise HTTPException(400, "tail must be between 1 and 10000")
kubeconfig = await _get_kubeconfig(cluster_id)
logs = await _manager.get_pod_logs(kubeconfig, network, pod_name, tail=tail)
return {"pod": pod_name, "lines": logs.splitlines()}
@router.get("/fleets/{cluster_id}/{network}/pods/{pod_name}/health")
async def get_pod_health(
cluster_id: str,
network: LuxNetwork,
pod_name: str,
_api_key: ApiKeyDep,
) -> dict:
"""Probe a luxd node's health via RPC."""
_validate_k8s_name(pod_name, "pod_name")
kubeconfig = await _get_kubeconfig(cluster_id)
return await _manager.probe_node_health(kubeconfig, network, pod_name)
# ---- Stats ----
@router.get("/stats", response_model=LuxFleetStats)
async def get_stats(_api_key: ApiKeyDep) -> LuxFleetStats:
"""Get aggregate fleet statistics."""
total_fleets = len(_fleets)
fleets_by_network: dict[str, int] = {}
for fdata in _fleets.values():
net = fdata["network"]
fleets_by_network[net] = fleets_by_network.get(net, 0) + 1
return LuxFleetStats(
total_fleets=total_fleets,
fleets_by_network=fleets_by_network,
)
# ---- Network info ----
@router.get("/networks")
async def list_networks() -> dict:
"""List available Lux networks and their configurations."""
return {
"networks": {
net.value: {
"network_id": cfg.network_id,
"chain_id": cfg.chain_id,
"http_port": cfg.http_port,
"staking_port": cfg.staking_port,
"namespace": cfg.namespace,
}
for net, cfg in NETWORK_CONFIGS.items()
}
}
"""Deprecated — all fleet management is at /v1/fleets."""
+476
View File
@@ -0,0 +1,476 @@
"""Observability API — platform health, metrics, alerts, logs, and events.
Provides /v1/o11y/* endpoints for cloud.lux.network dashboard.
Backend stack (Hanzo O11y — same as Hanzo console):
- Traces: OTEL Collector → Datastore (otel DB)
- Metrics: OTEL Collector → Datastore + Prometheus :8889
- Logs: Loki (Promtail scrapes bootnode namespace)
- Analytics: DataStore (bootnode DB, api_usage table)
- Dashboards: Grafana (grafana.hanzo.ai)
"""
import logging
import time
from datetime import datetime, timezone
from enum import Enum
from typing import Any
from fastapi import APIRouter, Query
from pydantic import BaseModel
from bootnode.api.deps import ProjectDep, DbDep
from bootnode.core.datastore import datastore_client
from bootnode.core.deploy import ServiceStatus, ServiceType, get_deployer
logger = logging.getLogger(__name__)
router = APIRouter()
# =============================================================================
# Models
# =============================================================================
class ServiceHealth(BaseModel):
"""Health of a single service."""
name: str
healthy: bool
replicas: int
ready_replicas: int
latency_ms: float | None = None
class HealthOverview(BaseModel):
"""Aggregate health across all services."""
total_services: int
healthy: int
unhealthy: int
degraded: int
uptime_percent: float
services: list[ServiceHealth]
checked_at: str
class MetricPoint(BaseModel):
"""A single metric data point."""
name: str
value: float
unit: str
labels: dict[str, str] = {}
class MetricsResponse(BaseModel):
"""Platform metrics snapshot."""
requests_per_sec: float
latency_p50_ms: float
latency_p95_ms: float
latency_p99_ms: float
error_rate: float
active_connections: int
total_requests_24h: int
metrics: list[MetricPoint]
collected_at: str
class AlertSeverity(str, Enum):
CRITICAL = "critical"
WARNING = "warning"
INFO = "info"
class Alert(BaseModel):
"""An active alert."""
id: str
severity: AlertSeverity
service: str
message: str
metric: str | None = None
threshold: float | None = None
current_value: float | None = None
fired_at: str
resolved_at: str | None = None
class AlertRule(BaseModel):
"""Alert rule definition."""
service: str
metric: str
threshold: float
severity: AlertSeverity = AlertSeverity.WARNING
message: str | None = None
class AlertRuleResponse(BaseModel):
"""Created alert rule."""
id: str
service: str
metric: str
threshold: float
severity: str
created_at: str
class EventType(str, Enum):
DEPLOY = "deploy"
SCALE = "scale"
RESTART = "restart"
ERROR = "error"
ALERT = "alert"
HEALTH_CHANGE = "health_change"
class PlatformEvent(BaseModel):
"""A platform event."""
id: str
type: EventType
service: str | None = None
message: str
metadata: dict[str, Any] = {}
timestamp: str
class ServiceMapNode(BaseModel):
"""A node in the service dependency graph."""
name: str
type: str
healthy: bool
connections: list[str]
class LogEntry(BaseModel):
"""Aggregated log entry."""
timestamp: str
service: str
level: str
message: str
metadata: dict[str, Any] = {}
# =============================================================================
# Endpoints
# =============================================================================
@router.get("/health", response_model=HealthOverview)
async def get_health_overview(
project: ProjectDep,
fail_if_unhealthy: bool = Query(False, alias="failIfUnhealthy"),
) -> HealthOverview:
"""Aggregate health across all services and fleets.
Mirrors console's /api/public/health pattern with optional failure conditions.
"""
deployer = get_deployer()
services: list[ServiceHealth] = []
for svc in ServiceType:
try:
t0 = time.monotonic()
status = await deployer.status(svc)
latency = (time.monotonic() - t0) * 1000
services.append(ServiceHealth(
name=svc.value,
healthy=status.running and status.ready_replicas > 0,
replicas=status.replicas,
ready_replicas=status.ready_replicas,
latency_ms=round(latency, 2),
))
except Exception:
services.append(ServiceHealth(
name=svc.value,
healthy=False,
replicas=0,
ready_replicas=0,
))
total = len(services)
healthy = sum(1 for s in services if s.healthy)
unhealthy = sum(1 for s in services if not s.healthy and s.replicas > 0)
degraded = sum(1 for s in services if s.healthy and s.ready_replicas < s.replicas)
uptime = (healthy / total * 100) if total > 0 else 0.0
overview = HealthOverview(
total_services=total,
healthy=healthy,
unhealthy=unhealthy,
degraded=degraded,
uptime_percent=round(uptime, 2),
services=services,
checked_at=datetime.now(timezone.utc).isoformat(),
)
if fail_if_unhealthy and unhealthy > 0:
from fastapi import HTTPException
raise HTTPException(503, detail=overview.model_dump())
return overview
@router.get("/metrics", response_model=MetricsResponse)
async def get_metrics(
project: ProjectDep,
) -> MetricsResponse:
"""Platform-wide metrics snapshot.
Queries ClickHouse (Datastore) api_usage table for real metrics.
Falls back to zeros if Datastore is unavailable.
"""
now = datetime.now(timezone.utc)
rps = 0.0
p50 = 0.0
p95 = 0.0
p99 = 0.0
error_rate = 0.0
total_24h = 0
if datastore_client.is_connected:
try:
row = await datastore_client.fetchone(
"""
SELECT
count() / 86400 AS rps,
quantile(0.50)(response_time_ms) AS p50,
quantile(0.95)(response_time_ms) AS p95,
quantile(0.99)(response_time_ms) AS p99,
countIf(status_code >= 500) / greatest(count(), 1) AS error_rate,
count() AS total_24h
FROM api_usage
WHERE project_id = {project_id:String}
AND timestamp >= now() - INTERVAL 24 HOUR
""",
{"project_id": str(project.id)},
)
if row:
rps = float(row.get("rps", 0))
p50 = float(row.get("p50", 0))
p95 = float(row.get("p95", 0))
p99 = float(row.get("p99", 0))
error_rate = float(row.get("error_rate", 0))
total_24h = int(row.get("total_24h", 0))
except Exception as e:
logger.warning("Failed to query Datastore for metrics: %s", e)
return MetricsResponse(
requests_per_sec=round(rps, 2),
latency_p50_ms=round(p50, 1),
latency_p95_ms=round(p95, 1),
latency_p99_ms=round(p99, 1),
error_rate=round(error_rate, 4),
active_connections=0, # TODO: from Redis pub/sub or WS manager
total_requests_24h=total_24h,
metrics=[
MetricPoint(name="rpc_requests_total", value=total_24h, unit="count"),
MetricPoint(name="rpc_error_rate", value=round(error_rate * 100, 2), unit="percent"),
MetricPoint(name="rpc_latency_p95", value=round(p95, 1), unit="ms"),
],
collected_at=now.isoformat(),
)
@router.get("/metrics/{service}", response_model=MetricsResponse)
async def get_service_metrics(
service: str,
project: ProjectDep,
) -> MetricsResponse:
"""Per-service metrics.
TODO: Wire to per-service Prometheus metrics.
"""
try:
ServiceType(service)
except ValueError:
from fastapi import HTTPException
raise HTTPException(400, f"Unknown service: {service}")
return MetricsResponse(
requests_per_sec=0.0,
latency_p50_ms=0.0,
latency_p95_ms=0.0,
latency_p99_ms=0.0,
error_rate=0.0,
active_connections=0,
total_requests_24h=0,
metrics=[],
collected_at=datetime.now(timezone.utc).isoformat(),
)
@router.get("/alerts", response_model=list[Alert])
async def get_alerts(
project: ProjectDep,
severity: AlertSeverity | None = None,
active_only: bool = True,
) -> list[Alert]:
"""Active alerts and recent incidents.
TODO: Wire to alert rules engine + Redis pub/sub.
"""
# TODO: Query alert state from DB/Redis
return []
@router.post("/alerts", response_model=AlertRuleResponse)
async def create_alert_rule(
rule: AlertRule,
project: ProjectDep,
db: DbDep,
) -> AlertRuleResponse:
"""Create an alert rule (threshold on a metric).
TODO: Persist to DB and register with metrics poller.
"""
import uuid
# TODO: Persist alert rule to database
return AlertRuleResponse(
id=str(uuid.uuid4()),
service=rule.service,
metric=rule.metric,
threshold=rule.threshold,
severity=rule.severity.value,
created_at=datetime.now(timezone.utc).isoformat(),
)
@router.get("/service-map", response_model=list[ServiceMapNode])
async def get_service_map(
project: ProjectDep,
) -> list[ServiceMapNode]:
"""Service dependency graph.
Returns nodes and their connections for visualization.
"""
deployer = get_deployer()
nodes: list[ServiceMapNode] = []
# Build a static dependency map
deps: dict[str, list[str]] = {
"api": ["web", "indexer", "bundler", "webhook-worker", "rpc-proxy"],
"web": [],
"rpc-proxy": ["validator"],
"explorer": ["rpc-proxy", "graph-node"],
"graph-node": ["graph-postgres", "graph-ipfs", "rpc-proxy"],
"graph-postgres": [],
"graph-ipfs": [],
"bridge": ["rpc-proxy"],
"exchange": ["rpc-proxy"],
"safe": ["safe-transaction", "safe-config"],
"safe-transaction": ["rpc-proxy"],
"safe-config": [],
"mpc": ["mpc-postgres"],
"mpc-postgres": [],
"gateway": ["api"],
"faucet": ["rpc-proxy"],
"market": ["rpc-proxy", "graph-node"],
"staking": ["rpc-proxy"],
"validator": [],
"indexer": [],
"bundler": ["rpc-proxy"],
"webhook-worker": [],
}
for svc in ServiceType:
try:
status = await deployer.status(svc)
healthy = status.running and status.ready_replicas > 0
except Exception:
healthy = False
nodes.append(ServiceMapNode(
name=svc.value,
type="core" if svc.value in ("api", "web", "indexer", "bundler", "webhook-worker") else "infra",
healthy=healthy,
connections=deps.get(svc.value, []),
))
return nodes
@router.get("/logs", response_model=list[LogEntry])
async def search_logs(
project: ProjectDep,
q: str | None = None,
service: str | None = None,
level: str | None = None,
since: str | None = None,
until: str | None = None,
limit: int = Query(100, ge=1, le=1000),
) -> list[LogEntry]:
"""Search aggregated logs across services.
Queries Loki (loki.hanzo.svc:3100) for container logs from the
bootnode namespace. Falls back to ClickHouse otel_logs table.
"""
import os
import httpx
loki_url = os.getenv("LOKI_URL", "http://loki.hanzo.svc:3100")
entries: list[LogEntry] = []
# Build LogQL query
label_selectors = ['namespace="bootnode"']
if service:
label_selectors.append(f'app="{service}"')
logql = '{' + ','.join(label_selectors) + '}'
if level:
logql += f' | level="{level.upper()}"'
if q:
logql += f' |= "{q}"'
params: dict[str, Any] = {
"query": logql,
"limit": str(limit),
"direction": "backward",
}
if since:
params["start"] = since
if until:
params["end"] = until
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{loki_url}/loki/api/v1/query_range", params=params)
if resp.status_code == 200:
data = resp.json()
for stream in data.get("data", {}).get("result", []):
labels = stream.get("stream", {})
for ts, line in stream.get("values", []):
entries.append(LogEntry(
timestamp=datetime.fromtimestamp(
int(ts) / 1e9, tz=timezone.utc
).isoformat(),
service=labels.get("app", labels.get("container", "unknown")),
level=labels.get("level", "info"),
message=line,
metadata=labels,
))
except Exception as e:
logger.warning("Loki query failed, falling back to empty: %s", e)
return entries[:limit]
@router.get("/events", response_model=list[PlatformEvent])
async def get_events(
project: ProjectDep,
type: EventType | None = None,
service: str | None = None,
limit: int = Query(50, ge=1, le=500),
) -> list[PlatformEvent]:
"""Recent platform events (deploys, scales, restarts, errors).
TODO: Wire to events table in DB.
"""
# TODO: Query events from database
return []
+140 -3
View File
@@ -100,7 +100,7 @@ async def get_zap_server_info(_api_key: ApiKeyDep) -> dict[str, Any]:
"prompts": False,
"logging": True,
},
tools_count=11,
tools_count=18,
resources_count=3,
).model_dump(),
"protocol": {
@@ -120,13 +120,14 @@ async def list_zap_tools(_api_key: ApiKeyDep) -> dict[str, Any]:
Each tool has a name, description, and JSON Schema for arguments.
"""
tools = [
# === RPC & Blockchain ===
{
"name": "rpc_call",
"description": "Execute JSON-RPC on any supported blockchain",
"inputSchema": {
"type": "object",
"properties": {
"chain": {"type": "string", "description": "Chain name (ethereum, polygon, etc.)"},
"chain": {"type": "string", "description": "Chain name (ethereum, polygon, lux, etc.)"},
"network": {"type": "string", "default": "mainnet"},
"method": {"type": "string", "description": "RPC method"},
"params": {"type": "array", "default": []},
@@ -184,12 +185,148 @@ async def list_zap_tools(_api_key: ApiKeyDep) -> dict[str, Any]:
"required": ["chain"],
},
},
# === Fleet Management (AI agent infrastructure control) ===
{
"name": "fleet_list",
"description": "List all validator fleets across clusters and networks",
"inputSchema": {
"type": "object",
"properties": {
"chain": {"type": "string", "description": "Filter by chain (lux, hanzo, etc.)"},
"cluster_id": {"type": "string", "description": "Filter by cluster"},
},
},
},
{
"name": "fleet_get",
"description": "Get full status of a fleet including nodes and endpoints",
"inputSchema": {
"type": "object",
"properties": {
"chain": {"type": "string"},
"cluster_id": {"type": "string"},
"network": {"type": "string"},
},
"required": ["chain", "cluster_id", "network"],
},
},
{
"name": "fleet_scale",
"description": "Scale a fleet to target replica count",
"inputSchema": {
"type": "object",
"properties": {
"chain": {"type": "string"},
"cluster_id": {"type": "string"},
"network": {"type": "string"},
"replicas": {"type": "integer", "minimum": 1, "maximum": 100},
},
"required": ["chain", "cluster_id", "network", "replicas"],
},
},
{
"name": "fleet_restart",
"description": "Rolling restart all pods in a fleet",
"inputSchema": {
"type": "object",
"properties": {
"chain": {"type": "string"},
"cluster_id": {"type": "string"},
"network": {"type": "string"},
},
"required": ["chain", "cluster_id", "network"],
},
},
# === Service Management ===
{
"name": "service_list",
"description": "List all deployed infrastructure services and their status",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "service_deploy",
"description": "Deploy or update an infrastructure service (explorer, bridge, safe, etc.)",
"inputSchema": {
"type": "object",
"properties": {
"service": {"type": "string", "description": "Service type (explorer, bridge, safe, faucet, etc.)"},
"image": {"type": "string", "description": "Container image"},
"replicas": {"type": "integer", "default": 1},
"env": {"type": "object", "description": "Environment variables"},
},
"required": ["service", "image"],
},
},
{
"name": "service_scale",
"description": "Scale a service to target replica count",
"inputSchema": {
"type": "object",
"properties": {
"service": {"type": "string"},
"replicas": {"type": "integer", "minimum": 0, "maximum": 100},
},
"required": ["service", "replicas"],
},
},
{
"name": "service_logs",
"description": "Get logs from a deployed service",
"inputSchema": {
"type": "object",
"properties": {
"service": {"type": "string"},
"tail": {"type": "integer", "default": 100},
},
"required": ["service"],
},
},
# === Observability ===
{
"name": "o11y_health",
"description": "Get aggregate health across all services and fleets",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "o11y_metrics",
"description": "Get platform metrics (requests/sec, latency, error rate)",
"inputSchema": {
"type": "object",
"properties": {
"service": {"type": "string", "description": "Optional: filter to specific service"},
},
},
},
{
"name": "o11y_logs",
"description": "Search aggregated logs across all services via Loki",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search text"},
"service": {"type": "string", "description": "Filter by service"},
"level": {"type": "string", "enum": ["debug", "info", "warn", "error"]},
"limit": {"type": "integer", "default": 100},
},
},
},
{
"name": "o11y_alerts",
"description": "Get active alerts and recent incidents",
"inputSchema": {"type": "object", "properties": {}},
},
]
return {
"tools": tools,
"total": len(tools),
"note": "Connect via ZAP for full tool list with all parameters",
"categories": {
"rpc": ["rpc_call", "get_token_balances", "get_nfts_owned", "create_smart_wallet", "estimate_gas"],
"fleet": ["fleet_list", "fleet_get", "fleet_scale", "fleet_restart"],
"service": ["service_list", "service_deploy", "service_scale", "service_logs"],
"o11y": ["o11y_health", "o11y_metrics", "o11y_logs", "o11y_alerts"],
},
"note": "Connect via ZAP for binary RPC: zap://api.bootno.de:9999",
}
+22 -1
View File
@@ -78,7 +78,7 @@ class Settings(BaseSettings):
btc_rpc_user: str = "bootnode"
btc_rpc_password: str = ""
# DataStore (ClickHouse)
# DataStore (OLAP analytics backend)
datastore_url: str = "clickhouse://bootnode:bootnode@localhost:8123/bootnode"
datastore_pool_size: int = 10
@@ -132,6 +132,11 @@ class Settings(BaseSettings):
zap_host: str = "0.0.0.0"
zap_port: int = 9999
# Hanzo O11y (OTEL)
otel_enabled: bool = True
otel_endpoint: str = "http://otel-collector.hanzo.svc:4317"
otel_service_name: str = "bootnode-api"
# Deployment Target
deploy_target: Literal["docker", "process", "kubernetes"] = "docker"
deploy_compose_file: str = "infra/compose.yml"
@@ -146,6 +151,22 @@ class Settings(BaseSettings):
def is_production(self) -> bool:
return self.app_env == "production"
def validate_production(self) -> None:
"""Fail fast if production is missing critical secrets."""
if not self.is_production:
return
insecure = []
if self.jwt_secret == "change-me-in-production":
insecure.append("JWT_SECRET")
if self.api_key_salt == "change-me-in-production":
insecure.append("API_KEY_SALT")
if self.bundler_private_key == "":
insecure.append("BUNDLER_PRIVATE_KEY")
if insecure:
raise RuntimeError(
f"Production mode requires these secrets: {', '.join(insecure)}"
)
@lru_cache
def get_settings() -> Settings:
+11 -11
View File
@@ -1,7 +1,7 @@
"""Real-time usage tracking with Redis.
Tracks compute units in Redis for real-time rate limiting and quota checks.
Batches writes to ClickHouse for analytics and billing.
Batches writes to DataStore for analytics and billing.
"""
import json
@@ -22,15 +22,15 @@ logger = structlog.get_logger()
# Redis key prefixes
CU_USAGE_KEY = "cu:usage:{project_id}:{period}" # Monthly CU counter
RATE_LIMIT_KEY = "rate:{project_id}:{window}" # Per-second rate limit
CU_BUFFER_KEY = "cu:buffer:{project_id}" # Batch buffer for ClickHouse
CU_BUFFER_KEY = "cu:buffer:{project_id}" # Batch buffer for DataStore
class UsageTracker:
"""Track compute units in Redis, batch to ClickHouse."""
"""Track compute units in Redis, batch to DataStore."""
def __init__(self) -> None:
self._batch_size = 100 # Flush to ClickHouse after N records
self._batch_interval = 60 # Flush to ClickHouse after N seconds
self._batch_size = 100 # Flush to DataStore after N records
self._batch_interval = 60 # Flush to DataStore after N seconds
def _get_period_key(self, project_id: uuid.UUID) -> str:
"""Get the current billing period key (YYYY-MM)."""
@@ -84,7 +84,7 @@ class UsageTracker:
except Exception as e:
logger.error("Failed to track usage in Redis", error=str(e))
# Buffer for ClickHouse batch insert
# Buffer for DataStore batch insert
usage_record = {
"project_id": str(project_id),
"api_key_id": str(api_key_id) if api_key_id else None,
@@ -112,7 +112,7 @@ class UsageTracker:
logger.error("Failed to buffer usage", error=str(e))
async def _flush_buffer(self, project_id: uuid.UUID) -> None:
"""Flush buffered usage records to ClickHouse."""
"""Flush buffered usage records to DataStore."""
if not datastore_client.is_connected:
return
@@ -129,7 +129,7 @@ class UsageTracker:
if not records_raw:
return
# Parse records and insert to ClickHouse
# Parse records and insert to DataStore
records: list[dict[str, Any]] = []
for record_str in records_raw:
try:
@@ -141,13 +141,13 @@ class UsageTracker:
if records:
await datastore_client.insert("api_usage", records)
logger.debug(
"Flushed usage to ClickHouse",
"Flushed usage to DataStore",
project_id=str(project_id),
count=len(records),
)
except Exception as e:
logger.error("Failed to flush buffer to ClickHouse", error=str(e))
logger.error("Failed to flush buffer to DataStore", error=str(e))
async def get_current_usage(self, project_id: uuid.UUID) -> int:
"""Get current compute units used this billing period.
@@ -243,7 +243,7 @@ class UsageTracker:
}
async def flush_all_buffers(self) -> None:
"""Flush all project buffers to ClickHouse. Called on shutdown."""
"""Flush all project buffers to DataStore. Called on shutdown."""
try:
# Find all buffer keys
cursor = 0
+693
View File
@@ -0,0 +1,693 @@
"""Fleet manager — blockchain node fleet lifecycle via K8s CRDs.
Supports two CRD backends:
1. Bootnode Cluster CRDs (bootnode.dev/v1alpha1) — generic, any chain
2. Lux Operator LuxNetwork CRDs (lux.network/v1alpha1) — native lux validators
The manager tries bootnode CRDs first, then falls back to lux-operator CRDs
for Lux chain discovery. This allows managing both bootnode-provisioned fleets
and pre-existing lux-operator-managed validators from the same API.
"""
import logging
from typing import Optional
from bootnode.core.chains.fleet_models import (
CRD_GROUP,
CRD_PLURAL,
CRD_VERSION,
CHAIN_NETWORKS,
FleetCreate,
FleetResponse,
FleetStatus,
FleetSummary,
FleetUpdate,
ImageConfig,
NodeInfo,
NodeStatus,
crd_status_to_fleet_response,
fleet_create_to_crd,
fleet_update_to_crd_patch,
get_network_config,
)
logger = logging.getLogger(__name__)
# Lux Operator CRD constants
LUX_CRD_GROUP = "lux.network"
LUX_CRD_VERSION = "v1alpha1"
LUX_CRD_PLURAL = "luxnetworks"
class FleetManager:
"""Manages blockchain node fleet lifecycle via Cluster CRDs.
Works for any chain — lux, avalanche, ethereum, bitcoin, etc.
For Lux specifically, also discovers lux-operator-managed validators.
"""
def _cr_name(self, chain: str, network: str) -> str:
return f"{chain}d-{network}"
def _namespace(self, chain: str, network: str) -> str:
return get_network_config(chain, network).namespace
def _fleet_id(self, cluster_id: str, chain: str, network: str) -> str:
return f"{cluster_id}:{self._cr_name(chain, network)}"
def _get_k8s_clients(self, kubeconfig_data: Optional[str] = None):
"""Get K8s API clients.
If kubeconfig_data is provided, loads config from that YAML string.
Otherwise uses in-cluster config.
"""
from kubernetes import client, config
import tempfile
import os
if kubeconfig_data:
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".kubeconfig", delete=False
)
tmp.write(kubeconfig_data)
tmp.flush()
tmp.close()
config.load_kube_config(config_file=tmp.name)
os.unlink(tmp.name)
else:
config.load_incluster_config()
return client.CustomObjectsApi(), client.CoreV1Api()
# ---- Lux Operator native discovery ----
def _luxnetwork_to_fleet_response(
self, cr: dict, cluster_id: str, network: str,
) -> FleetResponse:
"""Convert a LuxNetwork CR into a FleetResponse."""
spec = cr.get("spec", {})
status = cr.get("status", {})
metadata = cr.get("metadata", {})
phase = status.get("phase", "Pending")
status_map = {
"Pending": FleetStatus.PENDING,
"Creating": FleetStatus.DEPLOYING,
"Bootstrapping": FleetStatus.DEPLOYING,
"Running": FleetStatus.RUNNING,
"Degraded": FleetStatus.DEGRADED,
}
fleet_status = status_map.get(phase, FleetStatus.ERROR)
# Build node list from nodeStatuses (dict of pod_name -> status)
node_statuses = status.get("nodeStatuses", {})
external_ips = status.get("externalIps", {})
nodes = []
for i, (pod_name, ns) in enumerate(sorted(node_statuses.items())):
if isinstance(ns, str):
# Sometimes nodeStatuses is just a list of strings
nodes.append(NodeInfo(
pod_name=ns, pod_index=i,
status=NodeStatus.HEALTHY, healthy=True,
external_ip=external_ips.get(ns),
))
continue
nodes.append(NodeInfo(
pod_name=pod_name,
pod_index=i,
node_id=ns.get("nodeId"),
status=NodeStatus.HEALTHY if ns.get("healthy") else NodeStatus.UNHEALTHY,
external_ip=ns.get("externalIp") or external_ips.get(pod_name),
healthy=ns.get("healthy", False),
bootstrapped=ns.get("bootstrapped", False),
peers=ns.get("connectedPeers"),
extra={
k: v for k, v in ns.items()
if k not in ("healthy", "bootstrapped", "connectedPeers",
"nodeId", "externalIp")
},
))
img_spec = spec.get("image", {})
image = ImageConfig(
repository=img_spec.get("repository", "ghcr.io/luxfi/node"),
tag=img_spec.get("tag", "latest"),
pull_policy=img_spec.get("pullPolicy", "IfNotPresent"),
) if img_spec else None
net_cfg = get_network_config("lux", network)
# RPC endpoint from first external IP
rpc_endpoint = None
if external_ips:
first_ip = next(iter(external_ips.values()))
rpc_endpoint = f"http://{first_ip}:{net_cfg.http_port}/ext/bc/C/rpc"
chain_statuses = status.get("chainStatuses", {})
return FleetResponse(
id=f"{cluster_id}:luxd-{network}",
name=metadata.get("name", f"luxd-{network}"),
cluster_id=cluster_id,
chain="lux",
network=network,
status=fleet_status,
replicas=status.get("totalValidators", spec.get("validators", 0)),
ready_replicas=status.get("readyValidators", 0),
image=image,
namespace=metadata.get("namespace", ""),
rpc_endpoint=rpc_endpoint,
nodes=nodes,
created_at=metadata.get("creationTimestamp", ""),
updated_at=None,
error="; ".join(
r.get("message", "")
for r in status.get("degradedReasons", [])
if r.get("message")
) or None,
)
def _luxnetwork_to_fleet_summary(
self, cr: dict, cluster_id: str, network: str,
) -> FleetSummary:
"""Convert a LuxNetwork CR into a FleetSummary."""
spec = cr.get("spec", {})
status = cr.get("status", {})
metadata = cr.get("metadata", {})
phase = status.get("phase", "Pending")
status_map = {
"Pending": FleetStatus.PENDING,
"Creating": FleetStatus.DEPLOYING,
"Bootstrapping": FleetStatus.DEPLOYING,
"Running": FleetStatus.RUNNING,
"Degraded": FleetStatus.DEGRADED,
}
return FleetSummary(
id=f"{cluster_id}:luxd-{network}",
name=metadata.get("name", f"luxd-{network}"),
chain="lux",
network=network,
status=status_map.get(phase, FleetStatus.ERROR),
replicas=status.get("totalValidators", spec.get("validators", 0)),
ready_replicas=status.get("readyValidators", 0),
cluster_id=cluster_id,
created_at=metadata.get("creationTimestamp", ""),
)
async def _list_lux_native_fleets(
self, custom_api, cluster_id: str,
) -> list[FleetSummary]:
"""Discover lux-operator managed validators via LuxNetwork CRDs."""
fleets = []
lux_nets = CHAIN_NETWORKS.get("lux", {})
for network_name, cfg in lux_nets.items():
try:
result = custom_api.list_namespaced_custom_object(
group=LUX_CRD_GROUP,
version=LUX_CRD_VERSION,
namespace=cfg.namespace,
plural=LUX_CRD_PLURAL,
)
for cr in result.get("items", []):
fleets.append(
self._luxnetwork_to_fleet_summary(cr, cluster_id, network_name)
)
except Exception as e:
logger.debug("No LuxNetwork CRs in %s: %s", cfg.namespace, e)
continue
return fleets
async def _get_lux_native_status(
self, custom_api, chain: str, network: str, cluster_id: str,
) -> Optional[FleetResponse]:
"""Get status from a LuxNetwork CR."""
if chain != "lux":
return None
namespace = self._namespace(chain, network)
try:
# The LuxNetwork CR is typically named "luxd" in each namespace
cr = custom_api.get_namespaced_custom_object(
group=LUX_CRD_GROUP,
version=LUX_CRD_VERSION,
namespace=namespace,
plural=LUX_CRD_PLURAL,
name="luxd",
)
return self._luxnetwork_to_fleet_response(cr, cluster_id, network)
except Exception as e:
logger.debug("No LuxNetwork 'luxd' in %s: %s", namespace, e)
return None
# ---- Fleet lifecycle ----
async def create(
self,
kubeconfig: str,
params: FleetCreate,
) -> FleetResponse:
"""Create a new node fleet by creating a Cluster CR."""
namespace = self._namespace(params.chain, params.network)
logger.info(
"Creating fleet: chain=%s network=%s namespace=%s replicas=%d",
params.chain, params.network, namespace, params.replicas,
)
try:
custom_api, core_api = self._get_k8s_clients(kubeconfig)
body = fleet_create_to_crd(params)
# Ensure namespace exists
from kubernetes import client
try:
core_api.read_namespace(namespace)
except client.ApiException as e:
if e.status == 404:
core_api.create_namespace(
client.V1Namespace(
metadata=client.V1ObjectMeta(name=namespace)
)
)
custom_api.create_namespaced_custom_object(
group=CRD_GROUP,
version=CRD_VERSION,
namespace=namespace,
plural=CRD_PLURAL,
body=body,
)
return FleetResponse(
id=self._fleet_id(params.cluster_id, params.chain, params.network),
name=self._cr_name(params.chain, params.network),
cluster_id=params.cluster_id,
chain=params.chain,
network=params.network,
status=FleetStatus.DEPLOYING,
replicas=params.replicas,
namespace=namespace,
image=params.image,
)
except Exception as e:
logger.error("Fleet creation failed: %s", e)
return FleetResponse(
id=self._fleet_id(params.cluster_id, params.chain, params.network),
name=self._cr_name(params.chain, params.network),
cluster_id=params.cluster_id,
chain=params.chain,
network=params.network,
status=FleetStatus.ERROR,
replicas=params.replicas,
namespace=namespace,
error=str(e),
)
async def update(
self,
kubeconfig: str,
chain: str,
network: str,
cluster_id: str,
params: FleetUpdate,
) -> FleetResponse:
"""Update an existing fleet by patching the CR.
Tries bootnode CRD first, falls back to LuxNetwork CRD for scale ops.
"""
namespace = self._namespace(chain, network)
cr_name = self._cr_name(chain, network)
logger.info("Updating fleet: cr=%s namespace=%s", cr_name, namespace)
try:
custom_api, _ = self._get_k8s_clients(kubeconfig)
patch = fleet_update_to_crd_patch(params)
if patch:
try:
custom_api.patch_namespaced_custom_object(
group=CRD_GROUP, version=CRD_VERSION,
namespace=namespace, plural=CRD_PLURAL,
name=cr_name, body=patch,
)
except Exception:
# Try LuxNetwork CRD for scale operations
if chain == "lux" and params.replicas is not None:
lux_patch = {"spec": {"validators": params.replicas}}
custom_api.patch_namespaced_custom_object(
group=LUX_CRD_GROUP, version=LUX_CRD_VERSION,
namespace=namespace, plural=LUX_CRD_PLURAL,
name="luxd", body=lux_patch,
)
else:
raise
# Try bootnode CRD status first, then lux native
try:
cr = custom_api.get_namespaced_custom_object(
group=CRD_GROUP, version=CRD_VERSION,
namespace=namespace, plural=CRD_PLURAL, name=cr_name,
)
resp = crd_status_to_fleet_response(cr, cluster_id)
except Exception:
native = await self._get_lux_native_status(
custom_api, chain, network, cluster_id
)
if native:
resp = native
else:
raise
if patch:
resp.status = FleetStatus.UPDATING
return resp
except Exception as e:
logger.error("Fleet update failed: %s", e)
return FleetResponse(
id=self._fleet_id(cluster_id, chain, network),
name=cr_name,
cluster_id=cluster_id,
chain=chain,
network=network,
status=FleetStatus.ERROR,
replicas=params.replicas or 0,
namespace=namespace,
error=str(e),
)
async def scale(
self,
kubeconfig: str,
chain: str,
network: str,
cluster_id: str,
replicas: int,
) -> FleetResponse:
"""Scale a fleet's replica count."""
return await self.update(
kubeconfig=kubeconfig,
chain=chain,
network=network,
cluster_id=cluster_id,
params=FleetUpdate(replicas=replicas),
)
async def destroy(
self,
kubeconfig: str,
chain: str,
network: str,
) -> bool:
"""Destroy a fleet by deleting the Cluster CR. PVCs persist."""
namespace = self._namespace(chain, network)
cr_name = self._cr_name(chain, network)
try:
custom_api, _ = self._get_k8s_clients(kubeconfig)
custom_api.delete_namespaced_custom_object(
group=CRD_GROUP, version=CRD_VERSION,
namespace=namespace, plural=CRD_PLURAL, name=cr_name,
)
return True
except Exception as e:
logger.error("Fleet destruction failed: %s", e)
return False
async def get_status(
self,
kubeconfig: str,
chain: str,
network: str,
cluster_id: str,
) -> FleetResponse:
"""Get full status of a fleet.
Tries bootnode Cluster CRD first, then falls back to
lux-operator LuxNetwork CRD for native validator discovery.
"""
namespace = self._namespace(chain, network)
cr_name = self._cr_name(chain, network)
try:
custom_api, _ = self._get_k8s_clients(kubeconfig)
# Try bootnode CRD first
try:
cr = custom_api.get_namespaced_custom_object(
group=CRD_GROUP, version=CRD_VERSION,
namespace=namespace, plural=CRD_PLURAL, name=cr_name,
)
return crd_status_to_fleet_response(cr, cluster_id)
except Exception:
pass
# Fall back to lux-operator CRD
native = await self._get_lux_native_status(
custom_api, chain, network, cluster_id
)
if native:
return native
return FleetResponse(
id=self._fleet_id(cluster_id, chain, network),
name=cr_name,
cluster_id=cluster_id,
chain=chain,
network=network,
status=FleetStatus.ERROR,
replicas=0,
namespace=namespace,
error=f"No fleet CRD found in {namespace}",
)
except Exception as e:
logger.error("Fleet status read failed: %s", e)
return FleetResponse(
id=self._fleet_id(cluster_id, chain, network),
name=cr_name,
cluster_id=cluster_id,
chain=chain,
network=network,
status=FleetStatus.ERROR,
replicas=0,
namespace=namespace,
error=str(e),
)
async def list_fleets(
self,
kubeconfig: Optional[str],
cluster_id: str,
chain: Optional[str] = None,
) -> list[FleetSummary]:
"""List node fleets across chains/networks.
Discovers fleets from both bootnode Cluster CRDs and
lux-operator LuxNetwork CRDs.
"""
custom_api, _ = self._get_k8s_clients(kubeconfig)
fleets: list[FleetSummary] = []
seen_ids: set[str] = set()
chains_to_check = {chain: CHAIN_NETWORKS[chain]} if chain else CHAIN_NETWORKS
# 1. Try bootnode Cluster CRDs
for chain_name, networks in chains_to_check.items():
for network_name, cfg in networks.items():
try:
result = custom_api.list_namespaced_custom_object(
group=CRD_GROUP, version=CRD_VERSION,
namespace=cfg.namespace, plural=CRD_PLURAL,
)
for cr in result.get("items", []):
status = cr.get("status", {})
spec = cr.get("spec", {})
phase = status.get("phase", "Pending")
metadata = cr.get("metadata", {})
status_map = {
"Running": FleetStatus.RUNNING,
"Degraded": FleetStatus.DEGRADED,
"Creating": FleetStatus.DEPLOYING,
"Bootstrapping": FleetStatus.DEPLOYING,
}
fleet_status = status_map.get(phase, FleetStatus.PENDING)
fleet_id = f"{cluster_id}:{metadata.get('name', '')}"
fleets.append(FleetSummary(
id=fleet_id,
name=metadata.get("name", ""),
chain=spec.get("chain", chain_name),
network=spec.get("network", network_name),
status=fleet_status,
replicas=status.get("totalNodes", spec.get("replicas", 0)),
ready_replicas=status.get("readyNodes", 0),
cluster_id=cluster_id,
created_at=metadata.get("creationTimestamp", ""),
))
seen_ids.add(fleet_id)
except Exception as e:
logger.debug("No bootnode CRDs in %s: %s", cfg.namespace, e)
continue
# 2. Try lux-operator LuxNetwork CRDs (only for lux chain)
if chain is None or chain == "lux":
try:
lux_fleets = await self._list_lux_native_fleets(custom_api, cluster_id)
for f in lux_fleets:
if f.id not in seen_ids:
fleets.append(f)
seen_ids.add(f.id)
except Exception as e:
logger.debug("Lux native fleet discovery failed: %s", e)
return fleets
async def get_pod_logs(
self,
kubeconfig: str,
chain: str,
network: str,
pod_name: str,
tail: int = 100,
) -> str:
"""Get logs from a specific pod."""
_, core_api = self._get_k8s_clients(kubeconfig)
namespace = self._namespace(chain, network)
return core_api.read_namespaced_pod_log(
name=pod_name,
namespace=namespace,
tail_lines=tail,
)
async def restart(
self,
kubeconfig: str,
chain: str,
network: str,
) -> list[str]:
"""Trigger a rolling restart.
For bootnode CRDs: annotates the CR.
For lux-operator CRDs: annotates the LuxNetwork CR.
"""
namespace = self._namespace(chain, network)
cr_name = self._cr_name(chain, network)
custom_api, _ = self._get_k8s_clients(kubeconfig)
from datetime import datetime
restart_annotation = datetime.utcnow().isoformat() + "Z"
# Try bootnode CRD first
try:
patch = {
"metadata": {
"annotations": {
"bootnode.dev/restart-at": restart_annotation,
}
}
}
custom_api.patch_namespaced_custom_object(
group=CRD_GROUP, version=CRD_VERSION,
namespace=namespace, plural=CRD_PLURAL,
name=cr_name, body=patch,
)
cr = custom_api.get_namespaced_custom_object(
group=CRD_GROUP, version=CRD_VERSION,
namespace=namespace, plural=CRD_PLURAL, name=cr_name,
)
return [
n.get("podName", f"node-{i}")
for i, n in enumerate(cr.get("status", {}).get("nodes", []))
]
except Exception:
pass
# Fall back to lux-operator CRD
if chain == "lux":
try:
patch = {
"metadata": {
"annotations": {
"lux.network/restart-at": restart_annotation,
}
}
}
custom_api.patch_namespaced_custom_object(
group=LUX_CRD_GROUP, version=LUX_CRD_VERSION,
namespace=namespace, plural=LUX_CRD_PLURAL,
name="luxd", body=patch,
)
cr = custom_api.get_namespaced_custom_object(
group=LUX_CRD_GROUP, version=LUX_CRD_VERSION,
namespace=namespace, plural=LUX_CRD_PLURAL,
name="luxd",
)
return list(cr.get("status", {}).get("nodeStatuses", {}).keys())
except Exception as e:
logger.error("Restart via lux-operator failed: %s", e)
raise
raise RuntimeError(f"No fleet CRD found for {chain}-{network}")
async def probe_node(
self,
kubeconfig: str,
chain: str,
network: str,
pod_name: str,
) -> dict:
"""Get a node's health from CR status."""
namespace = self._namespace(chain, network)
cr_name = self._cr_name(chain, network)
custom_api, _ = self._get_k8s_clients(kubeconfig)
# Try bootnode CRD first
try:
cr = custom_api.get_namespaced_custom_object(
group=CRD_GROUP, version=CRD_VERSION,
namespace=namespace, plural=CRD_PLURAL, name=cr_name,
)
for node in cr.get("status", {}).get("nodes", []):
if node.get("podName") == pod_name:
return {
"healthy": node.get("healthy", False),
"bootstrapped": node.get("bootstrapped", False),
"node_id": node.get("nodeId"),
"connected_peers": node.get("connectedPeers"),
"extra": node.get("extra", {}),
}
except Exception:
pass
# Fall back to lux-operator CRD
if chain == "lux":
try:
cr = custom_api.get_namespaced_custom_object(
group=LUX_CRD_GROUP, version=LUX_CRD_VERSION,
namespace=namespace, plural=LUX_CRD_PLURAL,
name="luxd",
)
node_statuses = cr.get("status", {}).get("nodeStatuses", {})
ns = node_statuses.get(pod_name)
if ns and isinstance(ns, dict):
return {
"healthy": ns.get("healthy", False),
"bootstrapped": ns.get("bootstrapped", False),
"node_id": ns.get("nodeId"),
"connected_peers": ns.get("connectedPeers"),
"extra": {
k: v for k, v in ns.items()
if k not in ("healthy", "bootstrapped",
"connectedPeers", "nodeId", "externalIp")
},
}
except Exception as e:
logger.debug("Lux native probe failed: %s", e)
return {"healthy": False, "error": f"Pod {pod_name} not found in fleet status"}
+518
View File
@@ -0,0 +1,518 @@
"""Fleet models — generic blockchain node fleet management.
Single source of truth for data structures shared between:
- Bootnode Operator CRDs (operator/src/crd.rs Cluster)
- Fleet API endpoints (api/lux/, api/ethereum/, etc.)
- Fleet Manager (core/chains/fleet_manager.py)
- Dashboard
All components import from here. No model duplication.
Models map to the Cluster CRD (bootnode.dev/v1alpha1).
"""
from datetime import datetime
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel, Field, field_validator
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class FleetStatus(str, Enum):
"""Lifecycle state of a node fleet."""
PENDING = "pending"
DEPLOYING = "deploying"
RUNNING = "running"
DEGRADED = "degraded"
UPDATING = "updating"
ERROR = "error"
DESTROYING = "destroying"
DESTROYED = "destroyed"
class NodeStatus(str, Enum):
"""Status of an individual node pod within a fleet."""
PENDING = "pending"
INIT = "init"
STARTING = "starting"
BOOTSTRAPPING = "bootstrapping"
HEALTHY = "healthy"
UNHEALTHY = "unhealthy"
TERMINATED = "terminated"
# ---------------------------------------------------------------------------
# Chain network configs — per-chain reference data
# ---------------------------------------------------------------------------
class NetworkConfig(BaseModel):
"""Per-network constants for a specific chain. Read-only reference data."""
chain: str
network_id: int
chain_id: int = 0
http_port: int
staking_port: int = 0
namespace: str
# Registry: chain → network → config
CHAIN_NETWORKS: dict[str, dict[str, NetworkConfig]] = {
"lux": {
"mainnet": NetworkConfig(
chain="lux", network_id=1, chain_id=96369,
http_port=9630, staking_port=9631, namespace="lux-mainnet",
),
"testnet": NetworkConfig(
chain="lux", network_id=2, chain_id=96368,
http_port=9640, staking_port=9641, namespace="lux-testnet",
),
"devnet": NetworkConfig(
chain="lux", network_id=3, chain_id=96370,
http_port=9650, staking_port=9651, namespace="lux-devnet",
),
},
"hanzo": {
"mainnet": NetworkConfig(
chain="hanzo", network_id=1, chain_id=1337,
http_port=8545, namespace="hanzo-mainnet",
),
"testnet": NetworkConfig(
chain="hanzo", network_id=2, chain_id=13370,
http_port=8545, namespace="hanzo-testnet",
),
},
"zoo": {
"mainnet": NetworkConfig(
chain="zoo", network_id=1, chain_id=2001,
http_port=8545, namespace="zoo-mainnet",
),
"testnet": NetworkConfig(
chain="zoo", network_id=2, chain_id=20010,
http_port=8545, namespace="zoo-testnet",
),
},
"pars": {
"mainnet": NetworkConfig(
chain="pars", network_id=1, chain_id=3001,
http_port=9650, staking_port=9651, namespace="pars-mainnet",
),
"testnet": NetworkConfig(
chain="pars", network_id=2, chain_id=30010,
http_port=9650, staking_port=9651, namespace="pars-testnet",
),
},
"avalanche": {
"mainnet": NetworkConfig(
chain="avalanche", network_id=1, chain_id=43114,
http_port=9650, staking_port=9651, namespace="avax-mainnet",
),
"fuji": NetworkConfig(
chain="avalanche", network_id=5, chain_id=43113,
http_port=9650, staking_port=9651, namespace="avax-fuji",
),
},
"ethereum": {
"mainnet": NetworkConfig(
chain="ethereum", network_id=1, chain_id=1,
http_port=8545, namespace="eth-mainnet",
),
"sepolia": NetworkConfig(
chain="ethereum", network_id=11155111, chain_id=11155111,
http_port=8545, namespace="eth-sepolia",
),
"holesky": NetworkConfig(
chain="ethereum", network_id=17000, chain_id=17000,
http_port=8545, namespace="eth-holesky",
),
},
"bitcoin": {
"mainnet": NetworkConfig(
chain="bitcoin", network_id=0, http_port=8332, namespace="btc-mainnet",
),
"testnet": NetworkConfig(
chain="bitcoin", network_id=0, http_port=18332, namespace="btc-testnet",
),
},
"solana": {
"mainnet": NetworkConfig(
chain="solana", network_id=0, http_port=8899, namespace="sol-mainnet",
),
"devnet": NetworkConfig(
chain="solana", network_id=0, http_port=8899, namespace="sol-devnet",
),
},
"filecoin": {
"mainnet": NetworkConfig(
chain="filecoin", network_id=314, chain_id=314,
http_port=1234, namespace="fil-mainnet",
),
"calibration": NetworkConfig(
chain="filecoin", network_id=314159, chain_id=314159,
http_port=1234, namespace="fil-calibration",
),
},
}
def get_network_config(chain: str, network: str) -> NetworkConfig:
"""Look up network config for a chain + network pair."""
chain_nets = CHAIN_NETWORKS.get(chain)
if not chain_nets:
raise ValueError(f"Unknown chain: {chain!r}. Available: {list(CHAIN_NETWORKS.keys())}")
cfg = chain_nets.get(network)
if not cfg:
raise ValueError(
f"Unknown network {network!r} for chain {chain!r}. "
f"Available: {list(chain_nets.keys())}"
)
return cfg
# ---------------------------------------------------------------------------
# Sub-models — shared across all chains
# ---------------------------------------------------------------------------
class ImageConfig(BaseModel):
"""Container image reference."""
repository: str = "ghcr.io/hanzoai/bootnode"
tag: str = "latest"
pull_policy: str = "IfNotPresent"
class ResourceSpec(BaseModel):
"""K8s resource request/limit pair."""
memory: str = "1Gi"
cpu: str = "500m"
class ResourceConfig(BaseModel):
"""K8s resource requests and limits."""
requests: ResourceSpec = Field(default_factory=lambda: ResourceSpec(memory="1Gi", cpu="500m"))
limits: ResourceSpec = Field(default_factory=lambda: ResourceSpec(memory="4Gi", cpu="2"))
class StorageConfig(BaseModel):
"""Persistent storage settings."""
size: str = "100Gi"
storage_class: str = "do-block-storage"
class ServiceConfig(BaseModel):
"""K8s service exposure settings."""
enabled: bool = True
type: str = "LoadBalancer"
annotations: dict[str, str] = Field(default_factory=dict)
class BootstrapConfig(BaseModel):
"""Bootstrap node configuration."""
use_hostnames: bool = True
external_ips: list[str] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# API request/response models
# ---------------------------------------------------------------------------
class FleetCreate(BaseModel):
"""Request body: create a new node fleet.
Used by: POST /fleets or POST /{chain}/fleets
The `chain` field selects the operator driver. Everything in
`chain_config` is chain-specific and opaque to the generic layer.
"""
name: str = Field(
...,
min_length=1,
max_length=63,
pattern=r"^[a-z][a-z0-9-]*$",
description="Fleet name. Must be a valid K8s name.",
)
cluster_id: str = Field(
...,
description="Target K8s cluster ID (from /infra/clusters).",
)
chain: str = Field(
default="lux",
description="Chain type: lux, avalanche, ethereum, bitcoin.",
)
network: str = Field(
default="devnet",
description="Network name: mainnet, testnet, devnet, etc.",
)
replicas: int = Field(default=5, ge=1, le=100)
# Generic overrides
image: Optional[ImageConfig] = None
resources: Optional[ResourceConfig] = None
storage: Optional[StorageConfig] = None
service: Optional[ServiceConfig] = None
bootstrap: Optional[BootstrapConfig] = None
# Chain-specific config (opaque to generic layer, interpreted by driver)
chain_config: dict[str, Any] = Field(default_factory=dict)
@field_validator("chain")
@classmethod
def validate_chain(cls, v: str) -> str:
if v not in CHAIN_NETWORKS:
raise ValueError(f"Unknown chain: {v!r}. Available: {list(CHAIN_NETWORKS.keys())}")
return v
class FleetUpdate(BaseModel):
"""Request body: update an existing fleet.
Only non-None fields are applied as overrides.
"""
replicas: Optional[int] = Field(default=None, ge=1, le=100)
image: Optional[ImageConfig] = None
resources: Optional[ResourceConfig] = None
# Partial chain_config update (merged with existing)
chain_config: Optional[dict[str, Any]] = None
class NodeInfo(BaseModel):
"""Status of a single node pod within a fleet."""
pod_name: str
pod_index: int
node_id: Optional[str] = None
status: NodeStatus = NodeStatus.PENDING
external_ip: Optional[str] = None
healthy: bool = False
bootstrapped: bool = False
peers: Optional[int] = None
# Chain-specific metrics (driver populates these)
extra: dict[str, Any] = Field(default_factory=dict)
class FleetResponse(BaseModel):
"""Response body: full fleet state."""
id: str = Field(description="Fleet ID.")
name: str
cluster_id: str
chain: str
network: str
status: FleetStatus = FleetStatus.PENDING
replicas: int
ready_replicas: int = 0
image: Optional[ImageConfig] = None
namespace: str = ""
rpc_endpoint: Optional[str] = None
p2p_endpoint: Optional[str] = None
nodes: list[NodeInfo] = Field(default_factory=list)
created_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
updated_at: Optional[str] = None
error: Optional[str] = None
class FleetSummary(BaseModel):
"""Lightweight fleet summary for list endpoints."""
id: str
name: str
chain: str
network: str
status: FleetStatus
replicas: int
ready_replicas: int = 0
cluster_id: str
created_at: str
class FleetStats(BaseModel):
"""Aggregate fleet statistics."""
total_fleets: int = 0
total_nodes: int = 0
healthy_nodes: int = 0
fleets_by_chain: dict[str, int] = Field(default_factory=dict)
fleets_by_network: dict[str, int] = Field(default_factory=dict)
fleets_by_status: dict[str, int] = Field(default_factory=dict)
# ---------------------------------------------------------------------------
# CRD constants — must match operator/src/crd.rs
# ---------------------------------------------------------------------------
CRD_GROUP = "bootnode.dev"
CRD_VERSION = "v1alpha1"
CRD_PLURAL = "clusters"
# ---------------------------------------------------------------------------
# CRD translation
# ---------------------------------------------------------------------------
def fleet_create_to_crd(req: FleetCreate) -> dict:
"""Convert a FleetCreate API request into a Cluster CR body.
This is the canonical translation layer. FleetManager calls this;
no other component should build CR bodies directly.
"""
net_cfg = get_network_config(req.chain, req.network)
spec: dict = {
"chain": req.chain,
"network": req.network,
"replicas": req.replicas,
"chainConfig": req.chain_config,
}
if req.image:
spec["image"] = {
"repository": req.image.repository,
"tag": req.image.tag,
"pullPolicy": req.image.pull_policy,
}
if req.resources:
spec["resources"] = {
"cpuRequest": req.resources.requests.cpu,
"cpuLimit": req.resources.limits.cpu,
"memoryRequest": req.resources.requests.memory,
"memoryLimit": req.resources.limits.memory,
}
if req.storage:
spec["storage"] = {
"size": req.storage.size,
"storageClass": req.storage.storage_class,
}
if req.service:
spec["service"] = {
"serviceType": req.service.type,
"perNodeLb": req.service.enabled,
"annotations": req.service.annotations,
}
# Set ports from network config
ports = [{"name": "http", "port": net_cfg.http_port}]
if net_cfg.staking_port:
ports.append({"name": "staking", "port": net_cfg.staking_port})
spec["ports"] = ports
if req.bootstrap:
spec["bootstrap"] = {
"useHostnames": req.bootstrap.use_hostnames,
"externalIps": req.bootstrap.external_ips,
}
cr_name = f"{req.chain}d-{req.network}"
return {
"apiVersion": f"{CRD_GROUP}/{CRD_VERSION}",
"kind": "Cluster",
"metadata": {
"name": cr_name,
"namespace": net_cfg.namespace,
"labels": {
"bootnode.dev/chain": req.chain,
"bootnode.dev/network": req.network,
},
},
"spec": spec,
}
def fleet_update_to_crd_patch(req: FleetUpdate) -> dict:
"""Convert a FleetUpdate into a partial CR spec for JSON merge patch."""
spec: dict = {}
if req.replicas is not None:
spec["replicas"] = req.replicas
if req.image:
spec["image"] = {
"repository": req.image.repository,
"tag": req.image.tag,
"pullPolicy": req.image.pull_policy,
}
if req.resources:
spec["resources"] = {
"cpuRequest": req.resources.requests.cpu,
"cpuLimit": req.resources.limits.cpu,
"memoryRequest": req.resources.requests.memory,
"memoryLimit": req.resources.limits.memory,
}
if req.chain_config:
spec["chainConfig"] = req.chain_config
return {"spec": spec} if spec else {}
def crd_status_to_fleet_response(
cr: dict,
cluster_id: str,
) -> FleetResponse:
"""Convert a Cluster CR (with status) into a FleetResponse."""
spec = cr.get("spec", {})
status = cr.get("status", {})
metadata = cr.get("metadata", {})
phase = status.get("phase", "Pending")
status_map = {
"Pending": FleetStatus.PENDING,
"Creating": FleetStatus.DEPLOYING,
"Bootstrapping": FleetStatus.DEPLOYING,
"Running": FleetStatus.RUNNING,
"Degraded": FleetStatus.DEGRADED,
}
fleet_status = status_map.get(phase, FleetStatus.ERROR)
nodes = []
for i, ns in enumerate(status.get("nodes", [])):
nodes.append(NodeInfo(
pod_name=ns.get("podName", f"node-{i}"),
pod_index=i,
node_id=ns.get("nodeId"),
status=NodeStatus.HEALTHY if ns.get("healthy") else NodeStatus.UNHEALTHY,
external_ip=ns.get("externalIp"),
healthy=ns.get("healthy", False),
bootstrapped=ns.get("bootstrapped", False),
peers=ns.get("connectedPeers"),
extra=ns.get("extra", {}),
))
img_spec = spec.get("image", {})
image = ImageConfig(
repository=img_spec.get("repository", ImageConfig().repository),
tag=img_spec.get("tag", ImageConfig().tag),
pull_policy=img_spec.get("pullPolicy", ImageConfig().pull_policy),
) if img_spec else None
return FleetResponse(
id=f"{cluster_id}:{metadata.get('name', '')}",
name=metadata.get("name", ""),
cluster_id=cluster_id,
chain=spec.get("chain", ""),
network=spec.get("network", ""),
status=fleet_status,
replicas=status.get("totalNodes", spec.get("replicas", 0)),
ready_replicas=status.get("readyNodes", 0),
image=image,
namespace=metadata.get("namespace", ""),
nodes=nodes,
created_at=metadata.get("creationTimestamp", ""),
updated_at=status.get("lastReconciled"),
)
+6 -695
View File
@@ -1,699 +1,10 @@
"""Lux fleet manager -- business logic for validator fleet lifecycle.
"""Lux fleet manager — backward compatibility re-export.
Orchestrates Lux validator fleet deployments via HelmDeployer.
Each fleet operation:
1. Fetches kubeconfig for the target cluster
2. Creates a HelmDeployer pointed at that cluster
3. Runs the Helm operation
4. Probes individual pod health via luxd RPC
All generic fleet management lives in fleet_manager.py.
New code should import FleetManager from fleet_manager directly.
"""
import asyncio
import json
import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from bootnode.core.chains.fleet_manager import FleetManager # noqa: F401
from bootnode.core.chains.lux_models import (
NETWORK_CONFIGS,
LuxFleetCreate,
LuxFleetResponse,
LuxFleetStatus,
LuxFleetSummary,
LuxFleetUpdate,
LuxNetwork,
LuxNodeInfo,
LuxNodeStatus,
fleet_create_to_helm_values,
fleet_update_to_helm_values,
)
from bootnode.core.deploy.helm import HelmDeployer, HelmError
logger = logging.getLogger(__name__)
# Chart path defaults to the devops repo checkout
DEFAULT_CHART_PATH = Path("/opt/charts/lux")
# Network-specific values files
NETWORK_VALUES_FILES: dict[str, str] = {
"mainnet": "values-mainnet.yaml",
"testnet": "values-testnet.yaml",
}
@dataclass
class LuxFleetManager:
"""Manages Lux validator fleet lifecycle via Helm."""
chart_path: Path = DEFAULT_CHART_PATH
def _release_name(self, network: LuxNetwork) -> str:
return f"luxd-{network.value}"
def _namespace(self, network: LuxNetwork) -> str:
return NETWORK_CONFIGS[network].namespace
def _fleet_id(self, name: str, network: LuxNetwork) -> str:
return f"{name}-{network.value}"
async def _get_deployer(self, kubeconfig_data: str) -> HelmDeployer:
"""Create a HelmDeployer from raw kubeconfig YAML.
The returned deployer's kubeconfig temp file is tracked for cleanup.
Call deployer.cleanup_temp_files() when done.
"""
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".kubeconfig", delete=False
)
tmp.write(kubeconfig_data)
tmp.flush()
tmp.close()
kc_path = Path(tmp.name)
deployer = HelmDeployer(
chart_path=self.chart_path,
kubeconfig_path=kc_path,
)
# Track kubeconfig path for cleanup alongside helm values files
if not hasattr(deployer, "_temp_files"):
deployer._temp_files = []
deployer._temp_files.append(kc_path)
return deployer
def _get_k8s_client(self):
"""Get a kubernetes client using in-cluster config."""
try:
from kubernetes import client, config
config.load_incluster_config()
return client.CoreV1Api(), client.AppsV1Api()
except Exception:
logger.warning("Failed to load in-cluster k8s config")
raise
def _get_values_files(self, network: LuxNetwork) -> list[Path]:
"""Get the network-specific values file if one exists."""
filename = NETWORK_VALUES_FILES.get(network.value)
if filename:
full_path = self.chart_path / filename
if full_path.exists():
return [full_path]
return []
# ---- Fleet lifecycle ----
async def create(
self,
kubeconfig: str,
params: LuxFleetCreate,
) -> LuxFleetResponse:
"""Create a new Lux validator fleet."""
deployer = await self._get_deployer(kubeconfig)
try:
release_name = self._release_name(params.network)
namespace = self._namespace(params.network)
values = fleet_create_to_helm_values(params)
values_files = self._get_values_files(params.network)
logger.info(
"Creating Lux fleet: release=%s namespace=%s network=%s replicas=%d",
release_name, namespace, params.network.value, params.replicas,
)
try:
release = await deployer.install(
release_name=release_name,
namespace=namespace,
values=values,
values_files=values_files,
wait=False,
create_namespace=True,
)
status = LuxFleetStatus.DEPLOYING
except HelmError as e:
logger.error("Fleet creation failed: %s", e)
return LuxFleetResponse(
id=self._fleet_id(params.name, params.network),
name=params.name,
cluster_id=params.cluster_id,
network=params.network,
status=LuxFleetStatus.ERROR,
replicas=params.replicas,
namespace=namespace,
error=str(e),
)
return LuxFleetResponse(
id=self._fleet_id(params.name, params.network),
name=params.name,
cluster_id=params.cluster_id,
network=params.network,
status=status,
replicas=params.replicas,
namespace=namespace,
helm_revision=release.revision,
image=params.image if params.image else None,
)
finally:
deployer.cleanup_temp_files()
async def update(
self,
kubeconfig: str,
name: str,
network: LuxNetwork,
cluster_id: str,
params: LuxFleetUpdate,
) -> LuxFleetResponse:
"""Update an existing fleet (helm upgrade)."""
deployer = await self._get_deployer(kubeconfig)
try:
release_name = self._release_name(network)
namespace = self._namespace(network)
values = fleet_update_to_helm_values(params)
logger.info("Updating fleet: release=%s values=%s", release_name, values)
try:
release = await deployer.upgrade(
release_name=release_name,
namespace=namespace,
values=values,
reuse_values=True,
)
except HelmError as e:
logger.error("Fleet update failed: %s", e)
return LuxFleetResponse(
id=self._fleet_id(name, network),
name=name,
cluster_id=cluster_id,
network=network,
status=LuxFleetStatus.ERROR,
replicas=params.replicas or 0,
namespace=namespace,
error=str(e),
)
# Get pod status after upgrade
pods = await deployer.get_pods(namespace, "app=luxd")
ready_count = sum(1 for p in pods if p.ready)
total = len(pods)
return LuxFleetResponse(
id=self._fleet_id(name, network),
name=name,
cluster_id=cluster_id,
network=network,
status=LuxFleetStatus.UPDATING,
replicas=total,
ready_replicas=ready_count,
namespace=namespace,
helm_revision=release.revision,
)
finally:
deployer.cleanup_temp_files()
async def scale(
self,
kubeconfig: str,
name: str,
network: LuxNetwork,
cluster_id: str,
replicas: int,
) -> LuxFleetResponse:
"""Scale a fleet's replica count."""
return await self.update(
kubeconfig=kubeconfig,
name=name,
network=network,
cluster_id=cluster_id,
params=LuxFleetUpdate(replicas=replicas),
)
async def destroy(
self,
kubeconfig: str,
network: LuxNetwork,
) -> bool:
"""Destroy a fleet (helm uninstall). PVCs persist."""
deployer = await self._get_deployer(kubeconfig)
try:
release_name = self._release_name(network)
namespace = self._namespace(network)
return await deployer.uninstall(release_name, namespace)
finally:
deployer.cleanup_temp_files()
async def get_status(
self,
kubeconfig: str,
name: str,
network: LuxNetwork,
cluster_id: str,
) -> LuxFleetResponse:
"""Get full status of a fleet including pods and endpoints."""
deployer = await self._get_deployer(kubeconfig)
try:
release_name = self._release_name(network)
namespace = self._namespace(network)
net_cfg = NETWORK_CONFIGS[network]
# Get Helm release status (optional -- fleet may be kubectl-deployed)
helm_revision = None
try:
release = await deployer.status(release_name, namespace)
helm_revision = release.revision
except HelmError:
pass # Not a Helm-managed fleet, continue with kubectl
# Get pods
pods = await deployer.get_pods(namespace, "app=luxd")
ready_count = sum(1 for p in pods if p.ready)
total = len(pods)
if total == 0 and helm_revision is None:
return LuxFleetResponse(
id=self._fleet_id(name, network),
name=name,
cluster_id=cluster_id,
network=network,
status=LuxFleetStatus.ERROR,
replicas=0,
namespace=namespace,
error="No fleet found (no Helm release or pods)",
)
# Get services for endpoint discovery
services = await deployer.get_services(namespace)
rpc_endpoint = None
staking_endpoint = None
for svc in services:
for ext_ip in svc.get("external_ips", []):
if ext_ip:
for port in svc.get("ports", []):
if port.get("port") == net_cfg.http_port:
rpc_endpoint = f"http://{ext_ip}:{net_cfg.http_port}"
elif port.get("port") == net_cfg.staking_port:
staking_endpoint = f"{ext_ip}:{net_cfg.staking_port}"
# Build node info
nodes = []
for i, pod in enumerate(sorted(pods, key=lambda p: p.name)):
node_status = LuxNodeStatus.HEALTHY if pod.ready else LuxNodeStatus.UNHEALTHY
if pod.status == "Pending":
node_status = LuxNodeStatus.PENDING
elif pod.status == "Init":
node_status = LuxNodeStatus.INIT
nodes.append(LuxNodeInfo(
pod_name=pod.name,
pod_index=i,
status=node_status,
http_port=net_cfg.http_port,
staking_port=net_cfg.staking_port,
))
# Determine fleet status
if total == 0:
fleet_status = LuxFleetStatus.DEPLOYING
elif ready_count == total:
fleet_status = LuxFleetStatus.RUNNING
elif ready_count > 0:
fleet_status = LuxFleetStatus.DEGRADED
else:
fleet_status = LuxFleetStatus.ERROR
return LuxFleetResponse(
id=self._fleet_id(name, network),
name=name,
cluster_id=cluster_id,
network=network,
status=fleet_status,
replicas=total,
ready_replicas=ready_count,
namespace=namespace,
helm_revision=helm_revision,
rpc_endpoint=rpc_endpoint,
staking_endpoint=staking_endpoint,
nodes=nodes,
)
finally:
deployer.cleanup_temp_files()
async def list_fleets(
self,
kubeconfig: str,
cluster_id: str,
) -> list[LuxFleetSummary]:
"""List all Lux fleets on a cluster.
Discovers fleets via Helm releases first, then falls back to
direct StatefulSet discovery for fleets deployed via kubectl.
"""
deployer = await self._get_deployer(kubeconfig)
try:
fleets = []
seen_namespaces: set[str] = set()
# 1. Try Helm releases
try:
releases = await deployer.list_releases(
all_namespaces=True,
filter_pattern="^luxd-",
)
for r in releases:
network_str = r.name.removeprefix("luxd-")
try:
network = LuxNetwork(network_str)
except ValueError:
continue
pods = await deployer.get_pods(r.namespace, "app=luxd")
ready = sum(1 for p in pods if p.ready)
total = len(pods)
seen_namespaces.add(r.namespace)
if total == 0:
status = LuxFleetStatus.DEPLOYING
elif ready == total:
status = LuxFleetStatus.RUNNING
elif ready > 0:
status = LuxFleetStatus.DEGRADED
else:
status = LuxFleetStatus.ERROR
fleets.append(LuxFleetSummary(
id=f"{cluster_id}:{r.name}",
name=r.name,
network=network,
status=status,
replicas=total,
ready_replicas=ready,
cluster_id=cluster_id,
created_at=r.updated or "",
))
except Exception:
logger.debug("No Helm releases found, trying StatefulSet discovery")
# 2. Fall back to StatefulSet discovery for kubectl-deployed fleets
for network, cfg in NETWORK_CONFIGS.items():
if cfg.namespace in seen_namespaces:
continue
try:
sts_list = await deployer.get_statefulsets(namespace=cfg.namespace)
for sts in sts_list:
if sts["name"] != "luxd":
continue
total = sts["replicas"]
ready = sts["ready_replicas"]
if total == 0:
status = LuxFleetStatus.DEPLOYING
elif ready == total:
status = LuxFleetStatus.RUNNING
elif ready > 0:
status = LuxFleetStatus.DEGRADED
else:
status = LuxFleetStatus.ERROR
fleets.append(LuxFleetSummary(
id=f"{cluster_id}:luxd-{network.value}",
name=f"luxd-{network.value}",
network=network,
status=status,
replicas=total,
ready_replicas=ready,
cluster_id=cluster_id,
created_at=sts.get("created_at", ""),
))
except Exception:
continue
return fleets
finally:
deployer.cleanup_temp_files()
async def list_fleets_incluster(
self,
cluster_id: str,
) -> list[LuxFleetSummary]:
"""List all Lux fleets using in-cluster k8s access.
Discovers fleets via StatefulSet discovery in known namespaces.
"""
core_v1, apps_v1 = self._get_k8s_client()
fleets = []
for network, cfg in NETWORK_CONFIGS.items():
try:
sts_list = apps_v1.list_namespaced_stateful_set(
namespace=cfg.namespace,
label_selector="app=luxd",
)
for sts in sts_list.items:
total = sts.spec.replicas or 0
ready = sts.status.ready_replicas or 0
if total == 0:
fleet_status = LuxFleetStatus.DEPLOYING
elif ready == total:
fleet_status = LuxFleetStatus.RUNNING
elif ready > 0:
fleet_status = LuxFleetStatus.DEGRADED
else:
fleet_status = LuxFleetStatus.ERROR
created = ""
if sts.metadata.creation_timestamp:
created = sts.metadata.creation_timestamp.isoformat()
fleets.append(LuxFleetSummary(
id=f"{cluster_id}:luxd-{network.value}",
name=f"luxd-{network.value}",
network=network,
status=fleet_status,
replicas=total,
ready_replicas=ready,
cluster_id=cluster_id,
created_at=created,
))
# If no statefulset found, check for pods directly
if not sts_list.items:
pods = core_v1.list_namespaced_pod(
namespace=cfg.namespace,
label_selector="app=luxd",
)
if pods.items:
total = len(pods.items)
ready = sum(
1 for p in pods.items
if p.status.phase == "Running"
and p.status.container_statuses
and all(c.ready for c in p.status.container_statuses)
)
fleet_status = LuxFleetStatus.RUNNING if ready == total else (
LuxFleetStatus.DEGRADED if ready > 0 else LuxFleetStatus.ERROR
)
fleets.append(LuxFleetSummary(
id=f"{cluster_id}:luxd-{network.value}",
name=f"luxd-{network.value}",
network=network,
status=fleet_status,
replicas=total,
ready_replicas=ready,
cluster_id=cluster_id,
created_at="",
))
except Exception as e:
logger.warning("Failed to list fleet in %s: %s", cfg.namespace, e)
continue
return fleets
async def get_status_incluster(
self,
name: str,
network: LuxNetwork,
cluster_id: str,
) -> LuxFleetResponse:
"""Get full fleet status using in-cluster k8s access."""
core_v1, apps_v1 = self._get_k8s_client()
namespace = self._namespace(network)
net_cfg = NETWORK_CONFIGS[network]
# Get pods
pod_list = core_v1.list_namespaced_pod(
namespace=namespace,
label_selector="app=luxd",
)
total = len(pod_list.items)
if total == 0:
return LuxFleetResponse(
id=self._fleet_id(name, network),
name=name,
cluster_id=cluster_id,
network=network,
status=LuxFleetStatus.ERROR,
replicas=0,
namespace=namespace,
error="No pods found",
)
ready_count = 0
for p in pod_list.items:
if (p.status.phase == "Running"
and p.status.container_statuses
and all(c.ready for c in p.status.container_statuses)):
ready_count += 1
# Get services for endpoint discovery
svc_list = core_v1.list_namespaced_service(namespace=namespace)
rpc_endpoint = None
staking_endpoint = None
for svc in svc_list.items:
if svc.status.load_balancer and svc.status.load_balancer.ingress:
for ing in svc.status.load_balancer.ingress:
ext_ip = ing.ip
if ext_ip and svc.spec.ports:
for port in svc.spec.ports:
if port.port == net_cfg.http_port:
rpc_endpoint = f"http://{ext_ip}:{net_cfg.http_port}"
elif port.port == net_cfg.staking_port:
staking_endpoint = f"{ext_ip}:{net_cfg.staking_port}"
# Build node info
nodes = []
sorted_pods = sorted(pod_list.items, key=lambda p: p.metadata.name)
for i, pod in enumerate(sorted_pods):
pod_ready = (
pod.status.phase == "Running"
and pod.status.container_statuses
and all(c.ready for c in pod.status.container_statuses)
)
if pod_ready:
node_status = LuxNodeStatus.HEALTHY
elif pod.status.phase == "Pending":
node_status = LuxNodeStatus.PENDING
else:
node_status = LuxNodeStatus.UNHEALTHY
nodes.append(LuxNodeInfo(
pod_name=pod.metadata.name,
pod_index=i,
status=node_status,
http_port=net_cfg.http_port,
staking_port=net_cfg.staking_port,
))
# Determine fleet status
if ready_count == total:
fleet_status = LuxFleetStatus.RUNNING
elif ready_count > 0:
fleet_status = LuxFleetStatus.DEGRADED
else:
fleet_status = LuxFleetStatus.ERROR
return LuxFleetResponse(
id=self._fleet_id(name, network),
name=name,
cluster_id=cluster_id,
network=network,
status=fleet_status,
replicas=total,
ready_replicas=ready_count,
namespace=namespace,
rpc_endpoint=rpc_endpoint,
staking_endpoint=staking_endpoint,
nodes=nodes,
)
async def get_pod_logs(
self,
kubeconfig: str,
network: LuxNetwork,
pod_name: str,
tail: int = 100,
) -> str:
"""Get logs from a specific pod."""
deployer = await self._get_deployer(kubeconfig)
try:
namespace = self._namespace(network)
return await deployer.get_pod_logs(pod_name, namespace, tail=tail)
finally:
deployer.cleanup_temp_files()
async def rolling_restart(
self,
kubeconfig: str,
network: LuxNetwork,
) -> list[str]:
"""Rolling restart: delete pods one at a time (OnDelete strategy)."""
deployer = await self._get_deployer(kubeconfig)
try:
namespace = self._namespace(network)
pods = await deployer.get_pods(namespace, "app=luxd")
restarted = []
for pod in sorted(pods, key=lambda p: p.name):
logger.info("Rolling restart: deleting %s", pod.name)
await deployer.delete_pod(pod.name, namespace)
restarted.append(pod.name)
# Wait for pod to come back ready (up to 5 min)
for _ in range(60):
await asyncio.sleep(5)
new_pods = await deployer.get_pods(namespace, "app=luxd")
replaced = next((p for p in new_pods if p.name == pod.name), None)
if replaced and replaced.ready:
break
return restarted
finally:
deployer.cleanup_temp_files()
async def probe_node_health(
self,
kubeconfig: str,
network: LuxNetwork,
pod_name: str,
) -> dict:
"""Probe a luxd node's health via kubectl exec + wget."""
deployer = await self._get_deployer(kubeconfig)
try:
namespace = self._namespace(network)
net_cfg = NETWORK_CONFIGS[network]
port = net_cfg.http_port
results: dict = {}
# Health check
try:
health = await deployer.exec_pod(
pod_name, namespace,
["wget", "-qO-", f"http://localhost:{port}/ext/health"],
)
results["health"] = json.loads(health)
results["healthy"] = results["health"].get("healthy", False)
except Exception:
results["healthy"] = False
# Block height
try:
rpc_resp = await deployer.exec_pod(
pod_name, namespace,
[
"wget", "-qO-",
"--header=Content-Type: application/json",
f"--post-data={{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"params\":[],\"id\":1}}",
f"http://localhost:{port}/ext/bc/C/rpc",
],
)
data = json.loads(rpc_resp)
results["c_chain_height"] = int(data.get("result", "0x0"), 16)
except Exception:
results["c_chain_height"] = None
return results
finally:
deployer.cleanup_temp_files()
# Backward compatibility alias
LuxFleetManager = FleetManager
+51 -432
View File
@@ -1,444 +1,63 @@
"""Shared Pydantic models for Lux fleet management.
"""Lux-specific fleet models — backward compatibility re-exports.
This module is the single source of truth for data structures shared between:
- HelmDeployer (core/deploy/helm.py)
- Lux Fleet API (api/lux/__init__.py)
- Lux Chain Manager (core/chains/lux.py)
- Dashboard (web/app/dashboard/infrastructure/lux/)
All four components import from here. No model duplication.
The models map 1:1 to the Helm chart values in lux/devops/charts/lux/values.yaml
so that API request bodies can be translated directly to helm --set flags or
values override files without lossy conversion.
All generic fleet models live in fleet_models.py. This module provides
Lux-prefixed aliases and Lux-specific network config for backward compat.
New code should import from fleet_models directly.
"""
from datetime import datetime
# Re-export generic models with Lux-prefixed aliases
from bootnode.core.chains.fleet_models import ( # noqa: F401
CHAIN_NETWORKS,
CRD_GROUP,
CRD_PLURAL,
CRD_VERSION,
BootstrapConfig,
FleetCreate,
FleetResponse,
FleetStats,
FleetStatus,
FleetSummary,
FleetUpdate,
ImageConfig,
NetworkConfig,
NodeInfo,
NodeStatus,
ResourceConfig,
ResourceSpec,
ServiceConfig,
StorageConfig,
crd_status_to_fleet_response,
fleet_create_to_crd,
fleet_update_to_crd_patch,
get_network_config,
)
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, field_validator
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
# Lux-specific aliases
class LuxNetwork(str, Enum):
"""Lux network identifier. Maps to values.network in the Helm chart."""
MAINNET = "mainnet"
TESTNET = "testnet"
DEVNET = "devnet"
class LuxFleetStatus(str, Enum):
"""Lifecycle state of a fleet (Helm release)."""
PENDING = "pending" # Request accepted, not yet applied
DEPLOYING = "deploying" # helm install/upgrade in progress
RUNNING = "running" # All pods healthy
DEGRADED = "degraded" # Some pods not ready
UPDATING = "updating" # Rolling update in progress
ERROR = "error" # Helm or K8s error
DESTROYING = "destroying" # helm uninstall in progress
DESTROYED = "destroyed" # Release removed
class LuxNodeStatus(str, Enum):
"""Status of an individual luxd pod within a fleet."""
PENDING = "pending"
INIT = "init" # Init container running
STARTING = "starting" # luxd process starting
BOOTSTRAPPING = "bootstrapping" # RLP import / chain sync
HEALTHY = "healthy" # Health endpoint responding
UNHEALTHY = "unhealthy" # Failed liveness/readiness
TERMINATED = "terminated"
# ---------------------------------------------------------------------------
# Helm chart value sub-models
# These mirror the YAML structure in charts/lux/values.yaml exactly.
# ---------------------------------------------------------------------------
class LuxNetworkConfig(BaseModel):
"""Per-network constants. Read-only reference data, not user input."""
network_id: int
chain_id: int
http_port: int
staking_port: int
namespace: str
# Canonical network parameters. Must match values.yaml .networks.*
NETWORK_CONFIGS: dict[LuxNetwork, LuxNetworkConfig] = {
LuxNetwork.MAINNET: LuxNetworkConfig(
network_id=1,
chain_id=96369,
http_port=9630,
staking_port=9631,
namespace="lux-mainnet",
),
LuxNetwork.TESTNET: LuxNetworkConfig(
network_id=2,
chain_id=96368,
http_port=9640,
staking_port=9641,
namespace="lux-testnet",
),
LuxNetwork.DEVNET: LuxNetworkConfig(
network_id=3,
chain_id=96370,
http_port=9650,
staking_port=9651,
namespace="lux-devnet",
),
}
class RLPImportConfig(BaseModel):
"""C-chain RLP block import settings. Maps to values.bootstrap.rlpImport."""
enabled: bool = False
base_url: str = ""
rlp_filename: str = ""
multi_part: bool = False
parts: list[str] = Field(default_factory=list)
min_height: int = 1
timeout: int = 7200
class BootstrapConfig(BaseModel):
"""Bootstrap node configuration. Maps to values.bootstrap."""
node_ids: list[str] = Field(default_factory=list)
use_hostnames: bool = True
external_ips: list[str] = Field(default_factory=list)
rlp_import: RLPImportConfig = Field(default_factory=RLPImportConfig)
@field_validator("node_ids")
@classmethod
def validate_node_ids(cls, v: list[str]) -> list[str]:
for nid in v:
if not nid.startswith("NodeID-"):
raise ValueError(f"Invalid node ID format: {nid!r} (must start with 'NodeID-')")
return v
class ConsensusConfig(BaseModel):
"""Consensus tuning. Maps to values.consensus."""
sample_size: int = 5
quorum_size: int = 4
sybil_protection_enabled: bool = False
require_validator_to_connect: bool = False
allow_private_ips: bool = True
class ChainTrackingConfig(BaseModel):
"""Subnet/chain tracking. Maps to values.chainTracking."""
track_all_chains: bool = True
tracked_chains: list[str] = Field(default_factory=list)
aliases: list[str] = Field(
default_factory=lambda: ["zoo", "hanzo", "spc", "pars"],
)
class ImageConfig(BaseModel):
"""Container image reference. Maps to values.image."""
repository: str = "registry.digitalocean.com/hanzo/bootnode"
tag: str = "luxd-v1.23.11"
pull_policy: str = "Always"
class ResourceSpec(BaseModel):
"""K8s resource request/limit pair."""
memory: str = "1Gi"
cpu: str = "500m"
class ResourceConfig(BaseModel):
"""Maps to values.resources."""
requests: ResourceSpec = Field(default_factory=lambda: ResourceSpec(memory="1Gi", cpu="500m"))
limits: ResourceSpec = Field(default_factory=lambda: ResourceSpec(memory="4Gi", cpu="2"))
class StorageConfig(BaseModel):
"""Maps to values.storage."""
size: str = "100Gi"
storage_class: str = "do-block-storage"
class NodeServicesConfig(BaseModel):
"""Maps to values.nodeServices."""
enabled: bool = True
type: str = "LoadBalancer"
annotations: dict[str, str] = Field(default_factory=dict)
class APIConfig(BaseModel):
"""Maps to values.api."""
admin_enabled: bool = True
metrics_enabled: bool = True
index_enabled: bool = True
http_allowed_hosts: str = "*"
# ---------------------------------------------------------------------------
# API request/response models
# ---------------------------------------------------------------------------
class LuxFleetCreate(BaseModel):
"""Request body: create a new Lux fleet (Helm release).
Used by: POST /lux/fleets
Consumed by: HelmDeployer, LuxChainManager
"""
name: str = Field(
...,
min_length=1,
max_length=63,
pattern=r"^[a-z][a-z0-9-]*$",
description="Helm release name. Must be a valid K8s name.",
)
cluster_id: str = Field(
...,
description="Target K8s cluster ID (from /infra/clusters).",
)
network: LuxNetwork = LuxNetwork.DEVNET
replicas: int = Field(default=5, ge=1, le=20)
# Optional overrides -- None means use chart defaults
image: Optional[ImageConfig] = None
bootstrap: Optional[BootstrapConfig] = None
consensus: Optional[ConsensusConfig] = None
chain_tracking: Optional[ChainTrackingConfig] = None
resources: Optional[ResourceConfig] = None
storage: Optional[StorageConfig] = None
node_services: Optional[NodeServicesConfig] = None
api: Optional[APIConfig] = None
log_level: str = "info"
db_type: str = "badgerdb"
@field_validator("replicas")
@classmethod
def quorum_safety(cls, v: int) -> int:
"""Warn-level: consensus requires >= 4 for BFT quorum of 5."""
# Validation only; warning handled at the API layer.
return v
class LuxFleetUpdate(BaseModel):
"""Request body: update (helm upgrade) an existing fleet.
Used by: PATCH /lux/fleets/{fleet_id}
Only non-None fields are applied as overrides.
"""
replicas: Optional[int] = Field(default=None, ge=1, le=20)
image: Optional[ImageConfig] = None
consensus: Optional[ConsensusConfig] = None
chain_tracking: Optional[ChainTrackingConfig] = None
resources: Optional[ResourceConfig] = None
log_level: Optional[str] = None
class LuxNodeInfo(BaseModel):
"""Status of a single luxd pod within a fleet.
Populated by LuxChainManager from K8s pod status + RPC probes.
"""
pod_name: str
pod_index: int
node_id: Optional[str] = None
status: LuxNodeStatus = LuxNodeStatus.PENDING
external_ip: Optional[str] = None
http_port: int = 9650
staking_port: int = 9651
# RPC-derived metrics (populated lazily)
is_bootstrapped: Optional[bool] = None
peers: Optional[int] = None
c_chain_height: Optional[int] = None
p_chain_height: Optional[int] = None
uptime_pct: Optional[float] = None
# K8s resource usage
cpu_usage: Optional[float] = None
memory_usage_mb: Optional[float] = None
class LuxFleetResponse(BaseModel):
"""Response body: full fleet state.
Used by: GET /lux/fleets/{fleet_id}, POST /lux/fleets
"""
id: str = Field(description="Fleet ID (= Helm release name + network).")
name: str
cluster_id: str
network: LuxNetwork
status: LuxFleetStatus = LuxFleetStatus.PENDING
replicas: int
ready_replicas: int = 0
image: ImageConfig = Field(default_factory=ImageConfig)
namespace: str = ""
helm_revision: Optional[int] = None
rpc_endpoint: Optional[str] = None
staking_endpoint: Optional[str] = None
nodes: list[LuxNodeInfo] = Field(default_factory=list)
created_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
updated_at: Optional[str] = None
error: Optional[str] = None
class LuxFleetSummary(BaseModel):
"""Lightweight fleet summary for list endpoints.
Used by: GET /lux/fleets
"""
id: str
name: str
network: LuxNetwork
status: LuxFleetStatus
replicas: int
ready_replicas: int = 0
cluster_id: str
created_at: str
class LuxFleetStats(BaseModel):
"""Aggregate fleet statistics.
Used by: GET /lux/stats
"""
total_fleets: int = 0
total_nodes: int = 0
healthy_nodes: int = 0
fleets_by_network: dict[str, int] = Field(default_factory=dict)
fleets_by_status: dict[str, int] = Field(default_factory=dict)
# ---------------------------------------------------------------------------
# Helm values translation
# ---------------------------------------------------------------------------
def fleet_create_to_helm_values(req: LuxFleetCreate) -> dict:
"""Convert a LuxFleetCreate request into a flat dict suitable for
helm install --set or a values override YAML file.
This is the canonical translation layer. HelmDeployer calls this;
no other component should build Helm values dicts directly.
"""
vals: dict = {
"network": req.network.value,
"replicas": req.replicas,
"logLevel": req.log_level,
"dbType": req.db_type,
}
if req.image:
vals["image.repository"] = req.image.repository
vals["image.tag"] = req.image.tag
vals["image.pullPolicy"] = req.image.pull_policy
if req.bootstrap:
b = req.bootstrap
vals["bootstrap.useHostnames"] = b.use_hostnames
if b.node_ids:
# Helm list: pass as JSON or comma-separated via --set-json
vals["bootstrap.nodeIDs"] = b.node_ids
if b.external_ips:
vals["bootstrap.externalIPs"] = b.external_ips
r = b.rlp_import
vals["bootstrap.rlpImport.enabled"] = r.enabled
vals["bootstrap.rlpImport.baseUrl"] = r.base_url
vals["bootstrap.rlpImport.rlpFilename"] = r.rlp_filename
vals["bootstrap.rlpImport.multiPart"] = r.multi_part
if r.parts:
vals["bootstrap.rlpImport.parts"] = r.parts
vals["bootstrap.rlpImport.minHeight"] = r.min_height
vals["bootstrap.rlpImport.timeout"] = r.timeout
if req.consensus:
c = req.consensus
vals["consensus.sampleSize"] = c.sample_size
vals["consensus.quorumSize"] = c.quorum_size
vals["consensus.sybilProtectionEnabled"] = c.sybil_protection_enabled
vals["consensus.requireValidatorToConnect"] = c.require_validator_to_connect
vals["consensus.allowPrivateIPs"] = c.allow_private_ips
if req.chain_tracking:
ct = req.chain_tracking
vals["chainTracking.trackAllChains"] = ct.track_all_chains
if ct.tracked_chains:
vals["chainTracking.trackedChains"] = ct.tracked_chains
if ct.aliases:
vals["chainTracking.aliases"] = ct.aliases
if req.resources:
vals["resources.requests.memory"] = req.resources.requests.memory
vals["resources.requests.cpu"] = req.resources.requests.cpu
vals["resources.limits.memory"] = req.resources.limits.memory
vals["resources.limits.cpu"] = req.resources.limits.cpu
if req.storage:
vals["storage.size"] = req.storage.size
vals["storage.storageClass"] = req.storage.storage_class
if req.node_services:
vals["nodeServices.enabled"] = req.node_services.enabled
vals["nodeServices.type"] = req.node_services.type
if req.api:
vals["api.adminEnabled"] = req.api.admin_enabled
vals["api.metricsEnabled"] = req.api.metrics_enabled
vals["api.indexEnabled"] = req.api.index_enabled
vals["api.httpAllowedHosts"] = req.api.http_allowed_hosts
return vals
def fleet_update_to_helm_values(req: LuxFleetUpdate) -> dict:
"""Convert a LuxFleetUpdate into a partial Helm values dict.
Only non-None fields are included.
"""
vals: dict = {}
if req.replicas is not None:
vals["replicas"] = req.replicas
if req.log_level is not None:
vals["logLevel"] = req.log_level
if req.image:
vals["image.repository"] = req.image.repository
vals["image.tag"] = req.image.tag
vals["image.pullPolicy"] = req.image.pull_policy
if req.consensus:
c = req.consensus
vals["consensus.sampleSize"] = c.sample_size
vals["consensus.quorumSize"] = c.quorum_size
vals["consensus.sybilProtectionEnabled"] = c.sybil_protection_enabled
if req.chain_tracking:
ct = req.chain_tracking
vals["chainTracking.trackAllChains"] = ct.track_all_chains
if ct.tracked_chains:
vals["chainTracking.trackedChains"] = ct.tracked_chains
if req.resources:
vals["resources.requests.memory"] = req.resources.requests.memory
vals["resources.requests.cpu"] = req.resources.requests.cpu
vals["resources.limits.memory"] = req.resources.limits.memory
vals["resources.limits.cpu"] = req.resources.limits.cpu
return vals
# Convenience: Lux network configs (subset of CHAIN_NETWORKS["lux"])
NETWORK_CONFIGS = CHAIN_NETWORKS["lux"]
# Type aliases for backward compatibility
LuxFleetStatus = FleetStatus
LuxNodeStatus = NodeStatus
LuxFleetCreate = FleetCreate
LuxFleetUpdate = FleetUpdate
LuxFleetResponse = FleetResponse
LuxFleetSummary = FleetSummary
LuxFleetStats = FleetStats
LuxNodeInfo = NodeInfo
LuxNetworkConfig = NetworkConfig
# Deprecated Lux-specific sub-models (kept as pass-through)
RLPImportConfig = None # Now part of chain_config dict
ConsensusConfig = None # Now part of chain_config dict
ChainTrackingConfig = None # Now part of chain_config dict
NodeServicesConfig = ServiceConfig
APIConfig = None # Now part of chain_config dict
+1 -1
View File
@@ -1,4 +1,4 @@
"""DataStore (ClickHouse) client for high-performance analytics."""
"""DataStore client for high-performance analytics."""
from .client import DataStoreClient, datastore_client
from .queries import (
+3 -3
View File
@@ -1,4 +1,4 @@
"""DataStore (ClickHouse) async client."""
"""DataStore async client — OLAP analytics backend."""
from typing import Any
from urllib.parse import urlparse
@@ -14,7 +14,7 @@ settings = get_settings()
class DataStoreClient:
"""Async ClickHouse client for high-performance analytics queries."""
"""Async DataStore client for high-performance analytics queries."""
def __init__(self) -> None:
self._client: ChClient | None = None
@@ -24,7 +24,7 @@ class DataStoreClient:
self._initialized = False
async def initialize(self) -> None:
"""Initialize the ClickHouse connection."""
"""Initialize the DataStore connection."""
if self._initialized:
return
+35 -1
View File
@@ -8,14 +8,48 @@ from pydantic import BaseModel
class ServiceType(str, Enum):
"""Bootnode service types."""
"""Bootnode service types.
Core platform services plus all blockchain infrastructure services
deployable per-network per-org.
"""
# Core platform
API = "api"
WEB = "web"
INDEXER = "indexer"
WEBHOOK_WORKER = "webhook-worker"
BUNDLER = "bundler"
# Block explorer
EXPLORER = "explorer"
# The Graph
GRAPH_NODE = "graph-node"
GRAPH_POSTGRES = "graph-postgres"
GRAPH_IPFS = "graph-ipfs"
# DeFi / exchange
BRIDGE = "bridge"
EXCHANGE = "exchange"
MARKET = "market"
STAKING = "staking"
# Security / multisig
MPC = "mpc"
MPC_POSTGRES = "mpc-postgres"
SAFE = "safe"
SAFE_TRANSACTION = "safe-transaction"
SAFE_CONFIG = "safe-config"
# Networking
GATEWAY = "gateway"
RPC_PROXY = "rpc-proxy"
VALIDATOR = "validator"
# Tools
FAUCET = "faucet"
class ServiceStatus(BaseModel):
"""Status of a deployed service."""
+274
View File
@@ -0,0 +1,274 @@
"""Hanzo O11y — OpenTelemetry instrumentation for Bootnode API.
Full observability stack:
Traces → OTEL Collector → Datastore (otel DB)
Metrics → OTEL Collector → Datastore + Prometheus
Logs → OTEL Collector → Datastore + Loki
OTEL Collector endpoint: otel-collector.hanzo.svc:4317 (gRPC)
Prometheus /metrics endpoint served by this module for ServiceMonitor scraping.
"""
from __future__ import annotations
import os
import time
from typing import Any
import structlog
from fastapi import FastAPI, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
logger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Prometheus metrics (lightweight, no external dep required)
# ---------------------------------------------------------------------------
_request_count: dict[str, int] = {}
_request_errors: dict[str, int] = {}
_request_latency_sum: dict[str, float] = {}
_request_latency_count: dict[str, int] = {}
_request_latency_buckets: dict[str, dict[float, int]] = {}
LATENCY_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
def _bucket_key(method: str, path: str, status: int) -> str:
return f"{method}|{path}|{status}"
class MetricsMiddleware(BaseHTTPMiddleware):
"""Collect request metrics for /metrics endpoint."""
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
# Skip metrics endpoint itself
if request.url.path == "/metrics":
return await call_next(request)
method = request.method
# Normalize path to reduce cardinality (strip IDs)
path = _normalize_path(request.url.path)
start = time.perf_counter()
try:
response = await call_next(request)
status = response.status_code
except Exception:
status = 500
raise
finally:
duration = time.perf_counter() - start
key = _bucket_key(method, path, status)
_request_count[key] = _request_count.get(key, 0) + 1
_request_latency_sum[key] = _request_latency_sum.get(key, 0.0) + duration
_request_latency_count[key] = _request_latency_count.get(key, 0) + 1
if status >= 500:
err_key = f"{method}|{path}"
_request_errors[err_key] = _request_errors.get(err_key, 0) + 1
# Histogram buckets
if key not in _request_latency_buckets:
_request_latency_buckets[key] = {b: 0 for b in LATENCY_BUCKETS}
for bucket in LATENCY_BUCKETS:
if duration <= bucket:
_request_latency_buckets[key][bucket] += 1
return response
def _normalize_path(path: str) -> str:
"""Reduce path cardinality by replacing UUIDs and hex strings with {id}."""
import re
# UUID pattern
path = re.sub(
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
"{id}",
path,
)
# Hex hashes (32+ chars)
path = re.sub(r"0x[0-9a-fA-F]{40,}", "{hash}", path)
# Numeric IDs
path = re.sub(r"/\d+(/|$)", "/{id}\\1", path)
return path
def render_metrics() -> str:
"""Render Prometheus text exposition format."""
lines: list[str] = []
# http_server_requests_total
lines.append("# HELP http_server_requests_total Total HTTP requests")
lines.append("# TYPE http_server_requests_total counter")
for key, count in sorted(_request_count.items()):
method, path, status = key.split("|")
lines.append(
f'http_server_requests_total{{method="{method}",path="{path}",status="{status}"}} {count}'
)
# http_server_request_duration_seconds (histogram)
lines.append("")
lines.append("# HELP http_server_request_duration_seconds Request latency histogram")
lines.append("# TYPE http_server_request_duration_seconds histogram")
for key in sorted(_request_latency_buckets):
method, path, status = key.split("|")
labels = f'method="{method}",path="{path}",status="{status}"'
buckets = _request_latency_buckets[key]
cumulative = 0
for bucket_le in LATENCY_BUCKETS:
cumulative += buckets.get(bucket_le, 0)
lines.append(
f'http_server_request_duration_seconds_bucket{{{labels},le="{bucket_le}"}} {cumulative}'
)
total_count = _request_latency_count.get(key, 0)
lines.append(
f'http_server_request_duration_seconds_bucket{{{labels},le="+Inf"}} {total_count}'
)
lines.append(
f"http_server_request_duration_seconds_sum{{{labels}}} {_request_latency_sum.get(key, 0.0):.6f}"
)
lines.append(
f"http_server_request_duration_seconds_count{{{labels}}} {total_count}"
)
# http_server_errors_total
lines.append("")
lines.append("# HELP http_server_errors_total Total 5xx errors")
lines.append("# TYPE http_server_errors_total counter")
for key, count in sorted(_request_errors.items()):
method, path = key.split("|")
lines.append(
f'http_server_errors_total{{method="{method}",path="{path}"}} {count}'
)
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# OTEL SDK setup
# ---------------------------------------------------------------------------
def setup_otel(app: FastAPI) -> None:
"""Initialize full OTEL stack: traces + metrics + logs.
Sends to OTEL Collector at otel-collector.hanzo.svc:4317.
Gracefully degrades if OTEL packages not installed or endpoint not set.
"""
endpoint = os.getenv(
"OTEL_EXPORTER_OTLP_ENDPOINT",
"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")
# Always add metrics middleware (no external deps)
app.add_middleware(MetricsMiddleware)
# Add /metrics endpoint
@app.get("/metrics", include_in_schema=False)
async def metrics_endpoint() -> Response:
return Response(
content=render_metrics(),
media_type="text/plain; version=0.0.4; charset=utf-8",
)
# OTEL SDK (traces, metrics export to collector)
if not os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") and not os.getenv("OTEL_ENABLED"):
logger.info("OTEL tracing disabled (no OTEL_EXPORTER_OTLP_ENDPOINT)")
return
try:
from opentelemetry import trace, metrics as otel_metrics
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter,
)
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
OTLPMetricExporter,
)
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
resource = Resource.create({
"service.name": service_name,
"service.version": service_version,
"service.namespace": "bootnode",
"deployment.environment": os.getenv("APP_ENV", "production"),
})
# Traces
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint, insecure=True))
)
trace.set_tracer_provider(tracer_provider)
# Metrics (OTEL metrics → collector → datastore)
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=endpoint, insecure=True),
export_interval_millis=30000,
)
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
otel_metrics.set_meter_provider(meter_provider)
# Auto-instrument FastAPI
FastAPIInstrumentor.instrument_app(app)
logger.info(
"hanzo11y: OTEL tracing + metrics enabled",
endpoint=endpoint,
service=service_name,
)
except ImportError as e:
logger.info("OTEL SDK not installed, traces disabled", missing=str(e))
except Exception as e:
logger.warning("OTEL init failed", error=str(e))
def setup_otel_logging() -> None:
"""Configure OTEL log export (structured logs → collector → datastore + loki).
Call this early, before any logging happens.
"""
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
if not endpoint:
return
try:
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
OTLPLogExporter,
)
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.resources import Resource
resource = Resource.create({
"service.name": os.getenv("OTEL_SERVICE_NAME", "bootnode-api"),
})
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(OTLPLogExporter(endpoint=endpoint, insecure=True))
)
set_logger_provider(logger_provider)
# Attach OTEL handler to Python root logger
import logging
handler = LoggingHandler(level=logging.INFO, logger_provider=logger_provider)
logging.getLogger().addHandler(handler)
except ImportError:
pass
except Exception as e:
structlog.get_logger().warning("OTEL log export init failed", error=str(e))
+43 -1
View File
@@ -50,7 +50,11 @@ class User(Base):
class Project(Base):
"""Project/Organization model."""
"""Project/Organization model.
Each project belongs to an IAM org (hanzo, lux, zoo, pars, etc.)
and has access to specific clusters.
"""
__tablename__ = "projects"
@@ -59,8 +63,10 @@ class Project(Base):
)
name: Mapped[str] = mapped_column(String(255), 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)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
@@ -72,6 +78,7 @@ class Project(Base):
api_keys: Mapped[list["ApiKey"]] = relationship(back_populates="project")
webhooks: Mapped[list["Webhook"]] = relationship(back_populates="project")
subscription: Mapped["Subscription | None"] = relationship(back_populates="project", uselist=False)
clusters: Mapped[list["OrgCluster"]] = relationship(back_populates="project")
class ApiKey(Base):
@@ -302,3 +309,38 @@ class Subscription(Base):
# Relationships
project: Mapped["Project"] = relationship(back_populates="subscription")
class OrgCluster(Base):
"""Org-to-cluster mapping. Controls which clusters each org/project can manage.
When a project has OrgCluster entries, fleet operations are scoped to those
clusters only. When no entries exist, the project has no cluster access
(except admin projects which bypass this check).
"""
__tablename__ = "org_clusters"
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
)
cluster_id: Mapped[str] = mapped_column(
String(255), nullable=False,
)
cluster_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
provider: Mapped[str] = mapped_column(
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)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# Relationships
project: Mapped["Project"] = relationship(back_populates="clusters")
+20 -2
View File
@@ -20,6 +20,17 @@ from bootnode.zap import start_zap_server, stop_zap_server
# Load secrets from Hanzo KMS before settings
inject_secrets()
# ---------------------------------------------------------------------------
# Hanzo O11y — Traces + Metrics + Logs
# All telemetry → OTEL Collector → Datastore + Loki
# Prometheus /metrics served natively for ServiceMonitor scraping
# ---------------------------------------------------------------------------
from bootnode.core.o11y import setup_otel, setup_otel_logging
# Wire OTEL log export early (before any logging)
setup_otel_logging()
logger = structlog.get_logger()
settings = get_settings()
@@ -32,7 +43,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
await init_db()
await redis_client.initialize()
# Initialize DataStore (ClickHouse) - optional, won't fail startup
# Initialize DataStore - optional, won't fail startup
try:
await datastore_client.initialize()
logger.info("DataStore connected")
@@ -83,14 +94,21 @@ app = FastAPI(
lifespan=lifespan,
)
# Hanzo O11y — /metrics + OTEL traces/metrics/logs
setup_otel(app)
# Validate production secrets (fail-fast)
settings.validate_production()
# CORS - all cloud domains for white-label multi-network support
production_origins = [
"https://bootno.de",
"https://lux.build",
"https://cloud.lux.network",
"https://cloud.pars.network",
"https://cloud.zoo.network",
"https://cloud.hanzo.network",
"https://cloud.hanzo.ai",
"https://docs.bootno.de",
]
# Add frontend_url if set
if settings.frontend_url:
+9 -1
View File
@@ -39,7 +39,7 @@ dependencies = [
"structlog>=24.4.0",
"tenacity>=9.0.0",
"cachetools>=5.5.0",
# DataStore (ClickHouse)
# DataStore
"aiochclient>=2.6.0",
# Message Queue (Redis-based, @hanzo/mq compatible)
"arq>=0.26.0",
@@ -49,6 +49,14 @@ dependencies = [
"docker>=7.0.0",
# ZAP Protocol (native Cap'n Proto RPC)
"pycapnp>=2.0.0",
# Hanzo O11y — full OTEL stack (traces + metrics + logs → Datastore)
"opentelemetry-api>=1.27.0",
"opentelemetry-sdk>=1.27.0",
"opentelemetry-exporter-otlp-proto-grpc>=1.27.0",
"opentelemetry-instrumentation-fastapi>=0.48b0",
"opentelemetry-instrumentation-httpx>=0.48b0",
"opentelemetry-instrumentation-sqlalchemy>=0.48b0",
"opentelemetry-instrumentation-redis>=0.48b0",
]
+4 -4
View File
@@ -110,9 +110,9 @@ export const hanzoWeb3: BrandConfig = {
},
zoo: {
name: "Zoo Labs",
domain: "zoo.id",
domain: "id.zoo.network",
color: "#00cc66",
iam: "https://zoo.id",
iam: "https://id.zoo.network",
},
lux: {
name: "Lux Network",
@@ -196,9 +196,9 @@ export const luxCloud: BrandConfig = {
},
zoo: {
name: "Zoo Labs",
domain: "zoo.id",
domain: "id.zoo.network",
color: "#00cc66",
iam: "https://zoo.id",
iam: "https://id.zoo.network",
},
lux: {
name: "Lux Network",
+14
View File
@@ -0,0 +1,14 @@
apiVersion: v2
name: lux
description: Lux blockchain node deployment for K8s
type: application
version: 1.1.0
appVersion: "1.23.15"
keywords:
- blockchain
- lux
- validator
- kubernetes
maintainers:
- name: Hanzo AI
email: [email protected]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
{{- $network := index .Values.networks .Values.network }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Values.genesis.configMapName }}
namespace: {{ $network.namespace }}
data:
genesis.json: |
{{- .Files.Get (printf "genesis/%s.json" .Values.network) | nindent 4 }}
+8
View File
@@ -0,0 +1,8 @@
{{- $network := index .Values.networks .Values.network }}
apiVersion: v1
kind: Namespace
metadata:
name: {{ $network.namespace }}
labels:
app: lux
network: {{ .Values.network }}
+26
View File
@@ -0,0 +1,26 @@
{{- $network := index .Values.networks .Values.network }}
{{- $namespace := $network.namespace }}
# Role to allow luxd pods to read their own LoadBalancer service for public IP discovery
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: luxd-service-reader
namespace: {{ $namespace }}
rules:
- apiGroups: [""]
resources: ["services"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: luxd-service-reader
namespace: {{ $namespace }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: luxd-service-reader
subjects:
- kind: ServiceAccount
name: default
namespace: {{ $namespace }}
+85
View File
@@ -0,0 +1,85 @@
{{- $network := index .Values.networks .Values.network }}
{{- $namespace := $network.namespace }}
{{- $stakingPort := $network.stakingPort }}
{{- $httpPort := $network.httpPort }}
# Headless service for StatefulSet DNS resolution
apiVersion: v1
kind: Service
metadata:
name: luxd-headless
namespace: {{ $namespace }}
labels:
app: luxd
lux-network: {{ .Values.network }}
spec:
clusterIP: None
publishNotReadyAddresses: true
selector:
app: luxd
lux-network: {{ .Values.network }}
ports:
- name: http
port: {{ $httpPort }}
- name: staking
port: {{ $stakingPort }}
{{- if .Values.nodeServices.enabled }}
---
# Main LoadBalancer service (round-robin across all pods)
apiVersion: v1
kind: Service
metadata:
name: luxd
namespace: {{ $namespace }}
labels:
app: luxd
lux-network: {{ .Values.network }}
annotations:
service.beta.kubernetes.io/do-loadbalancer-name: "k8s-luxd-{{ .Values.network }}"
{{- with .Values.nodeServices.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.nodeServices.type }}
selector:
app: luxd
lux-network: {{ .Values.network }}
ports:
- name: http
port: {{ $httpPort }}
targetPort: {{ $httpPort }}
- name: staking
port: {{ $stakingPort }}
targetPort: {{ $stakingPort }}
# Individual LoadBalancer services per pod for stable external IPs
# CRITICAL for P2P: each validator needs its own public IP
{{- range $i := until (int .Values.replicas) }}
---
apiVersion: v1
kind: Service
metadata:
name: luxd-{{ $i }}
namespace: {{ $namespace }}
labels:
app: luxd
lux-network: {{ $.Values.network }}
pod-index: "{{ $i }}"
annotations:
service.beta.kubernetes.io/do-loadbalancer-name: "k8s-luxd-{{ $.Values.network }}-{{ $i }}"
{{- with $.Values.nodeServices.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ $.Values.nodeServices.type }}
selector:
app: luxd
lux-network: {{ $.Values.network }}
statefulset.kubernetes.io/pod-name: luxd-{{ $i }}
ports:
- name: http
port: {{ $httpPort }}
targetPort: {{ $httpPort }}
- name: staking
port: {{ $stakingPort }}
targetPort: {{ $stakingPort }}
{{- end }}
{{- end }}
+290
View File
@@ -0,0 +1,290 @@
{{- $network := index .Values.networks .Values.network }}
{{- $namespace := $network.namespace }}
{{- $stakingPort := $network.stakingPort }}
{{- $httpPort := $network.httpPort }}
{{- $replicas := int .Values.replicas }}
apiVersion: v1
kind: ConfigMap
metadata:
name: luxd-startup
namespace: {{ $namespace }}
data:
startup.sh: |
#!/bin/sh
set -e
POD_INDEX=${HOSTNAME##*-}
echo "=== luxd startup for $HOSTNAME ({{ .Values.network }}, network-id={{ $network.networkID }}) ==="
# Staking keys are set up by init container
echo "Staking keys already in place for node $POD_INDEX"
# Build bootstrap node list excluding self
# Uses --bootstrap-nodes (endpoint-only, NodeID discovered from TLS cert)
{{- if .Values.bootstrap.useHostnames }}
# Using internal K8s DNS for bootstrap (recommended)
DNS_PREFIX="luxd"
DNS_SUFFIX="luxd-headless.{{ $namespace }}.svc.cluster.local"
BOOTSTRAP_NODES=""
{{- range $i := until $replicas }}
if [ "$POD_INDEX" != "{{ $i }}" ]; then
CUR_HOST="${DNS_PREFIX}-{{ $i }}.${DNS_SUFFIX}:{{ $stakingPort }}"
if [ -n "$BOOTSTRAP_NODES" ]; then
BOOTSTRAP_NODES="${BOOTSTRAP_NODES},${CUR_HOST}"
else
BOOTSTRAP_NODES="${CUR_HOST}"
fi
fi
{{- end }}
{{- else }}
# Using external IPs for bootstrap
BOOTSTRAP_NODES=""
{{- range $i := until $replicas }}
{{- $externalIP := "" }}
{{- if and $.Values.bootstrap.externalIPs (gt (len $.Values.bootstrap.externalIPs) $i) }}
{{- $externalIP = index $.Values.bootstrap.externalIPs $i }}
{{- end }}
if [ "$POD_INDEX" != "{{ $i }}" ] && [ -n "{{ $externalIP }}" ]; then
if [ -n "$BOOTSTRAP_NODES" ]; then
BOOTSTRAP_NODES="${BOOTSTRAP_NODES},{{ $externalIP }}:{{ $stakingPort }}"
else
BOOTSTRAP_NODES="{{ $externalIP }}:{{ $stakingPort }}"
fi
fi
{{- end }}
{{- end }}
# Get public IP
# Priority: 1) static externalIPs from values, 2) K8s LB service discovery, 3) pod IP fallback
PUBLIC_IP=""
{{- if .Values.bootstrap.externalIPs }}
case "$POD_INDEX" in
{{- range $i := until $replicas }}
{{- if and $.Values.bootstrap.externalIPs (gt (len $.Values.bootstrap.externalIPs) $i) }}
{{ $i }}) PUBLIC_IP="{{ index $.Values.bootstrap.externalIPs $i }}" ;;
{{- end }}
{{- end }}
esac
{{- end }}
# If no static IP, discover from per-pod LoadBalancer service via K8s API
if [ -z "$PUBLIC_IP" ]; then
SA_TOKEN_FILE="/var/run/secrets/kubernetes.io/serviceaccount/token"
CA_CERT="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
K8S_API="https://kubernetes.default.svc"
SVC_NAME="luxd-${POD_INDEX}"
NAMESPACE="{{ $namespace }}"
if [ -f "$SA_TOKEN_FILE" ]; then
TOKEN=$(cat "$SA_TOKEN_FILE")
echo "[PUBLIC-IP] Discovering external IP from service ${SVC_NAME} in ${NAMESPACE}..."
ATTEMPT=0
MAX_ATTEMPTS=12
while [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; do
SVC_JSON=$(curl -sf -m 5 \
-H "Authorization: Bearer ${TOKEN}" \
--cacert "$CA_CERT" \
"${K8S_API}/api/v1/namespaces/${NAMESPACE}/services/${SVC_NAME}" 2>/dev/null || true)
if [ -n "$SVC_JSON" ]; then
# Extract loadBalancer ingress IP (no jq in alpine, JSON spans multiple lines)
# Look for "ip" field that appears after "ingress" in the status block
LB_IP=$(echo "$SVC_JSON" | tr -d ' \n' | sed -n 's/.*"loadBalancer":{"ingress":\[{"ip":"\([^"]*\)".*/\1/p')
if [ -n "$LB_IP" ]; then
PUBLIC_IP="$LB_IP"
echo "[PUBLIC-IP] Discovered external IP: $PUBLIC_IP (attempt $((ATTEMPT+1)))"
break
fi
fi
ATTEMPT=$((ATTEMPT + 1))
if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then
echo "[PUBLIC-IP] LB IP not yet assigned, retrying in 5s... (attempt $((ATTEMPT))/${MAX_ATTEMPTS})"
sleep 5
fi
done
else
echo "[PUBLIC-IP] No service account token found, skipping LB discovery"
fi
fi
# Final fallback to pod IP
if [ -z "$PUBLIC_IP" ]; then
PUBLIC_IP=$(hostname -i)
echo "[PUBLIC-IP] WARNING: Using pod IP $PUBLIC_IP (no external IP found)"
fi
{{- if .Values.chainConfigs.enabled }}
# Write EVM chain configs for C-chain and deployed subnet chains
CHAIN_CFG='{{ .Values.chainConfigs.evmConfig }}'
for chain in C{{ range .Values.chainConfigs.blockchainIDs }} {{ . }}{{ end }}; do
mkdir -p /data/configs/chains/$chain
echo "$CHAIN_CFG" > /data/configs/chains/$chain/config.json
done
echo "Chain configs written for C{{ range .Values.chainConfigs.blockchainIDs }} + {{ . | trunc 8 }}{{ end }}"
{{- end }}
echo " public-ip: $PUBLIC_IP"
echo " bootstrap-nodes: $BOOTSTRAP_NODES"
# Build luxd arguments
ARGS="--network-id={{ $network.networkID }}"
ARGS="$ARGS --http-host=0.0.0.0"
ARGS="$ARGS --http-port={{ $httpPort }}"
ARGS="$ARGS --http-allowed-hosts={{ .Values.api.httpAllowedHosts }}"
ARGS="$ARGS --staking-port={{ $stakingPort }}"
ARGS="$ARGS --data-dir=/data"
ARGS="$ARGS --genesis-file=/genesis/genesis.json"
ARGS="$ARGS --db-type={{ .Values.dbType }}"
{{- if .Values.chainConfigs.enabled }}
ARGS="$ARGS --chain-config-dir=/data/configs/chains"
{{- end }}
ARGS="$ARGS --index-enabled={{ .Values.api.indexEnabled }}"
ARGS="$ARGS --api-admin-enabled={{ .Values.api.adminEnabled }}"
ARGS="$ARGS --api-metrics-enabled={{ .Values.api.metricsEnabled }}"
ARGS="$ARGS --api-keystore-enabled=true"
ARGS="$ARGS --log-level={{ .Values.logLevel }}"
ARGS="$ARGS --plugin-dir=/data/plugins"
ARGS="$ARGS --staking-tls-cert-file=/data/staking/staker.crt"
ARGS="$ARGS --staking-tls-key-file=/data/staking/staker.key"
# Signer key is optional
if [ -f "/data/staking/signer.key" ]; then
ARGS="$ARGS --staking-signer-key-file=/data/staking/signer.key"
fi
ARGS="$ARGS --consensus-sample-size={{ .Values.consensus.sampleSize }}"
ARGS="$ARGS --consensus-quorum-size={{ .Values.consensus.quorumSize }}"
ARGS="$ARGS --sybil-protection-enabled={{ .Values.consensus.sybilProtectionEnabled }}"
ARGS="$ARGS --network-require-validator-to-connect={{ .Values.consensus.requireValidatorToConnect }}"
ARGS="$ARGS --network-allow-private-ips={{ .Values.consensus.allowPrivateIPs }}"
if [ -n "$PUBLIC_IP" ]; then
ARGS="$ARGS --public-ip=$PUBLIC_IP"
fi
if [ -n "$BOOTSTRAP_NODES" ]; then
ARGS="$ARGS --bootstrap-nodes=$BOOTSTRAP_NODES"
fi
# Chain tracking
{{- if .Values.chainTracking.trackAllChains }}
ARGS="$ARGS --track-all-chains"
{{- else if .Values.chainTracking.trackedChains }}
ARGS="$ARGS --track-chains={{ join "," .Values.chainTracking.trackedChains }}"
{{- end }}
# Upgrade config override (all upgrades from genesis for custom networks)
{{- if .Values.upgradeConfig.enabled }}
UPGRADE_JSON='{{ .Values.upgradeConfig.content | replace "\n" "" | trim }}'
UPGRADE_B64=$(echo "$UPGRADE_JSON" | base64 | tr -d '\n')
ARGS="$ARGS --upgrade-file-content=$UPGRADE_B64"
{{- end }}
{{- if .Values.bootstrap.rlpImport.enabled }}
# Clear C-chain data on restart to avoid trie corruption
# (path database prunes genesis state, causing "triedb parent layer missing" on restart)
# P-chain and X-chain data are preserved (subnet deployments, etc.)
C_CHAIN_DIR="/data/chainData/network-{{ $network.networkID }}/{{ $network.cChainID }}"
IMPORT_MARKER="/data/.bootstrap-{{ .Values.network }}-import.done"
if [ -d "$C_CHAIN_DIR" ]; then
echo "[STARTUP] Clearing C-chain data to prevent trie corruption on restart..."
rm -rf "$C_CHAIN_DIR"
rm -f "$IMPORT_MARKER"
echo "[STARTUP] C-chain data cleared, will re-import RLP"
fi
{{- end }}
# Start luxd in background (allows post-start bootstrap logic)
/luxd/build/luxd $ARGS &
LUXD_PID=$!
# Disable set -e for bootstrap section
set +e
# Wait for node to be healthy
echo "[BOOTSTRAP] Waiting for node health..."
i=0
while [ "$i" -lt 180 ]; do
if curl -sf -m 2 http://127.0.0.1:{{ $httpPort }}/ext/health >/dev/null 2>&1; then
echo "[BOOTSTRAP] Node healthy after ${i}s"
break
fi
i=$((i + 1))
sleep 1
done
# Set up chain aliases
{{- if .Values.chainTracking.aliases }}
{{- range .Values.chainTracking.aliases }}
curl -s -m 5 -X POST -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"admin.aliasChain","params":{"chain":"C","alias":"{{ . }}"}}' \
http://127.0.0.1:{{ $httpPort }}/ext/admin >/dev/null 2>&1 || true
{{- end }}
{{- end }}
{{- if .Values.bootstrap.rlpImport.enabled }}
# C-Chain RLP import
MARKER="/data/.bootstrap-{{ .Values.network }}-import.done"
BOOTSTRAP_DIR="/data/bootstrap"
if [ ! -f "$MARKER" ]; then
height_hex=$(curl -s -m 10 -X POST -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' \
http://127.0.0.1:{{ $httpPort }}/ext/bc/C/rpc | sed -n 's/.*"result":"\(0x[0-9A-Fa-f]*\)".*/\1/p' | head -n1)
if [ -z "$height_hex" ]; then
height_hex="0x0"
fi
height_dec=$(printf '%d' "$height_hex" 2>/dev/null || echo 0)
echo "[BOOTSTRAP] C-Chain height: $height_dec (hex: $height_hex)"
if [ "$height_dec" -lt "{{ int .Values.bootstrap.rlpImport.minHeight }}" ]; then
echo "[BOOTSTRAP] C-Chain below target ({{ int .Values.bootstrap.rlpImport.minHeight }}), starting RLP import..."
mkdir -p "$BOOTSTRAP_DIR"
RLP_FILE="${BOOTSTRAP_DIR}/{{ .Values.bootstrap.rlpImport.rlpFilename }}"
{{- if .Values.bootstrap.rlpImport.multiPart }}
# Download multi-part RLP
if [ ! -s "$RLP_FILE" ]; then
echo "[BOOTSTRAP] Downloading RLP parts..."
{{- range .Values.bootstrap.rlpImport.parts }}
PART_FILE="${BOOTSTRAP_DIR}/{{ $.Values.bootstrap.rlpImport.rlpFilename }}.part.{{ . }}"
if [ ! -s "$PART_FILE" ]; then
echo "[BOOTSTRAP] Downloading part {{ . }}..."
curl -fL --retry 5 --retry-delay 2 --retry-connrefused -o "$PART_FILE" \
"{{ $.Values.bootstrap.rlpImport.baseUrl }}/{{ $.Values.bootstrap.rlpImport.rlpFilename | replace ".rlp" "" }}.part.{{ . }}" || true
fi
{{- end }}
echo "[BOOTSTRAP] Assembling RLP file..."
cat "${BOOTSTRAP_DIR}"/{{ .Values.bootstrap.rlpImport.rlpFilename }}.part.* > "$RLP_FILE" 2>/dev/null || true
fi
{{- else }}
# Download single RLP file
if [ ! -s "$RLP_FILE" ]; then
echo "[BOOTSTRAP] Downloading RLP..."
curl -fL --retry 5 --retry-delay 2 --retry-connrefused -o "$RLP_FILE" \
"{{ .Values.bootstrap.rlpImport.baseUrl }}/{{ .Values.bootstrap.rlpImport.rlpFilename }}" || true
fi
{{- end }}
if [ -s "$RLP_FILE" ]; then
echo "[BOOTSTRAP] Starting import of $RLP_FILE..."
import_payload=$(printf '{"jsonrpc":"2.0","id":1,"method":"admin_importChain","params":["%s"]}' "$RLP_FILE")
import_result=$(curl -s -m {{ int .Values.bootstrap.rlpImport.timeout }} -X POST -H 'Content-Type: application/json' --data "$import_payload" http://127.0.0.1:{{ $httpPort }}/ext/bc/C/rpc || true)
echo "[BOOTSTRAP] Import result: $import_result"
echo "$import_result" | grep -q '"success":true' && touch "$MARKER" && echo "[BOOTSTRAP] Import complete" || echo "[BOOTSTRAP] Import did not return success"
else
echo "[BOOTSTRAP] RLP file missing or empty, skipping"
fi
else
echo "[BOOTSTRAP] C-Chain already at height $height_dec, skipping import"
touch "$MARKER"
fi
else
echo "[BOOTSTRAP] Import marker exists, skipping bootstrap"
fi
{{- end }}
wait "$LUXD_PID"
+226
View File
@@ -0,0 +1,226 @@
{{- $network := index .Values.networks .Values.network }}
{{- $namespace := $network.namespace }}
{{- $stakingPort := $network.stakingPort }}
{{- $httpPort := $network.httpPort }}
{{- $replicas := int .Values.replicas }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: luxd
namespace: {{ $namespace }}
annotations:
lux.network/protect-pvc: "true"
spec:
serviceName: luxd-headless
replicas: {{ .Values.replicas }}
podManagementPolicy: Parallel
updateStrategy:
type: {{ .Values.upgradeStrategy.type | default "OnDelete" }}
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
selector:
matchLabels:
app: luxd
lux-network: {{ .Values.network }}
template:
metadata:
labels:
app: luxd
lux-network: {{ .Values.network }}
spec:
securityContext:
fsGroup: 1000
runAsUser: 1000
runAsGroup: 1000
terminationGracePeriodSeconds: 30
{{- if .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
{{- end }}
serviceAccountName: luxd
initContainers:
- name: init-plugins
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
imagePullPolicy: IfNotPresent
securityContext:
runAsUser: 0
runAsGroup: 0
command: ["/bin/sh", "-c"]
args:
- |
set -e
IDX=${HOSTNAME##*-}
echo "=== Init for luxd-${IDX} ({{ .Values.network }}) ==="
{{- if .Values.initMode.clearData }}
echo "=== Clearing data for re-genesis (preserving RLP) ==="
rm -rf /data/chainData /data/db /data/genesis.bytes /data/.bootstrap-*-import.done /data/.seeded
echo "Data cleared"
{{- else }}
echo "=== Preserving existing data ==="
{{- end }}
{{- if .Values.seedRestore.enabled }}
# ─── Seed Restore (idempotent via marker file) ───
SEED_MARKER="{{ .Values.seedRestore.markerPath | default "/data/.seeded" }}"
if [ ! -f "$SEED_MARKER" ]; then
echo "[SEED] Starting seed restore..."
{{- if eq .Values.seedRestore.sourceType "ObjectStore" }}
echo "[SEED] Downloading snapshot from {{ .Values.seedRestore.objectStoreUrl }}..."
curl -fL --retry 5 --retry-delay 5 -o /tmp/snapshot.tar.zst "{{ .Values.seedRestore.objectStoreUrl }}" && \
echo "[SEED] Extracting snapshot..." && \
(zstd -d /tmp/snapshot.tar.zst -o /tmp/snapshot.tar 2>/dev/null || cp /tmp/snapshot.tar.zst /tmp/snapshot.tar) && \
tar xf /tmp/snapshot.tar -C /data && \
rm -f /tmp/snapshot.tar /tmp/snapshot.tar.zst && \
touch "$SEED_MARKER" && \
echo "[SEED] Restore complete" || echo "[SEED] Restore failed"
{{- else if or (eq .Values.seedRestore.sourceType "PVCClone") (eq .Values.seedRestore.sourceType "VolumeSnapshot") }}
# PVC was created from snapshot/clone
if [ -d "/data/db" ] || [ -d "/data/chainData" ]; then
echo "[SEED] Data found from PVC clone/snapshot"
touch "$SEED_MARKER"
else
echo "[SEED] WARNING: PVC clone/snapshot has no data"
fi
{{- else }}
echo "[SEED] No seed source configured, skipping"
{{- end }}
else
echo "[SEED] Marker $SEED_MARKER exists, skipping restore"
fi
{{- end }}
# Set up plugins
echo "=== Setting up plugins ==="
mkdir -p /data/plugins
# C-Chain EVM plugin
cp /luxd/build/plugins/mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6 /data/plugins/
# Subnet EVM plugin (same binary, different VM ID)
cp /luxd/build/plugins/mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6 /data/plugins/ag3GReYPNuSR17rUP8acMdZipQBikdXNRKDyFszAysmy3vDXE
# Set up staking keys
echo "=== Setting up staking keys ==="
mkdir -p /data/staking
cp /staking-keys/staker-${IDX}.crt /data/staking/staker.crt
cp /staking-keys/staker-${IDX}.key /data/staking/staker.key
if [ -f "/staking-keys/signer-${IDX}.key" ]; then
cp /staking-keys/signer-${IDX}.key /data/staking/signer.key
fi
chown -R 1000:1000 /data/staking
chmod 600 /data/staking/*
echo "=== Init complete ==="
volumeMounts:
- name: data
mountPath: /data
- name: staking-keys
mountPath: /staking-keys
readOnly: true
{{- if .Values.startupGate.enabled }}
- name: startup-gate
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c"]
args:
- |
set -e
POD_INDEX=${HOSTNAME##*-}
MIN_PEERS={{ .Values.startupGate.minPeers | default 2 }}
TIMEOUT={{ .Values.startupGate.timeoutSeconds | default 300 }}
INTERVAL={{ .Values.startupGate.checkIntervalSeconds | default 5 }}
DNS_PREFIX="luxd"
DNS_SUFFIX="luxd-headless.{{ $namespace }}.svc.cluster.local"
START=$(date +%s)
echo "[GATE] Waiting for $MIN_PEERS peers on staking port {{ $stakingPort }} (timeout ${TIMEOUT}s)..."
while true; do
REACHABLE=0
{{- range $i := until $replicas }}
if [ "$POD_INDEX" != "{{ $i }}" ]; then
if nc -z -w 2 "${DNS_PREFIX}-{{ $i }}.${DNS_SUFFIX}" {{ $stakingPort }} 2>/dev/null; then
REACHABLE=$((REACHABLE + 1))
fi
fi
{{- end }}
if [ "$REACHABLE" -ge "$MIN_PEERS" ]; then
echo "[GATE] $REACHABLE peers reachable, proceeding"
break
fi
ELAPSED=$(( $(date +%s) - START ))
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
echo "[GATE] WARNING: Timeout after ${ELAPSED}s with only $REACHABLE peers. Starting anyway."
break
fi
echo "[GATE] $REACHABLE/$MIN_PEERS peers reachable (${ELAPSED}/${TIMEOUT}s), waiting ${INTERVAL}s..."
sleep "$INTERVAL"
done
{{- end }}
containers:
- name: luxd
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["/bin/sh", "/scripts/startup.sh"]
ports:
- name: http
containerPort: {{ $httpPort }}
- name: staking
containerPort: {{ $stakingPort }}
livenessProbe:
httpGet:
path: /ext/health
port: {{ $httpPort }}
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 5
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /ext/health
port: {{ $httpPort }}
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 5
startupProbe:
tcpSocket:
port: {{ $httpPort }}
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 60
resources:
{{- toYaml .Values.resources | nindent 10 }}
volumeMounts:
- name: data
mountPath: /data
- name: genesis
mountPath: /genesis
readOnly: true
- name: startup-script
mountPath: /scripts
- name: staking-keys
mountPath: /staking-keys
readOnly: true
volumes:
- name: genesis
configMap:
name: {{ .Values.genesis.configMapName }}
- name: startup-script
configMap:
name: luxd-startup
defaultMode: 0755
- name: staking-keys
secret:
secretName: {{ .Values.staking.secretName }}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: {{ .Values.storage.storageClass }}
resources:
requests:
storage: {{ .Values.storage.size }}
+41
View File
@@ -0,0 +1,41 @@
# Devnet configuration overrides
network: devnet
replicas: 5
bootstrap:
useHostnames: true
nodeIDs:
- NodeID-8mCxB5QtMDX1S84zaXF7i8vGcP4FJxACH
- NodeID-Pss5uJBPNECYvPZVuas9sE8ZwSpwP43Tr
- NodeID-F1snfkawAgudnrp4wZWRK6syZwokPRfb2
- NodeID-H7KVD16b5b8uzWy9uUdrjzsBLEbbTaUKc
- NodeID-aVsqjzUqNNTNDxbRwkCUrwnVgEe5x8Qc
# Data preserved (startTime 1766704800)
initMode:
clearData: false
# Devnet consensus - sybil off for development
consensus:
sybilProtectionEnabled: false
allowPrivateIPs: true
# Devnet tracks all chains
chainTracking:
trackAllChains: true
trackedChains: []
aliases:
- zoo
- hanzo
- spc
- pars
# EVM chain configs for C + deployed subnet chains (by blockchain ID)
chainConfigs:
enabled: true
blockchainIDs:
- "9caEYTT7d1g57qfh78WfmrnCc8TYVUfWKLPrrrgZnPv11NisX" # Zoo
- "5jTMMEPbJG2xuQzYgUhRsQE9gRm8jvayHsvzQ8xH35HAv6UHC" # Hanzo
- "2PydmLj14vqDhaZgtdb8nVBYc2Z3TUkmqmAwmasiatjEcdhzSk" # SPC
- "2m3UV9zVPKMZJYQyrQWhr49eiQbkhxULVmsTpYTmb11hC1MmQA" # Pars
+52
View File
@@ -0,0 +1,52 @@
# Mainnet configuration overrides
network: mainnet
replicas: 5
bootstrap:
useHostnames: true
nodeIDs:
- NodeID-Mf3JfSY91oDwfBqf7rCLmhg4NDtDghw1f
- NodeID-2TwSZ2oyeBK2mv7JiseEQ8m74rotDj4QR
- NodeID-Ld9VFBQ9zGbd79z2vzaAqkQ3jHuqbtRpo
- NodeID-8mY2fhUehN27v3LCU84BnnKEoeRfd2weC
- NodeID-4smuFz5Z9cm8BEyQU1oPQ9nwgXyMgaNqu
# C-Chain RLP import (1,082,780 blocks from canonical genesis)
rlpImport:
enabled: true
baseUrl: "https://raw.githubusercontent.com/luxfi/state/main/rlp/lux-mainnet"
rlpFilename: "lux-mainnet-96369.rlp"
multiPart: true
parts: ["aa","ab","ac","ad","ae","af","ag","ah","ai","aj","ak","al","am"]
minHeight: 1000000
timeout: 7200
initMode:
clearData: false
# Mainnet requires sybil protection
consensus:
sybilProtectionEnabled: true
# Mainnet tracks specific subnet chains (by subnet ID)
chainTracking:
trackAllChains: false
trackedChains:
- "Zb8CqjdWK87rrQNfyUXF8vyPTWpkfk6Fi11QAgdVx8Wqn92gN" # Zoo subnet
- "2Sp2D6AkezVy51Md1QN9pgSUrBFJokFftgu4CodoZ4G5vV8AA5" # Hanzo subnet
- "z3vzrV3VzGAzcRJRQD5h9A8SqTAHdUroUFMmCFjmm7E1eCmcm" # SPC subnet
- "qKtBVWY7RAEEWZDVHJTa2stao5tN4d57hkb7758E9H7tMNmec" # Pars subnet (494949)
aliases:
- zoo
- hanzo
- spc
- pars
# EVM chain configs for C + deployed subnet chains (by blockchain ID)
chainConfigs:
enabled: true
blockchainIDs:
- "pcMkknsTyA5JdYDzvgRQkq4WmPdz99TcAWeezAQYh7Tjh64HN" # Zoo
- "2ktB8JknYFzLTNWPFk5QHstmmbSx97vAFv5zJUD4BtgU59tZD5" # Hanzo
- "2jomRDDNY9c4eLS47m7gNUMnf6fNYdmCBHSGX6JHTqzP1W1FgE" # SPC
- "2ZPGRSPzvUfr8nSYigsUj1FYC68U6rcKoePhMSkHSYS4V9UmnS" # Pars (494949)
+48
View File
@@ -0,0 +1,48 @@
# Testnet configuration overrides
network: testnet
replicas: 5
bootstrap:
useHostnames: true
nodeIDs:
- NodeID-Mf3JfSY91oDwfBqf7rCLmhg4NDtDghw1f
- NodeID-2TwSZ2oyeBK2mv7JiseEQ8m74rotDj4QR
- NodeID-Ld9VFBQ9zGbd79z2vzaAqkQ3jHuqbtRpo
- NodeID-8mY2fhUehN27v3LCU84BnnKEoeRfd2weC
- NodeID-4smuFz5Z9cm8BEyQU1oPQ9nwgXyMgaNqu
# C-Chain RLP import (219 blocks from canonical genesis)
rlpImport:
enabled: true
baseUrl: "https://raw.githubusercontent.com/luxfi/state/main/rlp/lux-testnet"
rlpFilename: "lux-testnet-96368.rlp"
multiPart: false
parts: []
minHeight: 200
timeout: 600
initMode:
clearData: false
# Testnet requires sybil protection
consensus:
sybilProtectionEnabled: true
# Testnet tracks all chains automatically
chainTracking:
trackAllChains: true
trackedChains: []
aliases:
- zoo
- hanzo
- spc
- pars
# EVM chain configs for C + deployed subnet chains (by blockchain ID)
chainConfigs:
enabled: true
blockchainIDs:
- "2HejgoXvUEDYUYoJLUhQYYadtDSE5qpBLTJKxzYr1AcvkRT9fE" # Zoo
- "2gUYrds3GEira6a1KxzPxSHJx2rY48hLEtpcWXm7JFJ81WiKJg" # Hanzo (36964)
- "eoMnXFewgFM22caQi35FU4i4hyFdLrPFwNVT54yFzc2WPGnNY" # SPC
- "o5eQ7MhcM1K4CqFXRdseNQm9xnyVLKFxsdjHAq8v7UJbXU9rw" # Pars (494950)
+187
View File
@@ -0,0 +1,187 @@
# Lux blockchain node Helm chart values
# Base config (used as defaults, override per network with values-{network}.yaml)
network: devnet
# Network-specific settings
# networkID MUST match genesis.json networkID (node validates at startup)
# Well-known IDs: mainnet=1, testnet=2, devnet=3
# C-chain IDs (96369/96368/96370) are chain IDs, NOT network IDs
networks:
mainnet:
networkID: 1
chainID: 96369
cChainID: FvnNxsL1LGeNbp5HugkjK9VszFKWVN18b2DSoj1ZRbyBev2hM
httpPort: 9630
stakingPort: 9631
namespace: lux-mainnet
testnet:
networkID: 2
chainID: 96368
cChainID: 2oworJV5yJCV69AcBrizs7Xy4iZHs6Ao9pQP9PR7mNX3qoxaZn
httpPort: 9640
stakingPort: 9641
namespace: lux-testnet
devnet:
networkID: 3
chainID: 96370
cChainID: FvnNxsL1LGeNbp5HugkjK9VszFKWVN18b2DSoj1ZRbyBev2hM
httpPort: 9650
stakingPort: 9651
namespace: lux-devnet
# 5 replicas for production consensus
replicas: 5
image:
repository: registry.digitalocean.com/hanzo/bootnode
tag: luxd-v1.23.14
pullPolicy: Always
imagePullSecrets:
- name: registry-hanzo
# Bootstrap configuration
bootstrap:
nodeIDs:
- NodeID-8mCxB5QtMDX1S84zaXF7i8vGcP4FJxACH
- NodeID-Pss5uJBPNECYvPZVuas9sE8ZwSpwP43Tr
- NodeID-F1snfkawAgudnrp4wZWRK6syZwokPRfb2
- NodeID-H7KVD16b5b8uzWy9uUdrjzsBLEbbTaUKc
- NodeID-aVsqjzUqNNTNDxbRwkCUrwnVgEe5x8Qc
useHostnames: true
externalIPs: []
# RLP block import configuration
rlpImport:
enabled: false
baseUrl: ""
rlpFilename: ""
multiPart: false
parts: []
minHeight: 1
timeout: 7200
# Consensus settings (5-node network)
consensus:
sampleSize: 5
quorumSize: 4
sybilProtectionEnabled: false
requireValidatorToConnect: false
allowPrivateIPs: true
# Chain tracking
chainTracking:
trackAllChains: true
trackedChains: []
aliases:
- zoo
- hanzo
- spc
- pars
# Deployed chain configs (blockchain IDs for EVM chain config directories)
# These get JSON config written to /data/configs/chains/{blockchainID}/config.json
chainConfigs:
enabled: false
blockchainIDs: []
# EVM chain config applied to C-chain and all subnet chains
evmConfig: '{"admin-api-enabled":true,"eth-apis":["eth","eth-filter","net","web3","internal-eth","internal-blockchain","internal-transaction","internal-tx-pool","internal-account","internal-personal","debug","internal-debug","admin"],"rpc-gas-cap":50000000,"rpc-tx-fee-cap":100,"pruning-enabled":false,"allow-unfinalized-queries":true,"allow-unprotected-txs":true,"log-level":"info","state-sync-enabled":false,"allow-missing-tries":true}'
# Init container mode
initMode:
# Set to true to clear chain data on pod restart (for re-genesis)
clearData: false
# Startup gating: wait for peers before starting luxd
# Prevents "bootstrapped at height 0 with 0 peers" issue
startupGate:
enabled: true
minPeers: 2
timeoutSeconds: 300
checkIntervalSeconds: 5
# Seed/snapshot restore configuration
seedRestore:
enabled: false
# sourceType: None | ObjectStore | PVCClone | VolumeSnapshot
sourceType: None
objectStoreUrl: ""
markerPath: "/data/.seeded"
# Upgrade strategy
upgradeStrategy:
# OnDelete (safest) or RollingUpdate
type: OnDelete
maxUnavailable: 1
# Health policy
healthPolicy:
requireInboundValidators: false
minInbound: 1
gracePeriodSeconds: 300
maxHeightSkew: 10
# Staking keys
staking:
secretName: luxd-staking
# Resources
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "4Gi"
cpu: "2"
# Storage
storage:
size: 100Gi
storageClass: do-block-storage
# Node exposure
nodeServices:
enabled: true
type: LoadBalancer
annotations: {}
# Logging
logLevel: info
# Database
dbType: badgerdb
# API settings
api:
adminEnabled: true
metricsEnabled: true
indexEnabled: true
httpAllowedHosts: "*"
# Upgrade config override (only for custom network IDs, NOT well-known 1/2/3)
# Well-known network IDs use built-in upgrade schedules
upgradeConfig:
enabled: false
content: |
{
"apricotPhase1Time": "2020-12-05T05:00:00Z",
"apricotPhase2Time": "2020-12-05T05:00:00Z",
"apricotPhase3Time": "2020-12-05T05:00:00Z",
"apricotPhase4Time": "2020-12-05T05:00:00Z",
"apricotPhase4MinPChainHeight": 0,
"apricotPhase5Time": "2020-12-05T05:00:00Z",
"apricotPhasePre6Time": "2020-12-05T05:00:00Z",
"apricotPhase6Time": "2020-12-05T05:00:00Z",
"apricotPhasePost6Time": "2020-12-05T05:00:00Z",
"banffTime": "2020-12-05T05:00:00Z",
"cortinaTime": "2020-12-05T05:00:00Z",
"cortinaXChainStopVertexID": "11111111111111111111111111111111LpoYY",
"durangoTime": "2020-12-05T05:00:00Z",
"etnaTime": "2020-12-05T05:00:00Z",
"fortunaTime": "2020-12-05T05:00:00Z",
"graniteTime": "9999-12-01T00:00:00Z"
}
# Genesis
genesis:
configMapName: luxd-genesis
+1 -1
View File
@@ -275,7 +275,7 @@ services:
postgres_pass: graph-bootnode-2026
postgres_db: graph_node
ipfs: graph-ipfs:5001
ethereum: "lux:${LUX_RPC_URL:-http://host.docker.internal:9630/ext/bc/C/rpc}"
ethereum: "lux:${LUX_RPC_URL:-http://host.docker.internal:9630/ext/bc/C/rpc} zoo:${ZOO_RPC_URL:-http://host.docker.internal:9630/ext/bc/zoo/rpc} pars:${PARS_RPC_URL:-http://host.docker.internal:9630/ext/bc/pars/rpc} hanzo:${HANZO_RPC_URL:-http://host.docker.internal:9630/ext/bc/hanzo/rpc} spc:${SPC_RPC_URL:-http://host.docker.internal:9630/ext/bc/spc/rpc}"
GRAPH_LOG: info
ETHEREUM_REORG_THRESHOLD: 1
ETHEREUM_ANCESTOR_COUNT: 1
@@ -0,0 +1,31 @@
# DigitalOcean App Platform spec for the bootnode-operator.
#
# This builds and deploys the operator container to the lux-k8s cluster.
# The operator watches Cluster CRDs and reconciles blockchain node clusters.
#
# Deployment:
# doctl apps create --spec infra/do-app-platform/bootnode-operator.yaml
# doctl apps update <app-id> --spec infra/do-app-platform/bootnode-operator.yaml
name: bootnode-operator
region: sfo
services:
- name: operator
github:
repo: hanzoai/bootnode
branch: main
deploy_on_push: true
dockerfile_path: operator/Dockerfile
source_dir: operator
instance_count: 1
instance_size_slug: professional-xs
http_port: 8081
health_check:
http_path: /healthz
initial_delay_seconds: 10
period_seconds: 30
envs:
- key: RUST_LOG
value: info,bootnode_operator=debug
run_command: /usr/local/bin/bootnode-operator --namespace "" --log-level info
+30 -12
View File
@@ -29,7 +29,7 @@ spec:
serviceAccountName: bootnode-api
containers:
- name: api
image: ghcr.io/hanzoai/bootnode:api-latest
image: ghcr.io/hanzoai/bootnode:latest
imagePullPolicy: Always
ports:
- name: http
@@ -42,27 +42,27 @@ spec:
valueFrom:
secretKeyRef:
name: bootnode-secrets
key: database-url
key: DATABASE_URL
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: bootnode-secrets
key: redis-url
key: REDIS_URL
- name: DATASTORE_URL
valueFrom:
secretKeyRef:
name: bootnode-secrets
key: datastore-url
key: DATASTORE_URL
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: bootnode-secrets
key: jwt-secret
key: JWT_SECRET
- name: API_KEY_SALT
valueFrom:
secretKeyRef:
name: bootnode-secrets
key: api-key-salt
key: API_KEY_SALT
# Hanzo KMS Configuration
- name: HANZO_KMS_URL
value: "https://kms.hanzo.ai"
@@ -75,14 +75,14 @@ spec:
- name: HANZO_KMS_CLIENT_ID
valueFrom:
secretKeyRef:
name: hanzo-kms-secrets
key: client-id
name: universal-auth-credentials
key: clientId
optional: true
- name: HANZO_KMS_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: hanzo-kms-secrets
key: client-secret
name: universal-auth-credentials
key: clientSecret
optional: true
# Hanzo Commerce (Billing)
- name: HANZO_COMMERCE_URL
@@ -91,13 +91,29 @@ spec:
valueFrom:
secretKeyRef:
name: bootnode-secrets
key: commerce-api-key
key: COMMERCE_API_KEY
optional: true
- name: HANZO_COMMERCE_WEBHOOK_SECRET
valueFrom:
secretKeyRef:
name: bootnode-secrets
key: commerce-webhook-secret
key: COMMERCE_WEBHOOK_SECRET
optional: true
# Hanzo O11y (OTEL → shared Datastore)
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-collector.hanzo.svc:4317"
- name: OTEL_SERVICE_NAME
value: "bootnode-api"
- name: OTEL_SERVICE_VERSION
value: "2.0.0"
- name: OTEL_ENABLED
value: "true"
# AI Chat (Hanzo AI Gateway at api.hanzo.ai)
- name: HANZO_AI_API_KEY
valueFrom:
secretKeyRef:
name: bootnode-secrets
key: HANZO_AI_API_KEY
optional: true
# Hanzo IAM Configuration
- name: HANZO_IAM_URL
@@ -135,6 +151,8 @@ spec:
volumeMounts:
- name: tmp
mountPath: /tmp
imagePullSecrets:
- name: ghcr-secret
volumes:
- name: tmp
emptyDir: {}
-115
View File
@@ -1,115 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: bootnode-web
namespace: bootnode
labels:
app: bootnode-web
component: frontend
brand: bootnode
spec:
replicas: 2
selector:
matchLabels:
app: bootnode-web
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: bootnode-web
component: frontend
brand: bootnode
spec:
containers:
- name: web
image: ghcr.io/hanzoai/bootnode:web-latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 3001
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: NEXT_PUBLIC_BRAND
value: "bootnode"
- name: NEXT_PUBLIC_API_URL
value: "https://api.bootno.de"
- name: NEXT_PUBLIC_WS_URL
value: "wss://ws.bootno.de"
- name: NEXT_PUBLIC_IAM_URL
value: "https://iam.bootno.de"
- name: NEXT_PUBLIC_IAM_CLIENT_ID
value: "bootnode-web"
- name: NEXT_PUBLIC_IAM_DOMAIN
value: "iam.bootno.de"
- name: NEXT_PUBLIC_AUTH_MODE
value: "iam"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
securityContext:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
---
apiVersion: v1
kind: Service
metadata:
name: bootnode-web
namespace: bootnode
labels:
app: bootnode-web
spec:
type: ClusterIP
ports:
- name: http
port: 3001
targetPort: http
protocol: TCP
selector:
app: bootnode-web
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: bootnode-web-hpa
namespace: bootnode
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: bootnode-web
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
+76
View File
@@ -0,0 +1,76 @@
# Bootnode docs — static Next.js export served by lightweight container
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bootnode-docs
namespace: bootnode
labels:
app: bootnode-docs
component: docs
spec:
replicas: 2
selector:
matchLabels:
app: bootnode-docs
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: bootnode-docs
component: docs
spec:
containers:
- name: docs
image: ghcr.io/hanzoai/bootnode-docs:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 3005
protocol: TCP
env:
- name: NODE_ENV
value: "production"
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "256Mi"
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 10
securityContext:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
imagePullSecrets:
- name: ghcr-secret
---
apiVersion: v1
kind: Service
metadata:
name: bootnode-docs
namespace: bootnode
spec:
type: ClusterIP
ports:
- name: http
port: 3005
targetPort: http
selector:
app: bootnode-docs
+7
View File
@@ -158,6 +158,13 @@ spec:
remoteRef:
key: BTC_RPC_PASSWORD
# =========================================================================
# AI Chat (Hanzo AI Gateway at api.hanzo.ai)
# =========================================================================
- secretKey: HANZO_AI_API_KEY
remoteRef:
key: HANZO_AI_API_KEY
# =========================================================================
# ERC-4337 Bundler
# =========================================================================
-115
View File
@@ -1,115 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: hanzo-web3
namespace: bootnode
labels:
app: hanzo-web3
component: frontend
brand: hanzo
spec:
replicas: 2
selector:
matchLabels:
app: hanzo-web3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: hanzo-web3
component: frontend
brand: hanzo
spec:
containers:
- name: web
image: ghcr.io/hanzoai/bootnode:web-latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 3001
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: NEXT_PUBLIC_BRAND
value: "hanzo"
- name: NEXT_PUBLIC_API_URL
value: "https://api.web3.hanzo.ai"
- name: NEXT_PUBLIC_WS_URL
value: "wss://ws.web3.hanzo.ai"
- name: NEXT_PUBLIC_IAM_URL
value: "https://hanzo.id"
- name: NEXT_PUBLIC_IAM_CLIENT_ID
value: "hanzo-web3"
- name: NEXT_PUBLIC_IAM_DOMAIN
value: "hanzo.id"
- name: NEXT_PUBLIC_AUTH_MODE
value: "iam"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
securityContext:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
---
apiVersion: v1
kind: Service
metadata:
name: hanzo-web3
namespace: bootnode
labels:
app: hanzo-web3
spec:
type: ClusterIP
ports:
- name: http
port: 3001
targetPort: http
protocol: TCP
selector:
app: hanzo-web3
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: hanzo-web3-hpa
namespace: bootnode
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: hanzo-web3
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
+1 -1
View File
@@ -26,7 +26,7 @@ spec:
spec:
containers:
- name: api
image: ghcr.io/hanzoai/bootnode:api-latest
image: ghcr.io/hanzoai/bootnode:latest
imagePullPolicy: Always
ports:
- name: http
+334 -14
View File
@@ -1,7 +1,12 @@
# Bootnode ingress — multi-brand API + web portals
# All API domains → bootnode-api:80
# Each web domain → brand-specific bootnode-web-{brand}:3001
---
# ========== Hanzo Web3 ==========
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hanzo-web3-ingress
name: bootnode-hanzo-ingress
namespace: bootnode
annotations:
kubernetes.io/ingress.class: nginx
@@ -10,15 +15,13 @@ metadata:
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
nginx.ingress.kubernetes.io/rate-limit: "100"
nginx.ingress.kubernetes.io/rate-limit-window: "1s"
spec:
ingressClassName: nginx
tls:
- hosts:
- web3.hanzo.ai
- api.web3.hanzo.ai
secretName: hanzo-web3-tls
secretName: bootnode-hanzo-tls
rules:
- host: web3.hanzo.ai
http:
@@ -27,7 +30,7 @@ spec:
pathType: Prefix
backend:
service:
name: hanzo-web3
name: bootnode-web-hanzo
port:
number: 3001
- host: api.web3.hanzo.ai
@@ -44,7 +47,7 @@ spec:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hanzo-web3-ws-ingress
name: bootnode-hanzo-ws-ingress
namespace: bootnode
annotations:
kubernetes.io/ingress.class: nginx
@@ -59,7 +62,7 @@ spec:
tls:
- hosts:
- ws.web3.hanzo.ai
secretName: hanzo-web3-ws-tls
secretName: bootnode-hanzo-ws-tls
rules:
- host: ws.web3.hanzo.ai
http:
@@ -72,10 +75,230 @@ spec:
port:
number: 80
---
# ========== Lux Web3 ==========
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bootnode-lux-ingress
namespace: bootnode
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
spec:
ingressClassName: nginx
tls:
- hosts:
- web3.lux.network
- api.web3.lux.network
secretName: bootnode-lux-tls
rules:
- host: web3.lux.network
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bootnode-web-lux
port:
number: 3001
- host: api.web3.lux.network
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bootnode-api
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bootnode-ingress
name: bootnode-lux-ws-ingress
namespace: bootnode
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/upstream-hash-by: "$remote_addr"
nginx.ingress.kubernetes.io/websocket-services: "bootnode-api"
spec:
ingressClassName: nginx
tls:
- hosts:
- ws.web3.lux.network
secretName: bootnode-lux-ws-tls
rules:
- host: ws.web3.lux.network
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bootnode-api
port:
number: 80
---
# ========== Zoo Web3 ==========
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bootnode-zoo-ingress
namespace: bootnode
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
spec:
ingressClassName: nginx
tls:
- hosts:
- web3.zoo.ngo
- api.web3.zoo.ngo
secretName: bootnode-zoo-tls
rules:
- host: web3.zoo.ngo
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bootnode-web-zoo
port:
number: 3001
- host: api.web3.zoo.ngo
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bootnode-api
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bootnode-zoo-ws-ingress
namespace: bootnode
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/upstream-hash-by: "$remote_addr"
nginx.ingress.kubernetes.io/websocket-services: "bootnode-api"
spec:
ingressClassName: nginx
tls:
- hosts:
- ws.web3.zoo.ngo
secretName: bootnode-zoo-ws-tls
rules:
- host: ws.web3.zoo.ngo
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bootnode-api
port:
number: 80
---
# ========== Pars Web3 ==========
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bootnode-pars-ingress
namespace: bootnode
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
spec:
ingressClassName: nginx
tls:
- hosts:
- web3.pars.id
- api.web3.pars.id
secretName: bootnode-pars-tls
rules:
- host: web3.pars.id
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bootnode-web-pars
port:
number: 3001
- host: api.web3.pars.id
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bootnode-api
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bootnode-pars-ws-ingress
namespace: bootnode
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/upstream-hash-by: "$remote_addr"
nginx.ingress.kubernetes.io/websocket-services: "bootnode-api"
spec:
ingressClassName: nginx
tls:
- hosts:
- ws.web3.pars.id
secretName: bootnode-pars-ws-tls
rules:
- host: ws.web3.pars.id
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bootnode-api
port:
number: 80
---
# ========== Bootnode brand (bootno.de) ==========
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bootnode-brand-ingress
namespace: bootnode
annotations:
kubernetes.io/ingress.class: nginx
@@ -84,15 +307,13 @@ metadata:
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
nginx.ingress.kubernetes.io/rate-limit: "100"
nginx.ingress.kubernetes.io/rate-limit-window: "1s"
spec:
ingressClassName: nginx
tls:
- hosts:
- bootno.de
- api.bootno.de
secretName: bootnode-tls
secretName: bootnode-brand-tls
rules:
- host: bootno.de
http:
@@ -101,7 +322,7 @@ spec:
pathType: Prefix
backend:
service:
name: bootnode-web
name: bootnode-web-hanzo
port:
number: 3001
- host: api.bootno.de
@@ -118,7 +339,7 @@ spec:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bootnode-ws-ingress
name: bootnode-brand-ws-ingress
namespace: bootnode
annotations:
kubernetes.io/ingress.class: nginx
@@ -133,7 +354,7 @@ spec:
tls:
- hosts:
- ws.bootno.de
secretName: bootnode-ws-tls
secretName: bootnode-brand-ws-tls
rules:
- host: ws.bootno.de
http:
@@ -145,3 +366,102 @@ spec:
name: bootnode-api
port:
number: 80
---
# ========== Bootnode Docs (docs.bootno.de) ==========
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bootnode-docs-ingress
namespace: bootnode
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- docs.bootno.de
secretName: bootnode-docs-tls
rules:
- host: docs.bootno.de
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bootnode-docs
port:
number: 3005
---
# ========== Lux Cloud (cloud.lux.network) ==========
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bootnode-lux-cloud-ingress
namespace: bootnode
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
spec:
ingressClassName: nginx
tls:
- hosts:
- cloud.lux.network
- api.cloud.lux.network
secretName: bootnode-lux-cloud-tls
rules:
- host: cloud.lux.network
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bootnode-web-lux
port:
number: 3001
- host: api.cloud.lux.network
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bootnode-api
port:
number: 80
---
# ========== Lux Build (lux.build) ==========
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bootnode-lux-build-ingress
namespace: bootnode
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
ingressClassName: nginx
tls:
- hosts:
- lux.build
secretName: bootnode-lux-build-tls
rules:
- host: lux.build
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bootnode-web-lux
port:
number: 3001
+6 -1
View File
@@ -6,9 +6,14 @@ namespace: bootnode
resources:
- namespace.yaml
- secrets.yaml
- external-secrets.yaml
- api-deployment.yaml
- web-deployment.yaml
- web-deployments.yaml
- docs-deployment.yaml
- indexer-deployment.yaml
- ingress.yaml
- monitoring.yaml
- network-policies.yaml
commonLabels:
app.kubernetes.io/part-of: bootnode
+378
View File
@@ -0,0 +1,378 @@
---
# Cluster CRD — generic blockchain node fleet
# Managed by bootnode-operator, works for any chain.
# Must match operator/src/crd.rs ClusterSpec
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: clusters.bootnode.dev
spec:
group: bootnode.dev
names:
kind: Cluster
plural: clusters
singular: cluster
shortNames:
- bc
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- name: Chain
type: string
jsonPath: .spec.chain
- name: Network
type: string
jsonPath: .spec.network
- name: Phase
type: string
jsonPath: .status.phase
- name: Ready
type: integer
jsonPath: .status.readyNodes
- name: Total
type: integer
jsonPath: .status.totalNodes
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [chain, network, replicas]
properties:
chain:
type: string
description: "Chain type: lux, avalanche, ethereum, bitcoin"
network:
type: string
description: "Network: mainnet, testnet, devnet, etc."
replicas:
type: integer
minimum: 1
maximum: 100
image:
type: object
properties:
repository:
type: string
tag:
type: string
pullPolicy:
type: string
pullSecrets:
type: array
items:
type: string
resources:
type: object
properties:
cpuRequest:
type: string
cpuLimit:
type: string
memoryRequest:
type: string
memoryLimit:
type: string
storage:
type: object
properties:
storageClass:
type: string
size:
type: string
ports:
type: array
items:
type: object
required: [name, port]
properties:
name:
type: string
port:
type: integer
protocol:
type: string
service:
type: object
properties:
serviceType:
type: string
annotations:
type: object
additionalProperties:
type: string
perNodeLb:
type: boolean
bootstrap:
type: object
properties:
useHostnames:
type: boolean
externalIps:
type: array
items:
type: string
health:
type: object
properties:
path:
type: string
method:
type: string
portName:
type: string
gracePeriodSeconds:
type: integer
checkIntervalSeconds:
type: integer
maxHeightSkew:
type: integer
upgradeStrategy:
type: object
properties:
strategyType:
type: string
maxUnavailable:
type: integer
healthCheckBetweenRestarts:
type: boolean
stabilizationSeconds:
type: integer
startupGate:
type: object
properties:
enabled:
type: boolean
minPeers:
type: integer
timeoutSeconds:
type: integer
checkIntervalSeconds:
type: integer
allowScaleDown:
type: boolean
chainConfig:
type: object
x-kubernetes-preserve-unknown-fields: true
description: "Opaque chain-specific config interpreted by the driver"
genesis:
type: object
x-kubernetes-preserve-unknown-fields: true
genesisConfigMap:
type: string
config:
type: object
additionalProperties:
x-kubernetes-preserve-unknown-fields: true
init:
type: object
properties:
clearData:
type: boolean
plugins:
type: array
items:
type: object
properties:
vmId:
type: string
binary:
type: string
startupScript:
type: string
security:
type: object
properties:
runAsUser:
type: integer
runAsGroup:
type: integer
fsGroup:
type: integer
adoptExisting:
type: boolean
staking:
type: object
properties:
secretName:
type: string
kms:
type: object
properties:
provider:
type: string
keyIds:
type: array
items:
type: string
seedRestore:
type: object
properties:
source:
type: string
restorePolicy:
type: string
objectStoreUrl:
type: string
snapshotName:
type: string
sourcePvc:
type: string
status:
type: object
properties:
phase:
type: string
totalNodes:
type: integer
readyNodes:
type: integer
lastReconciled:
type: string
observedGeneration:
type: integer
nodes:
type: array
items:
type: object
properties:
podName:
type: string
nodeId:
type: string
externalIp:
type: string
healthy:
type: boolean
bootstrapped:
type: boolean
connectedPeers:
type: integer
inboundPeers:
type: integer
outboundPeers:
type: integer
chainsCount:
type: integer
extra:
type: object
x-kubernetes-preserve-unknown-fields: true
degradedConditions:
type: array
items:
type: object
properties:
node:
type: string
reason:
type: string
message:
type: string
since:
type: string
metrics:
type: object
properties:
minPHeight:
type: integer
maxPHeight:
type: integer
heightSkew:
type: integer
bootstrappedChains:
type: integer
totalPeers:
type: integer
conditions:
type: array
items:
type: object
properties:
type:
type: string
status:
type: string
reason:
type: string
message:
type: string
lastTransitionTime:
type: string
---
# ChainDeployment CRD — individual subnet/L2 chain deployments
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: chaindeployments.bootnode.dev
spec:
group: bootnode.dev
names:
kind: ChainDeployment
plural: chaindeployments
singular: chaindeployment
shortNames:
- cd
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- name: Cluster
type: string
jsonPath: .spec.clusterRef
- name: ChainID
type: string
jsonPath: .spec.chainId
- name: Status
type: string
jsonPath: .status.phase
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [clusterRef]
properties:
clusterRef:
type: string
chainId:
type: string
blockchainId:
type: string
alias:
type: string
config:
type: object
x-kubernetes-preserve-unknown-fields: true
status:
type: object
properties:
phase:
type: string
chainStatus:
type: object
additionalProperties:
type: object
properties:
synced:
type: boolean
blockHeight:
type: integer
vmId:
type: string
+43 -7
View File
@@ -3,7 +3,8 @@
#
# Usage:
# ./deploy-subgraph.sh # Deploy all subgraphs
# ./deploy-subgraph.sh amm-v3 # Deploy AMM V3 only
# ./deploy-subgraph.sh amm-v3 # Deploy Lux AMM V3 only
# ./deploy-subgraph.sh zoo-amm-v3 # Deploy Zoo AMM V3 only
# ./deploy-subgraph.sh amm-v3 --wipe # Wipe and redeploy AMM V3
#
# Prerequisites:
@@ -73,9 +74,33 @@ check_sync() {
case "$SUBGRAPH" in
amm-v3)
deploy_subgraph "amm-v3" "$EXCHANGE_DIR/subgraphs/dex-v3" "1.8.0"
deploy_subgraph "amm-v3" "$EXCHANGE_DIR/subgraphs/dex-v3" "2.1.0"
check_sync "amm-v3"
;;
zoo-amm-v3)
deploy_subgraph "zoo-amm-v3" "$EXCHANGE_DIR/subgraphs/zoo-v3" "1.0.0"
check_sync "zoo-amm-v3"
;;
pars-amm-v2)
if [ -d "$EXCHANGE_DIR/subgraphs/pars-v2" ]; then
deploy_subgraph "pars-amm-v2" "$EXCHANGE_DIR/subgraphs/pars-v2" "1.0.0"
check_sync "pars-amm-v2"
else
echo "Pars AMM V2 subgraph not found at $EXCHANGE_DIR/subgraphs/pars-v2"
fi
;;
hanzo-amm-v2)
if [ -d "$EXCHANGE_DIR/subgraphs/hanzo-v2" ]; then
deploy_subgraph "hanzo-amm-v2" "$EXCHANGE_DIR/subgraphs/hanzo-v2" "1.0.0"
check_sync "hanzo-amm-v2"
else
echo "Hanzo AMM V2 subgraph not found at $EXCHANGE_DIR/subgraphs/hanzo-v2"
fi
;;
zoo-amm-v2)
deploy_subgraph "zoo-amm-v2" "$EXCHANGE_DIR/subgraphs/zoo-v2" "1.0.0"
check_sync "zoo-amm-v2"
;;
amm-v2)
if [ -d "$EXCHANGE_DIR/subgraphs/dex-factory" ]; then
deploy_subgraph "amm-v2" "$EXCHANGE_DIR/subgraphs/dex-factory" "1.0.0"
@@ -87,17 +112,28 @@ case "$SUBGRAPH" in
status)
check_sync "amm-v3"
check_sync "amm-v2"
check_sync "zoo-amm-v3"
check_sync "zoo-amm-v2"
check_sync "pars-amm-v2"
check_sync "hanzo-amm-v2"
;;
all)
deploy_subgraph "amm-v3" "$EXCHANGE_DIR/subgraphs/dex-v3" "1.8.0"
if [ -d "$EXCHANGE_DIR/subgraphs/dex-factory" ]; then
deploy_subgraph "amm-v2" "$EXCHANGE_DIR/subgraphs/dex-factory" "1.0.0"
fi
deploy_subgraph "amm-v3" "$EXCHANGE_DIR/subgraphs/dex-v3" "2.1.0"
deploy_subgraph "amm-v2" "$EXCHANGE_DIR/subgraphs/dex-factory" "1.0.0"
deploy_subgraph "zoo-amm-v3" "$EXCHANGE_DIR/subgraphs/zoo-v3" "1.0.0"
deploy_subgraph "zoo-amm-v2" "$EXCHANGE_DIR/subgraphs/zoo-v2" "1.0.0"
deploy_subgraph "pars-amm-v2" "$EXCHANGE_DIR/subgraphs/pars-v2" "1.0.0"
deploy_subgraph "hanzo-amm-v2" "$EXCHANGE_DIR/subgraphs/hanzo-v2" "1.0.0"
echo "=== Sync Status ==="
check_sync "amm-v3"
check_sync "amm-v2"
check_sync "zoo-amm-v3"
check_sync "zoo-amm-v2"
check_sync "pars-amm-v2"
check_sync "hanzo-amm-v2"
;;
*)
echo "Usage: $0 {amm-v3|amm-v2|all|status} [--wipe]"
echo "Usage: $0 {amm-v3|zoo-amm-v3|zoo-amm-v2|amm-v2|pars-amm-v2|hanzo-amm-v2|all|status} [--wipe]"
exit 1
;;
esac
+24
View File
@@ -30,6 +30,30 @@ data:
provider = [
{ label = "lux-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/ext/bc/C/rpc", features = ["archive"] }
]
[chains.zoo]
shard = "primary"
provider = [
{ label = "zoo-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/ext/bc/2Y625Kvdd4wZ5LKx9kLSf1jGALyARmXVN1YsTb4F3Hzv4bPDbp/rpc", features = ["archive"] }
]
[chains.pars]
shard = "primary"
provider = [
{ label = "pars-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/ext/bc/2pUskxqaL5Bpx7uRUGG1fDjPckjxQ4UKX4sLKeaS1NdSVBJd3F/rpc", features = ["archive"] }
]
[chains.hanzo]
shard = "primary"
provider = [
{ label = "hanzo-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/ext/bc/2GiQb73CeJESjc4omFv2YtQHZrRgJf25NXPzAr5J6UNHRcDV2F/rpc", features = ["archive"] }
]
[chains.spc]
shard = "primary"
provider = [
{ label = "spc-mainnet", url = "http://luxd-0.luxd-headless.lux-mainnet.svc:9630/ext/bc/rtjwvtE1tEvrokmpeYdTq7b2zqZgmybKwR5MLjKMGAR1W78dQ/rpc", features = ["archive"] }
]
---
apiVersion: apps/v1
kind: StatefulSet
+93
View File
@@ -0,0 +1,93 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bootnode-operator
namespace: bootnode-system
labels:
app: bootnode-operator
spec:
replicas: 1
selector:
matchLabels:
app: bootnode-operator
template:
metadata:
labels:
app: bootnode-operator
spec:
serviceAccountName: bootnode-operator
containers:
- name: operator
image: ghcr.io/hanzoai/bootnode:operator-latest
args:
- "--namespace"
- "" # watch all namespaces
- "--log-level"
- "info"
ports:
- name: metrics
containerPort: 8080
protocol: TCP
- name: api
containerPort: 8081
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: api
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: api
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
env:
- name: RUST_LOG
value: "info,bootnode_operator=debug"
---
apiVersion: v1
kind: Service
metadata:
name: bootnode-operator
namespace: bootnode-system
labels:
app: bootnode-operator
spec:
selector:
app: bootnode-operator
ports:
- name: metrics
port: 8080
targetPort: metrics
- name: api
port: 8081
targetPort: api
---
apiVersion: v1
kind: Service
metadata:
name: bootnode-operator-metrics
namespace: bootnode-system
labels:
app: bootnode-operator
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
spec:
selector:
app: bootnode-operator
ports:
- name: metrics
port: 8080
targetPort: metrics
+64
View File
@@ -0,0 +1,64 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: bootnode-system
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: bootnode-operator
namespace: bootnode-system
---
# The operator needs broad access to manage resources across namespaces
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: bootnode-operator
rules:
# CRD management
- apiGroups: ["bootnode.dev"]
resources: ["clusters", "clusters/status", "chaindeployments", "chaindeployments/status"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Core resources for fleet reconciliation
- apiGroups: [""]
resources: ["pods", "pods/log", "pods/exec"]
verbs: ["get", "list", "watch", "delete"]
- apiGroups: [""]
resources: ["services", "configmaps", "secrets", "serviceaccounts", "namespaces"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "watch"]
# StatefulSets and PDBs
- apiGroups: ["apps"]
resources: ["statefulsets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["policy"]
resources: ["poddisruptionbudgets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# RBAC for per-namespace service accounts
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["roles", "rolebindings"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Events for status reporting
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
# Coordination for leader election
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: bootnode-operator
subjects:
- kind: ServiceAccount
name: bootnode-operator
namespace: bootnode-system
roleRef:
kind: ClusterRole
name: bootnode-operator
apiGroup: rbac.authorization.k8s.io
+238
View File
@@ -0,0 +1,238 @@
# Bootnode monitoring — ServiceMonitor, Grafana dashboard, PrometheusRules
---
# ServiceMonitor for bootnode-api (Prometheus scraping)
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: bootnode-api
namespace: bootnode
labels:
app: bootnode-api
release: prometheus
spec:
selector:
matchLabels:
app: bootnode-api
endpoints:
- port: http
path: /metrics
interval: 30s
scrapeTimeout: 10s
namespaceSelector:
matchNames:
- bootnode
---
# PrometheusRule — bootnode alerts
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: bootnode-alerts
namespace: bootnode
labels:
app: bootnode
release: prometheus
spec:
groups:
- name: bootnode.rules
rules:
- alert: BootnodeAPIHighErrorRate
expr: |
sum(rate(http_server_requests_total{namespace="bootnode",status=~"5.."}[5m]))
/
sum(rate(http_server_requests_total{namespace="bootnode"}[5m]))
> 0.05
for: 5m
labels:
severity: critical
service: bootnode-api
annotations:
summary: "Bootnode API error rate above 5%"
description: "Error rate is {{ $value | humanizePercentage }} over the last 5 minutes."
- alert: BootnodeAPIHighLatency
expr: |
histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket{namespace="bootnode"}[5m])) by (le))
> 1.0
for: 10m
labels:
severity: warning
service: bootnode-api
annotations:
summary: "Bootnode API p95 latency above 1s"
description: "p95 latency is {{ $value | humanizeDuration }} over the last 10 minutes."
- alert: BootnodeServiceDown
expr: |
kube_deployment_status_replicas_available{namespace="bootnode"}
/
kube_deployment_spec_replicas{namespace="bootnode"}
< 1
for: 3m
labels:
severity: critical
service: "{{ $labels.deployment }}"
annotations:
summary: "Bootnode service {{ $labels.deployment }} has no ready replicas"
description: "{{ $labels.deployment }} has 0 available replicas for 3+ minutes."
- alert: BootnodeFleetDegraded
expr: |
kube_statefulset_status_replicas_ready{namespace=~"lux-.*"}
<
kube_statefulset_spec_replicas{namespace=~"lux-.*"}
for: 5m
labels:
severity: warning
service: "{{ $labels.statefulset }}"
annotations:
summary: "Validator fleet {{ $labels.statefulset }} degraded"
description: "{{ $labels.statefulset }} has {{ $value }} ready replicas vs spec."
---
# Grafana dashboard ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: bootnode-grafana-dashboard
namespace: bootnode
labels:
grafana_dashboard: "1"
data:
bootnode-overview.json: |
{
"dashboard": {
"title": "Bootnode Overview",
"uid": "bootnode-overview",
"timezone": "utc",
"refresh": "30s",
"time": {"from": "now-6h", "to": "now"},
"templating": {
"list": [
{"name": "ds_datastore", "type": "datasource", "query": "grafana-clickhouse-datasource", "current": {"text": "ClickHouse", "value": "ClickHouse"}},
{"name": "ds_prometheus", "type": "datasource", "query": "prometheus", "current": {"text": "Prometheus", "value": "Prometheus"}}
]
},
"panels": [
{
"title": "RPC Requests/sec",
"type": "stat",
"gridPos": {"h": 4, "w": 6, "x": 0, "y": 0},
"targets": [{
"datasource": {"uid": "${ds_datastore}"},
"rawSql": "SELECT count() / 300 AS rps FROM bootnode.api_usage WHERE timestamp >= now() - INTERVAL 5 MINUTE",
"format": "table"
}]
},
{
"title": "Latency p50 / p95 / p99",
"type": "stat",
"gridPos": {"h": 4, "w": 6, "x": 6, "y": 0},
"targets": [{
"datasource": {"uid": "${ds_datastore}"},
"rawSql": "SELECT quantile(0.50)(response_time_ms) AS p50, quantile(0.95)(response_time_ms) AS p95, quantile(0.99)(response_time_ms) AS p99 FROM bootnode.api_usage WHERE timestamp >= now() - INTERVAL 5 MINUTE",
"format": "table"
}]
},
{
"title": "Error Rate",
"type": "gauge",
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
"targets": [{
"datasource": {"uid": "${ds_datastore}"},
"rawSql": "SELECT countIf(status_code >= 500) / greatest(count(), 1) * 100 AS error_pct FROM bootnode.api_usage WHERE timestamp >= now() - INTERVAL 5 MINUTE",
"format": "table"
}],
"fieldConfig": {"defaults": {"max": 100, "min": 0, "unit": "percent", "thresholds": {"steps": [{"value": 0, "color": "green"}, {"value": 1, "color": "yellow"}, {"value": 5, "color": "red"}]}}}
},
{
"title": "Total Requests (24h)",
"type": "stat",
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 0},
"targets": [{
"datasource": {"uid": "${ds_datastore}"},
"rawSql": "SELECT count() AS total FROM bootnode.api_usage WHERE timestamp >= now() - INTERVAL 24 HOUR",
"format": "table"
}]
},
{
"title": "Requests Over Time",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 4},
"targets": [{
"datasource": {"uid": "${ds_datastore}"},
"rawSql": "SELECT toStartOfMinute(timestamp) AS time, count() AS requests, countIf(status_code >= 500) AS errors FROM bootnode.api_usage WHERE timestamp >= now() - INTERVAL 6 HOUR GROUP BY time ORDER BY time",
"format": "time_series"
}]
},
{
"title": "Latency Over Time",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 4},
"targets": [{
"datasource": {"uid": "${ds_datastore}"},
"rawSql": "SELECT toStartOfMinute(timestamp) AS time, quantile(0.50)(response_time_ms) AS p50, quantile(0.95)(response_time_ms) AS p95, quantile(0.99)(response_time_ms) AS p99 FROM bootnode.api_usage WHERE timestamp >= now() - INTERVAL 6 HOUR GROUP BY time ORDER BY time",
"format": "time_series"
}]
},
{
"title": "OTEL Traces (Bootnode)",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 12},
"targets": [{
"datasource": {"uid": "${ds_datastore}"},
"rawSql": "SELECT toStartOfMinute(Timestamp) AS time, count() AS spans, countIf(StatusCode = 'STATUS_CODE_ERROR') AS errors FROM otel.otel_traces WHERE ServiceName = 'bootnode-api' AND Timestamp >= now() - INTERVAL 6 HOUR GROUP BY time ORDER BY time",
"format": "time_series"
}]
},
{
"title": "OTEL Trace Latency (p50/p95/p99)",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 12},
"targets": [{
"datasource": {"uid": "${ds_datastore}"},
"rawSql": "SELECT toStartOfMinute(Timestamp) AS time, quantile(0.50)(Duration / 1000000) AS p50_ms, quantile(0.95)(Duration / 1000000) AS p95_ms, quantile(0.99)(Duration / 1000000) AS p99_ms FROM otel.otel_traces WHERE ServiceName = 'bootnode-api' AND SpanKind = 'SPAN_KIND_SERVER' AND Timestamp >= now() - INTERVAL 6 HOUR GROUP BY time ORDER BY time",
"format": "time_series"
}]
},
{
"title": "Top Endpoints (Usage)",
"type": "table",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 20},
"targets": [{
"datasource": {"uid": "${ds_datastore}"},
"rawSql": "SELECT endpoint, count() AS calls, round(avg(response_time_ms), 1) AS avg_ms, round(quantile(0.95)(response_time_ms), 1) AS p95_ms, sum(compute_units) AS total_cu FROM bootnode.api_usage WHERE timestamp >= now() - INTERVAL 24 HOUR GROUP BY endpoint ORDER BY calls DESC LIMIT 20",
"format": "table"
}]
},
{
"title": "OTEL Logs (Errors)",
"type": "table",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 20},
"targets": [{
"datasource": {"uid": "${ds_datastore}"},
"rawSql": "SELECT Timestamp, SeverityText, Body FROM otel.otel_logs WHERE ServiceName = 'bootnode-api' AND SeverityNumber >= 17 AND Timestamp >= now() - INTERVAL 1 HOUR ORDER BY Timestamp DESC LIMIT 50",
"format": "table"
}]
},
{
"title": "Fleet Health",
"type": "stat",
"gridPos": {"h": 4, "w": 12, "x": 0, "y": 28},
"targets": [{
"datasource": {"uid": "${ds_prometheus}"},
"expr": "sum(kube_deployment_status_replicas_available{namespace=\"bootnode\"}) / sum(kube_deployment_spec_replicas{namespace=\"bootnode\"}) * 100"
}],
"fieldConfig": {"defaults": {"unit": "percent", "thresholds": {"steps": [{"value": 0, "color": "red"}, {"value": 80, "color": "yellow"}, {"value": 95, "color": "green"}]}}}
},
{
"title": "Validator Fleet Health",
"type": "stat",
"gridPos": {"h": 4, "w": 12, "x": 12, "y": 28},
"targets": [{
"datasource": {"uid": "${ds_prometheus}"},
"expr": "sum(kube_statefulset_status_replicas_ready{namespace=~\"lux-.*\"}) / sum(kube_statefulset_spec_replicas{namespace=~\"lux-.*\"}) * 100"
}],
"fieldConfig": {"defaults": {"unit": "percent", "thresholds": {"steps": [{"value": 0, "color": "red"}, {"value": 80, "color": "yellow"}, {"value": 95, "color": "green"}]}}}
}
]
}
}
+186
View File
@@ -0,0 +1,186 @@
# Bootnode Network Policies — Defense in Depth
#
# Architecture:
# Internet → Ingress → bootnode-api / bootnode-web-* / bootnode-docs
# bootnode-api → postgres, redis, datastore (internal only)
# bootnode-api → otel-collector.hanzo.svc (cross-namespace, traces/metrics)
# bootnode-api → api.hanzo.ai (external, LLM gateway for AI chat)
---
# -- Default Deny All Ingress --------------------------------------------------
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: bootnode
labels:
app.kubernetes.io/part-of: bootnode
app.kubernetes.io/component: network-policy
spec:
podSelector: {}
policyTypes:
- Ingress
---
# -- Allow Ingress from Nginx Ingress Controller --------------------------------
# Web portals and docs accept traffic from ingress controller
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-controller
namespace: bootnode
labels:
app.kubernetes.io/part-of: bootnode
app.kubernetes.io/component: network-policy
spec:
podSelector:
matchExpressions:
- key: app
operator: In
values:
- bootnode-api
- bootnode-web-hanzo
- bootnode-web-lux
- bootnode-web-zoo
- bootnode-web-pars
- bootnode-docs
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector: {}
podSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
---
# -- Intra-namespace: pods can talk to each other --------------------------------
# API → postgres, redis, datastore within bootnode namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-intra-namespace
namespace: bootnode
labels:
app.kubernetes.io/part-of: bootnode
app.kubernetes.io/component: network-policy
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- podSelector: {}
---
# -- Database Access: restrict to API only ----------------------------------------
# Only bootnode-api and bootnode-indexer can reach postgres
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: restrict-postgres-access
namespace: bootnode
labels:
app.kubernetes.io/part-of: bootnode
app.kubernetes.io/component: network-policy
spec:
podSelector:
matchLabels:
app: bootnode-postgres
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchExpressions:
- key: app
operator: In
values:
- bootnode-api
- bootnode-indexer
ports:
- protocol: TCP
port: 5432
---
# -- Redis Access: restrict to API only -------------------------------------------
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: restrict-redis-access
namespace: bootnode
labels:
app.kubernetes.io/part-of: bootnode
app.kubernetes.io/component: network-policy
spec:
podSelector:
matchLabels:
app: bootnode-redis
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchExpressions:
- key: app
operator: In
values:
- bootnode-api
- bootnode-indexer
ports:
- protocol: TCP
port: 6379
---
# -- Allow Prometheus/OTEL scraping from hanzo namespace --------------------------
# Hanzo OTEL collector and Prometheus need to scrape bootnode-api /metrics
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-monitoring-scrape
namespace: bootnode
labels:
app.kubernetes.io/part-of: bootnode
app.kubernetes.io/component: network-policy
spec:
podSelector:
matchLabels:
app: bootnode-api
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: hanzo
podSelector:
matchExpressions:
- key: app
operator: In
values:
- otel-collector
- prometheus
- grafana
ports:
- protocol: TCP
port: 8000
---
# -- Egress: API can reach hanzo namespace (OTEL collector, IAM, LLM) ------------
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-egress-hanzo
namespace: bootnode
labels:
app.kubernetes.io/part-of: bootnode
app.kubernetes.io/component: network-policy
spec:
podSelector:
matchLabels:
app: bootnode-api
policyTypes:
- Egress
egress:
# Allow all egress from API (needs external RPCs, LLM gateway, etc.)
- {}
+370
View File
@@ -0,0 +1,370 @@
# Bootnode web portals — one universal image, brand selected at runtime
# Each org gets its own deployment with brand-specific env vars
---
# ========== Hanzo Portal (web3.hanzo.ai) ==========
apiVersion: apps/v1
kind: Deployment
metadata:
name: bootnode-web-hanzo
namespace: bootnode
labels:
app: bootnode-web-hanzo
component: frontend
brand: hanzo
spec:
replicas: 2
selector:
matchLabels:
app: bootnode-web-hanzo
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: bootnode-web-hanzo
component: frontend
brand: hanzo
spec:
containers:
- name: web
image: ghcr.io/hanzoai/bootnode-web:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 3001
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: NEXT_PUBLIC_BRAND
value: "hanzo"
- name: NEXT_PUBLIC_API_URL
value: "https://api.cloud.hanzo.ai"
- name: NEXT_PUBLIC_WS_URL
value: "wss://ws.cloud.hanzo.ai"
- name: NEXT_PUBLIC_IAM_URL
value: "https://hanzo.id"
- name: NEXT_PUBLIC_IAM_CLIENT_ID
value: "hanzo-web3"
- name: NEXT_PUBLIC_IAM_DOMAIN
value: "hanzo.id"
- name: NEXT_PUBLIC_AUTH_MODE
value: "iam"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
securityContext:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
imagePullSecrets:
- name: ghcr-secret
---
apiVersion: v1
kind: Service
metadata:
name: bootnode-web-hanzo
namespace: bootnode
spec:
type: ClusterIP
ports:
- name: http
port: 3001
targetPort: http
selector:
app: bootnode-web-hanzo
---
# ========== Lux Portal (web3.lux.network) ==========
apiVersion: apps/v1
kind: Deployment
metadata:
name: bootnode-web-lux
namespace: bootnode
labels:
app: bootnode-web-lux
component: frontend
brand: lux
spec:
replicas: 2
selector:
matchLabels:
app: bootnode-web-lux
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: bootnode-web-lux
component: frontend
brand: lux
spec:
containers:
- name: web
image: ghcr.io/hanzoai/bootnode-web:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 3001
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: NEXT_PUBLIC_BRAND
value: "lux"
- name: NEXT_PUBLIC_API_URL
value: "https://api.web3.lux.network"
- name: NEXT_PUBLIC_WS_URL
value: "wss://ws.web3.lux.network"
- name: NEXT_PUBLIC_IAM_URL
value: "https://lux.id"
- name: NEXT_PUBLIC_IAM_CLIENT_ID
value: "lux-web3"
- name: NEXT_PUBLIC_IAM_DOMAIN
value: "lux.id"
- name: NEXT_PUBLIC_AUTH_MODE
value: "iam"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
securityContext:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
imagePullSecrets:
- name: ghcr-secret
---
apiVersion: v1
kind: Service
metadata:
name: bootnode-web-lux
namespace: bootnode
spec:
type: ClusterIP
ports:
- name: http
port: 3001
targetPort: http
selector:
app: bootnode-web-lux
---
# ========== Zoo Portal (web3.zoo.ngo) ==========
apiVersion: apps/v1
kind: Deployment
metadata:
name: bootnode-web-zoo
namespace: bootnode
labels:
app: bootnode-web-zoo
component: frontend
brand: zoo
spec:
replicas: 2
selector:
matchLabels:
app: bootnode-web-zoo
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: bootnode-web-zoo
component: frontend
brand: zoo
spec:
containers:
- name: web
image: ghcr.io/hanzoai/bootnode-web:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 3001
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: NEXT_PUBLIC_BRAND
value: "zoo"
- name: NEXT_PUBLIC_API_URL
value: "https://api.cloud.zoo.network"
- name: NEXT_PUBLIC_WS_URL
value: "wss://ws.cloud.zoo.network"
- name: NEXT_PUBLIC_IAM_URL
value: "https://id.zoo.network"
- name: NEXT_PUBLIC_IAM_CLIENT_ID
value: "zoo-web3"
- name: NEXT_PUBLIC_IAM_DOMAIN
value: "id.zoo.network"
- name: NEXT_PUBLIC_AUTH_MODE
value: "iam"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
securityContext:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
imagePullSecrets:
- name: ghcr-secret
---
apiVersion: v1
kind: Service
metadata:
name: bootnode-web-zoo
namespace: bootnode
spec:
type: ClusterIP
ports:
- name: http
port: 3001
targetPort: http
selector:
app: bootnode-web-zoo
---
# ========== Pars Portal (web3.pars.id) ==========
apiVersion: apps/v1
kind: Deployment
metadata:
name: bootnode-web-pars
namespace: bootnode
labels:
app: bootnode-web-pars
component: frontend
brand: pars
spec:
replicas: 2
selector:
matchLabels:
app: bootnode-web-pars
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: bootnode-web-pars
component: frontend
brand: pars
spec:
containers:
- name: web
image: ghcr.io/hanzoai/bootnode-web:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 3001
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: NEXT_PUBLIC_BRAND
value: "pars"
- name: NEXT_PUBLIC_API_URL
value: "https://api.cloud.pars.network"
- name: NEXT_PUBLIC_WS_URL
value: "wss://ws.cloud.pars.network"
- name: NEXT_PUBLIC_IAM_URL
value: "https://pars.id"
- name: NEXT_PUBLIC_IAM_CLIENT_ID
value: "pars-web3"
- name: NEXT_PUBLIC_IAM_DOMAIN
value: "pars.id"
- name: NEXT_PUBLIC_AUTH_MODE
value: "iam"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
securityContext:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
imagePullSecrets:
- name: ghcr-secret
---
apiVersion: v1
kind: Service
metadata:
name: bootnode-web-pars
namespace: bootnode
spec:
type: ClusterIP
ports:
- name: http
port: 3001
targetPort: http
selector:
app: bootnode-web-pars
+2
View File
@@ -0,0 +1,2 @@
# Rust build artifacts
target/
+3087
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "bootnode-operator"
version = "0.1.0"
edition = "2021"
license = "BSD-3-Clause"
description = "Generic blockchain node operator for Kubernetes"
[[bin]]
name = "bootnode-operator"
path = "src/main.rs"
[dependencies]
# Async runtime
tokio = { version = "1.35", features = ["full"] }
async-trait = "0.1"
futures = "0.3"
# Kubernetes
kube = { version = "0.87", features = ["runtime", "derive"] }
k8s-openapi = { version = "0.20", features = ["v1_28"] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# CLI
clap = { version = "4.4", features = ["derive", "env"] }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# HTTP
reqwest = { version = "0.11", default-features = false, features = ["json", "native-tls"] }
axum = "0.7"
# Utils
thiserror = "1.0"
anyhow = "1.0"
chrono = { version = "0.4", features = ["serde"] }
schemars = "0.8"
+52
View File
@@ -0,0 +1,52 @@
# Multi-stage build for bootnode-operator
# Stage 1: Build the Rust binary
FROM rust:1.88-bookworm AS builder
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Limit parallelism to reduce memory usage
ENV CARGO_BUILD_JOBS=2
# Copy manifest first for dependency caching
COPY Cargo.toml Cargo.lock ./
# Create dummy source for dependency caching
RUN mkdir -p src/drivers && \
echo "fn main() {}" > src/main.rs
# Build dependencies only (cached layer)
RUN cargo build --release 2>/dev/null || true
# Copy actual source code
COPY src/ src/
# Touch to invalidate cache
RUN touch src/main.rs
# Build the operator
RUN cargo build --release
# Stage 2: Minimal runtime image
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl3 \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -f bootnode && useradd -r -s /bin/false -g bootnode bootnode
COPY --from=builder /build/target/release/bootnode-operator /usr/local/bin/bootnode-operator
RUN chmod +x /usr/local/bin/bootnode-operator
USER bootnode
EXPOSE 8080 8081
ENTRYPOINT ["/usr/local/bin/bootnode-operator"]
+103
View File
@@ -0,0 +1,103 @@
//! REST API for imperative operator actions.
//!
//! Runs on :8081 alongside the controller. Provides endpoints for:
//! - Restarting unhealthy nodes
//! - Force health checks
//! - Per-node status
//! - Operator health
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use kube::{
api::{Api, DeleteParams, ListParams},
Client,
};
use k8s_openapi::api::core::v1::Pod;
use serde_json::json;
use std::sync::Arc;
pub struct ApiState {
pub client: Client,
}
pub fn router(state: Arc<ApiState>) -> Router {
Router::new()
.route("/healthz", get(healthz))
.route("/readyz", get(readyz))
.route("/api/v1/clusters/:namespace/:name/nodes", get(list_nodes))
.route("/api/v1/clusters/:namespace/:name/restart", post(restart_cluster))
.with_state(state)
}
async fn healthz() -> &'static str {
"ok"
}
async fn readyz() -> &'static str {
"ok"
}
async fn list_nodes(
State(state): State<Arc<ApiState>>,
Path((namespace, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let pods: Api<Pod> = Api::namespaced(state.client.clone(), &namespace);
let label = format!("app.kubernetes.io/instance={}", name);
let list = pods
.list(&ListParams::default().labels(&label))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let nodes: Vec<serde_json::Value> = list
.items
.iter()
.map(|pod| {
let name = pod.metadata.name.clone().unwrap_or_default();
let ip = pod.status.as_ref().and_then(|s| s.pod_ip.clone());
let phase = pod.status.as_ref().and_then(|s| s.phase.clone());
let ready = pod.status.as_ref()
.and_then(|s| s.conditions.as_ref())
.and_then(|c| c.iter().find(|c| c.type_ == "Ready"))
.map(|c| c.status == "True")
.unwrap_or(false);
json!({
"name": name,
"ip": ip,
"phase": phase,
"ready": ready,
})
})
.collect();
Ok(Json(json!({"nodes": nodes})))
}
async fn restart_cluster(
State(state): State<Arc<ApiState>>,
Path((namespace, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let pods: Api<Pod> = Api::namespaced(state.client.clone(), &namespace);
let label = format!("app.kubernetes.io/instance={}", name);
let list = pods
.list(&ListParams::default().labels(&label))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut restarted = Vec::new();
for pod in &list.items {
let pod_name = pod.metadata.name.clone().unwrap_or_default();
if pods.delete(&pod_name, &DeleteParams::default()).await.is_ok() {
restarted.push(pod_name);
}
}
Ok(Json(json!({
"restarted": restarted,
"count": restarted.len(),
})))
}
+646
View File
@@ -0,0 +1,646 @@
//! Generic reconciliation controller for Cluster and ChainDeployment CRDs.
//!
//! All chain-specific logic is delegated to [`ChainDriver`] implementations.
//! This module handles the state machine, K8s API interactions, health checks,
//! and recovery — identically for every blockchain.
use crate::crd::{
ChainDeployment, ChainDeploymentStatus, Cluster, ClusterStatus,
DegradedCondition, DegradedReason, NetworkMetrics, NodeStatus,
};
use crate::drivers::DriverRegistry;
use crate::error::{OperatorError, Result};
use crate::resources;
use futures::StreamExt;
use k8s_openapi::api::apps::v1::StatefulSet;
use k8s_openapi::api::core::v1::{ConfigMap, Pod, Service};
use k8s_openapi::api::policy::v1::PodDisruptionBudget;
use k8s_openapi::api::rbac::v1::{Role, RoleBinding};
use kube::{
api::{Api, DeleteParams, ListParams, Patch, PatchParams},
runtime::{
controller::{Action, Controller},
watcher::Config as WatcherConfig,
},
Client, ResourceExt,
};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use tracing::{error, info, warn};
/// Shared context for controllers.
pub struct Context {
pub client: Client,
pub drivers: DriverRegistry,
}
// ─── Controller Runners ───
pub async fn run_cluster_controller(client: Client, namespace: String) -> Result<()> {
let ctx = Arc::new(Context {
client: client.clone(),
drivers: DriverRegistry::new(),
});
let clusters: Api<Cluster> = if namespace.is_empty() {
Api::all(client.clone())
} else {
Api::namespaced(client.clone(), &namespace)
};
info!("Starting Cluster controller");
Controller::new(clusters, WatcherConfig::default())
.run(reconcile_cluster, cluster_error_policy, ctx)
.for_each(|res| async move {
match res {
Ok(o) => info!("Reconciled cluster: {:?}", o),
Err(e) => error!("Cluster reconcile error: {:?}", e),
}
})
.await;
Ok(())
}
pub async fn run_chain_controller(client: Client, namespace: String) -> Result<()> {
let ctx = Arc::new(Context {
client: client.clone(),
drivers: DriverRegistry::new(),
});
let chains: Api<ChainDeployment> = if namespace.is_empty() {
Api::all(client.clone())
} else {
Api::namespaced(client.clone(), &namespace)
};
info!("Starting ChainDeployment controller");
Controller::new(chains, WatcherConfig::default())
.run(reconcile_chain, chain_error_policy, ctx)
.for_each(|res| async move {
match res {
Ok(o) => info!("Reconciled chain: {:?}", o),
Err(e) => error!("Chain reconcile error: {:?}", e),
}
})
.await;
Ok(())
}
// ─── Cluster Reconciliation ───
async fn reconcile_cluster(cluster: Arc<Cluster>, ctx: Arc<Context>) -> Result<Action> {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
info!("Reconciling Cluster {}/{}", namespace, name);
let driver = ctx.drivers.get(&cluster.spec.chain).ok_or_else(|| {
OperatorError::UnknownChain(format!(
"No driver for chain '{}'. Available: {:?}",
cluster.spec.chain,
ctx.drivers.chains()
))
})?;
// Validate chain_config
info!(chain = driver.name(), "reconciling cluster");
driver.validate_config(&cluster.spec.chain_config)?;
let clusters_api: Api<Cluster> = Api::namespaced(ctx.client.clone(), &namespace);
let current_status = cluster.status.clone().unwrap_or_default();
let phase = current_status.phase.as_str();
let new_status = match phase {
"" | "Pending" => {
if cluster.spec.adopt_existing {
adopt_existing(&cluster, &ctx, &*driver).await?
} else {
create_cluster(&cluster, &ctx, &*driver).await?
}
}
"Creating" => check_creation_progress(&cluster, &ctx).await?,
"Bootstrapping" => check_bootstrap_progress(&cluster, &ctx, &*driver).await?,
"Running" => {
if !check_scale_safety(&cluster, &ctx).await? {
warn!("Cluster {} scale-down blocked", name);
let mut s = current_status.clone();
s.degraded_reasons.push(DegradedCondition {
reason: DegradedReason::NodeUnhealthy,
message: "Scale-down blocked: set allowScaleDown=true".into(),
affected_nodes: vec![],
});
s
} else {
// Sync replicas if changed
let sts_api: Api<StatefulSet> = Api::namespaced(ctx.client.clone(), &namespace);
if let Ok(sts) = sts_api.get(&name).await {
let current = sts.spec.as_ref().and_then(|s| s.replicas).unwrap_or(0) as u32;
if current != cluster.spec.replicas {
info!("Cluster {} scaling: {} -> {}", name, current, cluster.spec.replicas);
let patch = Patch::Merge(serde_json::json!({"spec":{"replicas":cluster.spec.replicas}}));
sts_api.patch(&name, &PatchParams::default(), &patch).await.map_err(OperatorError::KubeApi)?;
if cluster.spec.replicas > current && cluster.spec.service.per_node_lb {
create_per_pod_services(&cluster, &ctx, &*driver).await?;
}
}
}
check_health(&cluster, &ctx, &*driver).await?
}
}
"Degraded" => attempt_recovery(&cluster, &ctx, &*driver).await?,
_ => current_status.clone(),
};
// Update status if changed
if new_status.phase != current_status.phase
|| new_status.ready_nodes != current_status.ready_nodes
|| new_status.chain_statuses != current_status.chain_statuses
|| new_status.external_ips != current_status.external_ips
{
let patch = Patch::Merge(serde_json::json!({"status": new_status}));
clusters_api
.patch_status(&name, &PatchParams::default(), &patch)
.await
.map_err(OperatorError::KubeApi)?;
}
let interval = cluster.spec.health.check_interval_seconds;
let requeue = match new_status.phase.as_str() {
"Running" => Duration::from_secs(interval),
"Creating" | "Bootstrapping" => Duration::from_secs(10),
"Degraded" => Duration::from_secs(interval / 2),
_ => Duration::from_secs(15),
};
Ok(Action::requeue(requeue))
}
// ─── Create Cluster ───
async fn create_cluster(
cluster: &Cluster,
ctx: &Context,
driver: &dyn crate::drivers::ChainDriver,
) -> Result<ClusterStatus> {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
info!("Creating cluster {} with {} nodes", name, cluster.spec.replicas);
// ConfigMap
let cm = resources::build_config_map(cluster);
apply(&Api::<ConfigMap>::namespaced(ctx.client.clone(), &namespace), &format!("{}-config", name), &cm).await?;
// Startup script (driver-generated unless user provides their own)
if cluster.spec.startup_script.is_none() {
let script = driver.startup_script(cluster)?;
let startup_cm = resources::build_startup_configmap(cluster, script);
apply(&Api::<ConfigMap>::namespaced(ctx.client.clone(), &namespace), &format!("{}-startup", name), &startup_cm).await?;
}
// Services
let headless = resources::build_headless_service(cluster, driver);
apply(&Api::<Service>::namespaced(ctx.client.clone(), &namespace), &format!("{}-headless", name), &headless).await?;
let rpc_svc = resources::build_rpc_service(cluster, driver);
apply(&Api::<Service>::namespaced(ctx.client.clone(), &namespace), &name, &rpc_svc).await?;
if cluster.spec.service.per_node_lb {
create_per_pod_services(cluster, ctx, driver).await?;
}
// RBAC
let role = resources::build_role(cluster);
apply(&Api::<Role>::namespaced(ctx.client.clone(), &namespace), &format!("{}-service-reader", name), &role).await?;
let binding = resources::build_role_binding(cluster);
apply(&Api::<RoleBinding>::namespaced(ctx.client.clone(), &namespace), &format!("{}-service-reader", name), &binding).await?;
// PDB
let pdb = resources::build_pdb(cluster);
apply(&Api::<PodDisruptionBudget>::namespaced(ctx.client.clone(), &namespace), &format!("{}-pdb", name), &pdb).await?;
// StatefulSet
let sts = resources::build_statefulset(cluster, driver)?;
apply(&Api::<StatefulSet>::namespaced(ctx.client.clone(), &namespace), &name, &sts).await?;
Ok(ClusterStatus {
phase: "Creating".into(),
ready_nodes: 0,
total_nodes: cluster.spec.replicas,
..Default::default()
})
}
async fn create_per_pod_services(
cluster: &Cluster,
ctx: &Context,
driver: &dyn crate::drivers::ChainDriver,
) -> Result<()> {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let svc_api: Api<Service> = Api::namespaced(ctx.client.clone(), &namespace);
for i in 0..cluster.spec.replicas {
let svc = resources::build_per_pod_service(cluster, driver, i);
apply(&svc_api, &format!("{}-{}", name, i), &svc).await?;
}
Ok(())
}
// ─── Adopt Existing ───
async fn adopt_existing(
cluster: &Cluster,
ctx: &Context,
_driver: &dyn crate::drivers::ChainDriver,
) -> Result<ClusterStatus> {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let sts_api: Api<StatefulSet> = Api::namespaced(ctx.client.clone(), &namespace);
match sts_api.get(&name).await {
Ok(sts) => {
let ready = sts.status.unwrap_or_default().ready_replicas.unwrap_or(0) as u32;
let phase = if ready >= cluster.spec.replicas {
"Running"
} else if ready > 0 {
"Bootstrapping"
} else {
"Creating"
};
Ok(ClusterStatus {
phase: phase.into(),
ready_nodes: ready,
total_nodes: cluster.spec.replicas,
..Default::default()
})
}
Err(kube::Error::Api(e)) if e.code == 404 => {
warn!("adopt_existing: StatefulSet {} not found, falling back to create", name);
create_cluster(cluster, ctx, _driver).await
}
Err(e) => Err(OperatorError::KubeApi(e)),
}
}
// ─── Progress Checks ───
async fn check_creation_progress(cluster: &Cluster, ctx: &Context) -> Result<ClusterStatus> {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let status = cluster.status.clone().unwrap_or_default();
let sts_api: Api<StatefulSet> = Api::namespaced(ctx.client.clone(), &namespace);
let sts = match sts_api.get(&name).await {
Ok(s) => s,
Err(kube::Error::Api(e)) if e.code == 404 => return Ok(status),
Err(e) => return Err(OperatorError::KubeApi(e)),
};
let ready = sts.status.unwrap_or_default().ready_replicas.unwrap_or(0) as u32;
let phase = if ready >= cluster.spec.replicas { "Bootstrapping" } else { "Creating" };
Ok(ClusterStatus {
phase: phase.into(),
ready_nodes: ready,
total_nodes: cluster.spec.replicas,
..status
})
}
async fn check_bootstrap_progress(
cluster: &Cluster,
ctx: &Context,
driver: &dyn crate::drivers::ChainDriver,
) -> Result<ClusterStatus> {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let status = cluster.status.clone().unwrap_or_default();
let pods = list_cluster_pods(&name, &namespace, &ctx.client, cluster.spec.adopt_existing).await?;
let mut bootstrapped = 0u32;
for pod in &pods {
let pod_name = pod.metadata.name.clone().unwrap_or_default();
let pod_ip = pod.status.as_ref().and_then(|s| s.pod_ip.clone()).unwrap_or_default();
if pod_ip.is_empty() { continue; }
match driver.check_node_health(cluster, &pod_name, &pod_ip).await {
Ok(ns) if ns.bootstrapped => { bootstrapped += 1; }
_ => {}
}
}
let phase = if bootstrapped >= cluster.spec.replicas { "Running" } else { "Bootstrapping" };
Ok(ClusterStatus {
phase: phase.into(),
ready_nodes: bootstrapped,
total_nodes: cluster.spec.replicas,
..status
})
}
// ─── Health Checks ───
async fn check_health(
cluster: &Cluster,
ctx: &Context,
driver: &dyn crate::drivers::ChainDriver,
) -> Result<ClusterStatus> {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let status = cluster.status.clone().unwrap_or_default();
let pods = list_cluster_pods(&name, &namespace, &ctx.client, cluster.spec.adopt_existing).await?;
let mut healthy_count = 0u32;
let mut node_statuses = BTreeMap::new();
let mut degraded_reasons: Vec<DegradedCondition> = Vec::new();
let mut heights: Vec<u64> = Vec::new();
for pod in &pods {
let pod_name = pod.metadata.name.clone().unwrap_or_default();
let pod_ip = pod.status.as_ref().and_then(|s| s.pod_ip.clone()).unwrap_or_default();
let start_time = pod.status.as_ref()
.and_then(|s| s.start_time.as_ref())
.map(|t| t.0.to_rfc3339());
if pod_ip.is_empty() {
node_statuses.insert(pod_name.clone(), NodeStatus { healthy: false, ..Default::default() });
degraded_reasons.push(DegradedCondition {
reason: DegradedReason::PodNotReady,
message: format!("Pod {} has no IP", pod_name),
affected_nodes: vec![pod_name],
});
continue;
}
let mut ns = driver.check_node_health(cluster, &pod_name, &pod_ip).await.unwrap_or_default();
ns.start_time = start_time.clone();
if ns.healthy { healthy_count += 1; }
if let Some(h) = ns.block_height { heights.push(h); }
if !ns.healthy {
degraded_reasons.push(DegradedCondition {
reason: DegradedReason::NodeUnhealthy,
message: format!("Pod {} failed health check", pod_name),
affected_nodes: vec![pod_name.clone()],
});
}
node_statuses.insert(pod_name, ns);
}
// Height skew
let (min_h, max_h) = if !heights.is_empty() {
(*heights.iter().min().unwrap_or(&0), *heights.iter().max().unwrap_or(&0))
} else { (0, 0) };
let skew = max_h.saturating_sub(min_h);
if skew > cluster.spec.health.max_height_skew && heights.len() > 1 {
let lagging: Vec<String> = node_statuses.iter()
.filter(|(_, ns)| ns.block_height.map(|h| max_h.saturating_sub(h) > cluster.spec.health.max_height_skew).unwrap_or(false))
.map(|(name, _)| name.clone())
.collect();
degraded_reasons.push(DegradedCondition {
reason: DegradedReason::HeightSkew,
message: format!("Height skew: {} (min={}, max={})", skew, min_h, max_h),
affected_nodes: lagging,
});
}
// External IPs from LB services
let svc_api: Api<Service> = Api::namespaced(ctx.client.clone(), &namespace);
let external_ips = discover_external_ips(&svc_api, &name, cluster.spec.replicas).await;
for (svc_key, ip) in &external_ips {
if let Some(idx_str) = svc_key.rsplit('-').next() {
if let Ok(idx) = idx_str.parse::<u32>() {
let pod = node_statuses.keys()
.find(|pn| pn.rsplit('-').next().and_then(|s| s.parse::<u32>().ok()) == Some(idx))
.cloned();
if let Some(pn) = pod {
if let Some(ns) = node_statuses.get_mut(&pn) {
ns.external_ip = Some(ip.clone());
}
}
}
}
}
let total_peers: u32 = node_statuses.values().map(|ns| ns.connected_peers).sum();
let threshold = (cluster.spec.replicas * 2) / 3;
let phase = if healthy_count >= cluster.spec.replicas && degraded_reasons.is_empty() {
"Running"
} else if healthy_count >= threshold {
"Degraded"
} else {
"Degraded"
};
Ok(ClusterStatus {
phase: phase.into(),
ready_nodes: healthy_count,
total_nodes: cluster.spec.replicas,
external_ips,
node_statuses,
degraded_reasons,
network_metrics: Some(NetworkMetrics {
min_height: min_h,
max_height: max_h,
height_skew: skew,
bootstrapped_chains: 0,
total_peers,
}),
..status
})
}
// ─── Recovery ───
async fn attempt_recovery(
cluster: &Cluster,
ctx: &Context,
driver: &dyn crate::drivers::ChainDriver,
) -> Result<ClusterStatus> {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let status = cluster.status.clone().unwrap_or_default();
let pod_api: Api<Pod> = Api::namespaced(ctx.client.clone(), &namespace);
let pods = list_cluster_pods(&name, &namespace, &ctx.client, cluster.spec.adopt_existing).await?;
let mut recovered = 0u32;
let mut healthy = 0u32;
let max_recoveries = cluster.spec.upgrade_strategy.max_unavailable;
for pod in &pods {
let pod_name = pod.metadata.name.clone().unwrap_or_default();
let pod_ip = pod.status.as_ref().and_then(|s| s.pod_ip.clone()).unwrap_or_default();
let is_healthy = if pod_ip.is_empty() {
false
} else {
driver.check_node_health(cluster, &pod_name, &pod_ip).await.map(|ns| ns.healthy).unwrap_or(false)
};
if is_healthy {
healthy += 1;
} else if recovered < max_recoveries {
let age = pod.status.as_ref()
.and_then(|s| s.start_time.as_ref())
.map(|t| chrono::Utc::now().signed_duration_since(t.0).num_seconds())
.unwrap_or(0);
if age > 120 {
info!("Deleting unhealthy pod {} (age {}s) for recovery", pod_name, age);
if pod_api.delete(&pod_name, &DeleteParams::default()).await.is_ok() {
recovered += 1;
}
}
}
}
let phase = if recovered > 0 {
"Bootstrapping"
} else if healthy >= cluster.spec.replicas {
"Running"
} else {
"Degraded"
};
Ok(ClusterStatus {
phase: phase.into(),
ready_nodes: healthy,
total_nodes: cluster.spec.replicas,
..status
})
}
// ─── Scale Safety ───
async fn check_scale_safety(cluster: &Cluster, ctx: &Context) -> Result<bool> {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let sts_api: Api<StatefulSet> = Api::namespaced(ctx.client.clone(), &namespace);
let sts = match sts_api.get(&name).await {
Ok(s) => s,
Err(_) => return Ok(true),
};
let current = sts.spec.as_ref().and_then(|s| s.replicas).unwrap_or(0) as u32;
if cluster.spec.replicas < current && !cluster.spec.allow_scale_down {
warn!("Cluster {} scale-down blocked: {} -> {}", name, current, cluster.spec.replicas);
return Ok(false);
}
Ok(true)
}
// ─── ChainDeployment Reconciliation ───
async fn reconcile_chain(chain: Arc<ChainDeployment>, ctx: Arc<Context>) -> Result<Action> {
let name = chain.name_any();
let namespace = chain.namespace().unwrap_or_else(|| "default".into());
info!("Reconciling ChainDeployment {}/{}", namespace, name);
let chains_api: Api<ChainDeployment> = Api::namespaced(ctx.client.clone(), &namespace);
let current = chain.status.clone().unwrap_or_default();
let phase = current.phase.as_str();
let new_status = match phase {
"" | "Pending" => {
if chain.spec.blockchain_id.is_some() {
ChainDeploymentStatus {
chain_id: chain.spec.chain_id.clone(),
blockchain_id: chain.spec.blockchain_id.clone(),
phase: "Active".into(),
..Default::default()
}
} else {
ChainDeploymentStatus {
chain_id: chain.spec.chain_id.clone(),
phase: "Pending".into(),
..Default::default()
}
}
}
"Active" => current.clone(),
_ => current.clone(),
};
if new_status.phase != current.phase || new_status.blockchain_id != current.blockchain_id {
let patch = Patch::Merge(serde_json::json!({"status": new_status}));
chains_api.patch_status(&name, &PatchParams::default(), &patch).await.map_err(OperatorError::KubeApi)?;
}
let requeue = match new_status.phase.as_str() {
"Active" => Duration::from_secs(120),
_ => Duration::from_secs(30),
};
Ok(Action::requeue(requeue))
}
// ─── Helpers ───
async fn list_cluster_pods(
name: &str,
namespace: &str,
client: &Client,
adopt: bool,
) -> Result<Vec<Pod>> {
let pods: Api<Pod> = Api::namespaced(client.clone(), namespace);
let label = if adopt {
format!("app={}", name)
} else {
format!("app.kubernetes.io/instance={}", name)
};
let list = pods.list(&ListParams::default().labels(&label)).await.map_err(OperatorError::KubeApi)?;
Ok(list.items)
}
async fn discover_external_ips(
svc_api: &Api<Service>,
name: &str,
replicas: u32,
) -> BTreeMap<String, String> {
let mut ips = BTreeMap::new();
for i in 0..replicas {
let svc_name = format!("{}-{}", name, i);
if let Ok(svc) = svc_api.get(&svc_name).await {
if let Some(ip) = svc.status
.and_then(|s| s.load_balancer)
.and_then(|lb| lb.ingress)
.and_then(|ing| ing.first().cloned())
.and_then(|i| i.ip)
{
ips.insert(svc_name, ip);
}
}
}
ips
}
async fn apply<T>(api: &Api<T>, name: &str, resource: &T) -> Result<()>
where
T: kube::Resource<DynamicType = ()> + Clone + std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned,
{
let params = PatchParams::apply("bootnode-operator").force();
let patch = Patch::Apply(resource);
api.patch(name, &params, &patch).await.map_err(OperatorError::KubeApi)?;
Ok(())
}
fn cluster_error_policy(cluster: Arc<Cluster>, error: &OperatorError, _ctx: Arc<Context>) -> Action {
error!("Error reconciling cluster {}: {:?}", cluster.name_any(), error);
Action::requeue(Duration::from_secs(30))
}
fn chain_error_policy(chain: Arc<ChainDeployment>, error: &OperatorError, _ctx: Arc<Context>) -> Action {
error!("Error reconciling chain {}: {:?}", chain.name_any(), error);
Action::requeue(Duration::from_secs(30))
}
+607
View File
@@ -0,0 +1,607 @@
//! Generic blockchain CRDs.
//!
//! One CRD (`Cluster`) handles all chains: Lux, Avalanche, Ethereum, Bitcoin, etc.
//! Chain-specific behavior lives in drivers (see `drivers/`). The CRD carries
//! chain-specific config as an opaque JSON value interpreted by the driver.
//!
//! Naming: A `Cluster` (bootnode.dev/v1alpha1) is a cluster of blockchain nodes
//! on a single K8s cluster. A "Fleet" is the API-level concept spanning multiple
//! Clusters across providers/regions.
use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
// ---------------------------------------------------------------------------
// Cluster — primary CRD (a cluster of blockchain nodes on one K8s cluster)
// ---------------------------------------------------------------------------
/// A cluster of blockchain nodes managed as a StatefulSet.
///
/// Works for any chain. The `chain` field selects the driver that interprets
/// `chain_config` and generates the startup script, health checks, etc.
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(
group = "bootnode.dev",
version = "v1alpha1",
kind = "Cluster",
namespaced,
status = "ClusterStatus",
shortname = "bc",
printcolumn = r#"{"name":"Chain","type":"string","jsonPath":".spec.chain"}"#,
printcolumn = r#"{"name":"Network","type":"string","jsonPath":".spec.network"}"#,
printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.readyNodes"}"#,
printcolumn = r#"{"name":"Total","type":"string","jsonPath":".status.totalNodes"}"#,
printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct ClusterSpec {
// ---- Chain identification ----
/// Chain type: "lux", "avalanche", "ethereum", "bitcoin", etc.
/// Selects the driver that interprets chain_config.
pub chain: String,
/// Network name: "mainnet", "testnet", "devnet", or custom.
pub network: String,
// ---- Topology ----
/// Number of nodes (validators/peers).
pub replicas: u32,
// ---- Image ----
/// Container image.
#[serde(default)]
pub image: ImageSpec,
// ---- Resources ----
#[serde(default)]
pub resources: ResourceSpec,
#[serde(default)]
pub storage: StorageSpec,
// ---- Networking ----
/// Port mappings. Drivers provide defaults; user can override.
#[serde(default)]
pub ports: Vec<PortMapping>,
/// Service exposure settings.
#[serde(default)]
pub service: ServiceSpec,
// ---- Bootstrap ----
#[serde(default)]
pub bootstrap: BootstrapSpec,
// ---- Health ----
#[serde(default)]
pub health: HealthSpec,
// ---- Lifecycle ----
#[serde(default)]
pub upgrade_strategy: UpgradeStrategy,
#[serde(default)]
pub startup_gate: StartupGateSpec,
/// Explicit opt-in to scale down (remove nodes).
#[serde(default)]
pub allow_scale_down: bool,
// ---- Chain-specific ----
/// Opaque config interpreted by the chain driver.
/// For Lux: consensus, chain_tracking, rlp_import, evm_config, etc.
/// For Ethereum: execution_client, consensus_client, jwt_secret, etc.
#[serde(default)]
pub chain_config: serde_json::Value,
/// Genesis data (inline JSON).
#[serde(skip_serializing_if = "Option::is_none")]
pub genesis: Option<serde_json::Value>,
/// Pre-existing genesis ConfigMap name.
#[serde(skip_serializing_if = "Option::is_none")]
pub genesis_config_map: Option<String>,
/// Arbitrary node config overrides (merged into generated config).
#[serde(default)]
pub config: BTreeMap<String, serde_json::Value>,
// ---- Init / plugins ----
#[serde(skip_serializing_if = "Option::is_none")]
pub init: Option<InitSpec>,
/// Custom startup script ConfigMap (bypasses driver generation).
#[serde(skip_serializing_if = "Option::is_none")]
pub startup_script: Option<String>,
// ---- Security ----
#[serde(default)]
pub security: SecuritySpec,
/// Adopt existing resources instead of creating new ones.
#[serde(default)]
pub adopt_existing: bool,
// ---- Staking / identity ----
#[serde(skip_serializing_if = "Option::is_none")]
pub staking: Option<StakingSpec>,
// ---- Seed restore ----
#[serde(default)]
pub seed_restore: SeedRestoreSpec,
}
// ---------------------------------------------------------------------------
// Sub-specs (shared across all chains)
// ---------------------------------------------------------------------------
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ImageSpec {
#[serde(default = "default_image_repo")]
pub repository: String,
#[serde(default = "default_image_tag")]
pub tag: String,
#[serde(default = "default_pull_policy")]
pub pull_policy: String,
#[serde(default)]
pub pull_secrets: Vec<String>,
}
fn default_image_repo() -> String { "ghcr.io/hanzoai/bootnode".to_string() }
fn default_image_tag() -> String { "latest".to_string() }
fn default_pull_policy() -> String { "IfNotPresent".to_string() }
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ResourceSpec {
#[serde(skip_serializing_if = "Option::is_none")]
pub cpu_request: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cpu_limit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_request: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_limit: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct StorageSpec {
#[serde(skip_serializing_if = "Option::is_none")]
pub storage_class: Option<String>,
#[serde(default = "default_storage_size")]
pub size: String,
}
fn default_storage_size() -> String { "200Gi".to_string() }
/// Named port mapping (used by all chains).
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PortMapping {
pub name: String,
pub port: i32,
#[serde(default)]
pub protocol: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ServiceSpec {
#[serde(default = "default_service_type")]
pub service_type: String,
#[serde(default)]
pub annotations: BTreeMap<String, String>,
/// Create per-node LoadBalancer services for stable P2P IPs.
#[serde(default = "default_true")]
pub per_node_lb: bool,
}
fn default_service_type() -> String { "LoadBalancer".to_string() }
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct BootstrapSpec {
/// Use internal K8s DNS for bootstrap.
#[serde(default = "default_true")]
pub use_hostnames: bool,
/// Static external IPs (overrides LB discovery).
#[serde(default)]
pub external_ips: Vec<String>,
}
impl Default for BootstrapSpec {
fn default() -> Self {
Self {
use_hostnames: true,
external_ips: vec![],
}
}
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct HealthSpec {
/// HTTP path for health check (e.g. "/ext/health" for Lux, "/healthz" for Ethereum).
#[serde(default = "default_health_path")]
pub path: String,
/// HTTP method (GET or POST).
#[serde(default = "default_health_method")]
pub method: String,
/// Port name to use for health checks (matches a PortMapping name).
#[serde(default = "default_health_port_name")]
pub port_name: String,
/// Grace period after pod start before enforcing requirements.
#[serde(default = "default_health_grace")]
pub grace_period_seconds: u64,
/// Check interval.
#[serde(default = "default_health_interval")]
pub check_interval_seconds: u64,
/// Max height skew between nodes before marking Degraded.
#[serde(default = "default_max_height_skew")]
pub max_height_skew: u64,
}
impl Default for HealthSpec {
fn default() -> Self {
Self {
path: default_health_path(),
method: default_health_method(),
port_name: default_health_port_name(),
grace_period_seconds: default_health_grace(),
check_interval_seconds: default_health_interval(),
max_height_skew: default_max_height_skew(),
}
}
}
fn default_health_path() -> String { "/ext/health".to_string() }
fn default_health_method() -> String { "GET".to_string() }
fn default_health_port_name() -> String { "http".to_string() }
fn default_health_grace() -> u64 { 300 }
fn default_health_interval() -> u64 { 60 }
fn default_max_height_skew() -> u64 { 10 }
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpgradeStrategy {
/// "OnDelete" (safest) or "RollingUpdate".
#[serde(default = "default_upgrade_type")]
pub strategy_type: String,
#[serde(default = "default_one")]
pub max_unavailable: u32,
#[serde(default = "default_true")]
pub health_check_between_restarts: bool,
#[serde(default = "default_stabilization")]
pub stabilization_seconds: u64,
}
impl Default for UpgradeStrategy {
fn default() -> Self {
Self {
strategy_type: default_upgrade_type(),
max_unavailable: 1,
health_check_between_restarts: true,
stabilization_seconds: default_stabilization(),
}
}
}
fn default_upgrade_type() -> String { "OnDelete".to_string() }
fn default_one() -> u32 { 1 }
fn default_stabilization() -> u64 { 60 }
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct StartupGateSpec {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_min_peers")]
pub min_peers: u32,
#[serde(default = "default_gate_timeout")]
pub timeout_seconds: u64,
#[serde(default = "default_gate_interval")]
pub check_interval_seconds: u64,
/// "Fail" (exit 1, pod restarts) or "StartAnyway".
#[serde(default = "default_on_timeout")]
pub on_timeout: String,
}
impl Default for StartupGateSpec {
fn default() -> Self {
Self {
enabled: true,
min_peers: 2,
timeout_seconds: 300,
check_interval_seconds: 5,
on_timeout: "Fail".to_string(),
}
}
}
fn default_min_peers() -> u32 { 2 }
fn default_gate_timeout() -> u64 { 300 }
fn default_gate_interval() -> u64 { 5 }
fn default_on_timeout() -> String { "Fail".to_string() }
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct StakingSpec {
/// Secret name containing node identity keys.
pub secret_name: String,
/// KMS configuration for syncing keys.
#[serde(skip_serializing_if = "Option::is_none")]
pub kms: Option<KmsSpec>,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct KmsSpec {
pub host_api: String,
pub project_slug: String,
pub env_slug: String,
#[serde(default = "default_kms_path")]
pub secrets_path: String,
pub auth: KmsAuthSpec,
#[serde(default = "default_kms_resync")]
pub resync_interval: u64,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct KmsAuthSpec {
pub credentials_secret: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub credentials_namespace: Option<String>,
}
fn default_kms_path() -> String { "/staking".to_string() }
fn default_kms_resync() -> u64 { 300 }
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct InitSpec {
pub image: String,
#[serde(default = "default_pull_policy")]
pub pull_policy: String,
#[serde(default)]
pub plugins: Vec<PluginSpec>,
#[serde(default)]
pub clear_data: bool,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginSpec {
pub source: String,
pub dest: String,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct SecuritySpec {
#[serde(default = "default_uid")]
pub run_as_user: i64,
#[serde(default = "default_uid")]
pub run_as_group: i64,
#[serde(default = "default_uid")]
pub fs_group: i64,
}
impl Default for SecuritySpec {
fn default() -> Self {
Self {
run_as_user: 1000,
run_as_group: 1000,
fs_group: 1000,
}
}
}
fn default_uid() -> i64 { 1000 }
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct SeedRestoreSpec {
#[serde(default)]
pub enabled: bool,
/// "None", "ObjectStore", "PVCClone", "VolumeSnapshot".
#[serde(default = "default_seed_source")]
pub source_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_store_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub volume_snapshot_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub donor_pvc_name: Option<String>,
#[serde(default = "default_restore_policy")]
pub restore_policy: String,
#[serde(default = "default_marker_path")]
pub marker_path: String,
}
impl Default for SeedRestoreSpec {
fn default() -> Self {
Self {
enabled: false,
source_type: "None".to_string(),
object_store_url: None,
volume_snapshot_name: None,
donor_pvc_name: None,
restore_policy: "IfNotSeeded".to_string(),
marker_path: "/data/.seeded".to_string(),
}
}
}
fn default_seed_source() -> String { "None".to_string() }
fn default_restore_policy() -> String { "IfNotSeeded".to_string() }
fn default_marker_path() -> String { "/data/.seeded".to_string() }
fn default_true() -> bool { true }
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ClusterStatus {
/// Pending, Creating, Bootstrapping, Running, Degraded.
#[serde(default)]
pub phase: String,
#[serde(default)]
pub ready_nodes: u32,
#[serde(default)]
pub total_nodes: u32,
#[serde(default)]
pub bootstrap_endpoints: Vec<String>,
#[serde(default)]
pub external_ips: BTreeMap<String, String>,
#[serde(default)]
pub node_statuses: BTreeMap<String, NodeStatus>,
#[serde(default)]
pub chain_statuses: BTreeMap<String, ChainStatus>,
#[serde(default)]
pub degraded_reasons: Vec<DegradedCondition>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_metrics: Option<NetworkMetrics>,
#[serde(default)]
#[schemars(skip)]
pub conditions: Vec<Condition>,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed_generation: Option<i64>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, PartialEq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct NodeStatus {
#[serde(skip_serializing_if = "Option::is_none")]
pub node_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_ip: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub block_height: Option<u64>,
#[serde(default)]
pub connected_peers: u32,
#[serde(default)]
pub inbound_peers: u32,
#[serde(default)]
pub outbound_peers: u32,
#[serde(default)]
pub bootstrapped: bool,
#[serde(default)]
pub healthy: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_time: Option<String>,
/// Driver-specific extra status fields.
#[serde(default)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ChainStatus {
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
#[serde(default)]
pub healthy: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub block_height: Option<u64>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct DegradedCondition {
pub reason: DegradedReason,
pub message: String,
#[serde(default)]
pub affected_nodes: Vec<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum DegradedReason {
BootstrapBlocked,
HeightSkew,
InboundPeersTooLow,
ChainUnhealthy,
PodNotReady,
NodeUnhealthy,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, PartialEq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct NetworkMetrics {
#[serde(default)]
pub min_height: u64,
#[serde(default)]
pub max_height: u64,
#[serde(default)]
pub height_skew: u64,
#[serde(default)]
pub bootstrapped_chains: u32,
#[serde(default)]
pub total_peers: u32,
}
// ---------------------------------------------------------------------------
// ChainDeployment — secondary CRD for subnet/L2 chains
// ---------------------------------------------------------------------------
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(
group = "bootnode.dev",
version = "v1alpha1",
kind = "ChainDeployment",
namespaced,
status = "ChainDeploymentStatus",
shortname = "cd",
printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
printcolumn = r#"{"name":"ChainID","type":"string","jsonPath":".status.chainId"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct ChainDeploymentSpec {
/// Parent Cluster name.
pub cluster_ref: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub chain_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blockchain_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
/// Chain-specific VM/client config.
#[serde(default)]
pub config: serde_json::Value,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ChainDeploymentStatus {
#[serde(skip_serializing_if = "Option::is_none")]
pub chain_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blockchain_id: Option<String>,
#[serde(default)]
pub phase: String,
#[serde(default)]
#[schemars(skip)]
pub conditions: Vec<Condition>,
}
+70
View File
@@ -0,0 +1,70 @@
//! Bitcoin chain driver (stub).
use super::ChainDriver;
use crate::crd::{HealthSpec, Cluster, NodeStatus, PortMapping};
use crate::error::{OperatorError, Result};
use async_trait::async_trait;
pub struct BitcoinDriver;
#[async_trait]
impl ChainDriver for BitcoinDriver {
fn name(&self) -> &str {
"bitcoin"
}
fn container_name(&self) -> &str {
"bitcoind"
}
fn default_ports(&self) -> Vec<PortMapping> {
vec![
PortMapping { name: "rpc".into(), port: 8332, protocol: None },
PortMapping { name: "p2p".into(), port: 8333, protocol: None },
]
}
fn default_health(&self) -> HealthSpec {
HealthSpec {
path: "/".into(),
method: "POST".into(),
port_name: "rpc".into(),
..Default::default()
}
}
fn startup_script(&self, _cluster: &Cluster) -> Result<String> {
Err(OperatorError::Config(
"Bitcoin driver not yet implemented".into(),
))
}
fn startup_gate_script(&self, _cluster: &Cluster) -> Result<String> {
Err(OperatorError::Config(
"Bitcoin driver not yet implemented".into(),
))
}
fn init_script(&self, _cluster: &Cluster) -> Result<Option<String>> {
Ok(None)
}
fn env_vars(&self, _cluster: &Cluster) -> Vec<(String, String)> {
vec![]
}
async fn check_node_health(
&self,
_cluster: &Cluster,
_pod_name: &str,
_pod_ip: &str,
) -> Result<NodeStatus> {
Err(OperatorError::Config(
"Bitcoin driver not yet implemented".into(),
))
}
fn validate_config(&self, _chain_config: &serde_json::Value) -> Result<()> {
Ok(())
}
}
+72
View File
@@ -0,0 +1,72 @@
//! Ethereum chain driver (stub).
use super::ChainDriver;
use crate::crd::{HealthSpec, Cluster, NodeStatus, PortMapping};
use crate::error::{OperatorError, Result};
use async_trait::async_trait;
pub struct EthereumDriver;
#[async_trait]
impl ChainDriver for EthereumDriver {
fn name(&self) -> &str {
"ethereum"
}
fn container_name(&self) -> &str {
"geth"
}
fn default_ports(&self) -> Vec<PortMapping> {
vec![
PortMapping { name: "http".into(), port: 8545, protocol: None },
PortMapping { name: "ws".into(), port: 8546, protocol: None },
PortMapping { name: "p2p".into(), port: 30303, protocol: None },
PortMapping { name: "metrics".into(), port: 6060, protocol: None },
]
}
fn default_health(&self) -> HealthSpec {
HealthSpec {
path: "/".into(),
method: "POST".into(),
port_name: "http".into(),
..Default::default()
}
}
fn startup_script(&self, _cluster: &Cluster) -> Result<String> {
Err(OperatorError::Config(
"Ethereum driver not yet implemented".into(),
))
}
fn startup_gate_script(&self, _cluster: &Cluster) -> Result<String> {
Err(OperatorError::Config(
"Ethereum driver not yet implemented".into(),
))
}
fn init_script(&self, _cluster: &Cluster) -> Result<Option<String>> {
Ok(None)
}
fn env_vars(&self, _cluster: &Cluster) -> Vec<(String, String)> {
vec![]
}
async fn check_node_health(
&self,
_cluster: &Cluster,
_pod_name: &str,
_pod_ip: &str,
) -> Result<NodeStatus> {
Err(OperatorError::Config(
"Ethereum driver not yet implemented".into(),
))
}
fn validate_config(&self, _chain_config: &serde_json::Value) -> Result<()> {
Ok(())
}
}
+681
View File
@@ -0,0 +1,681 @@
//! Lux / Avalanche chain driver.
//!
//! Generates startup scripts, health checks, and node identity extraction
//! for luxd-based chains (Lux mainnet/testnet/devnet and Avalanche).
use super::ChainDriver;
use crate::crd::{HealthSpec, Cluster, NodeStatus, PortMapping};
use crate::error::{OperatorError, Result};
use async_trait::async_trait;
use kube::ResourceExt;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::time::Duration;
/// Lux-specific chain_config fields (deserialized from opaque JSON).
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct LuxConfig {
#[serde(default)]
network_id: u32,
#[serde(default = "default_http_port")]
http_port: i32,
#[serde(default = "default_staking_port")]
staking_port: i32,
#[serde(default = "default_db_type")]
db_type: String,
#[serde(default)]
sybil_protection_enabled: Option<bool>,
#[serde(default)]
require_validator_to_connect: Option<bool>,
#[serde(default)]
allow_private_ips: Option<bool>,
#[serde(default)]
network_compression_type: Option<String>,
#[serde(default)]
consensus: Option<ConsensusConfig>,
#[serde(default)]
chain_tracking: Option<ChainTrackingConfig>,
#[serde(default)]
evm_config: Option<serde_json::Value>,
#[serde(default)]
chains: Vec<ChainRef>,
#[serde(default)]
chain_aliases: Option<BTreeMap<String, Vec<String>>>,
#[serde(default)]
rlp_import: Option<RlpImportConfig>,
#[serde(default)]
api: Option<ApiConfig>,
#[serde(default)]
upgrade_config: Option<serde_json::Value>,
#[serde(default)]
import_chain_data: Option<String>,
}
fn default_http_port() -> i32 { 9650 }
fn default_staking_port() -> i32 { 9651 }
fn default_db_type() -> String { "leveldb".into() }
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct ConsensusConfig {
#[serde(default = "default_sample")]
sample_size: u32,
#[serde(default = "default_quorum")]
quorum_size: u32,
}
fn default_sample() -> u32 { 5 }
fn default_quorum() -> u32 { 4 }
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct ChainTrackingConfig {
#[serde(default)]
track_all_chains: bool,
#[serde(default)]
tracked_chains: Vec<String>,
#[serde(default)]
aliases: Vec<String>,
}
#[derive(Deserialize, Default, Clone)]
#[serde(rename_all = "camelCase")]
struct ChainRef {
blockchain_id: String,
alias: String,
tracking_id: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct RlpImportConfig {
#[serde(default)]
enabled: bool,
#[serde(default)]
base_url: String,
#[serde(default)]
filename: String,
#[serde(default)]
multi_part: bool,
#[serde(default)]
parts: Vec<String>,
#[serde(default = "default_min_height")]
min_height: u64,
#[serde(default = "default_timeout")]
timeout: u64,
#[serde(default)]
c_chain_id: Option<String>,
}
fn default_min_height() -> u64 { 1 }
fn default_timeout() -> u64 { 7200 }
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct ApiConfig {
#[serde(default = "default_true")]
admin_enabled: bool,
#[serde(default = "default_true")]
index_enabled: bool,
#[serde(default = "default_true")]
metrics_enabled: bool,
#[serde(default = "default_http_allowed")]
http_allowed_hosts: String,
}
fn default_true() -> bool { true }
fn default_http_allowed() -> String { "*".into() }
pub struct LuxDriver;
impl LuxDriver {
fn parse_config(cluster: &Cluster) -> LuxConfig {
serde_json::from_value(cluster.spec.chain_config.clone()).unwrap_or_default()
}
/// Resolve ports: user overrides > chain_config > defaults.
fn ports(cluster: &Cluster) -> (i32, i32) {
let cfg = Self::parse_config(cluster);
let ports = &cluster.spec.ports;
let http = ports.iter().find(|p| p.name == "http").map(|p| p.port).unwrap_or(cfg.http_port);
let staking = ports.iter().find(|p| p.name == "staking").map(|p| p.port).unwrap_or(cfg.staking_port);
(http, staking)
}
}
#[async_trait]
impl ChainDriver for LuxDriver {
fn name(&self) -> &str { "lux" }
fn container_name(&self) -> &str { "luxd" }
fn default_ports(&self) -> Vec<PortMapping> {
vec![
PortMapping { name: "http".into(), port: 9650, protocol: None },
PortMapping { name: "staking".into(), port: 9651, protocol: None },
PortMapping { name: "metrics".into(), port: 9090, protocol: None },
]
}
fn default_health(&self) -> HealthSpec {
HealthSpec {
path: "/ext/health".into(),
method: "GET".into(),
port_name: "http".into(),
..Default::default()
}
}
fn startup_script(&self, cluster: &Cluster) -> Result<String> {
let cfg = Self::parse_config(cluster);
let spec = &cluster.spec;
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let (http_port, staking_port) = Self::ports(cluster);
let replicas = spec.replicas;
let mut s = String::with_capacity(8192);
s.push_str("#!/bin/sh\nset -e\n\n");
s.push_str("POD_INDEX=${HOSTNAME##*-}\n");
s.push_str(&format!(
"echo \"=== luxd startup for $HOSTNAME ({}, network-id={}) ===\"\n\n",
name, cfg.network_id
));
// Bootstrap node list (K8s DNS)
if spec.bootstrap.use_hostnames {
s.push_str("# Build bootstrap node list excluding self\n");
s.push_str(&format!("DNS_PREFIX=\"{}\"\n", name));
s.push_str(&format!(
"DNS_SUFFIX=\"{}-headless.{}.svc.cluster.local\"\n",
name, namespace
));
s.push_str("BOOTSTRAP_NODES=\"\"\n");
for i in 0..replicas {
s.push_str(&format!("if [ \"$POD_INDEX\" != \"{}\" ]; then\n", i));
s.push_str(&format!(
" CUR_HOST=\"${{DNS_PREFIX}}-{}.${{DNS_SUFFIX}}:{}\"\n",
i, staking_port
));
s.push_str(" if [ -n \"$BOOTSTRAP_NODES\" ]; then\n");
s.push_str(" BOOTSTRAP_NODES=\"${BOOTSTRAP_NODES},${CUR_HOST}\"\n");
s.push_str(" else\n");
s.push_str(" BOOTSTRAP_NODES=\"${CUR_HOST}\"\n");
s.push_str(" fi\n");
s.push_str("fi\n");
}
} else if !spec.bootstrap.external_ips.is_empty() {
s.push_str("# Build bootstrap node list from static IPs\n");
s.push_str("BOOTSTRAP_NODES=\"\"\n");
for (i, ip) in spec.bootstrap.external_ips.iter().enumerate() {
s.push_str(&format!("if [ \"$POD_INDEX\" != \"{}\" ] && [ -n \"{}\" ]; then\n", i, ip));
s.push_str(&format!(" CUR_HOST=\"{}:{}\"\n", ip, staking_port));
s.push_str(" if [ -n \"$BOOTSTRAP_NODES\" ]; then\n");
s.push_str(" BOOTSTRAP_NODES=\"${BOOTSTRAP_NODES},${CUR_HOST}\"\n");
s.push_str(" else\n");
s.push_str(" BOOTSTRAP_NODES=\"${CUR_HOST}\"\n");
s.push_str(" fi\n");
s.push_str("fi\n");
}
}
// Public IP discovery
s.push_str("\n# Discover public IP\nPUBLIC_IP=\"\"\n");
if !spec.bootstrap.external_ips.is_empty() {
s.push_str("case \"$POD_INDEX\" in\n");
for (i, ip) in spec.bootstrap.external_ips.iter().enumerate() {
s.push_str(&format!(" {}) PUBLIC_IP=\"{}\" ;;\n", i, ip));
}
s.push_str("esac\n");
}
// LB discovery via K8s API
s.push_str("\n# Discover from per-pod LoadBalancer service\n");
s.push_str("if [ -z \"$PUBLIC_IP\" ]; then\n");
s.push_str(" SA_TOKEN_FILE=\"/var/run/secrets/kubernetes.io/serviceaccount/token\"\n");
s.push_str(" CA_CERT=\"/var/run/secrets/kubernetes.io/serviceaccount/ca.crt\"\n");
s.push_str(" K8S_API=\"https://kubernetes.default.svc\"\n");
s.push_str(&format!(" SVC_NAME=\"{}-${{POD_INDEX}}\"\n", name));
s.push_str(&format!(" NAMESPACE=\"{}\"\n\n", namespace));
s.push_str(" if [ -f \"$SA_TOKEN_FILE\" ]; then\n");
s.push_str(" TOKEN=$(cat \"$SA_TOKEN_FILE\")\n");
s.push_str(" ATTEMPT=0; MAX_ATTEMPTS=12\n");
s.push_str(" while [ \"$ATTEMPT\" -lt \"$MAX_ATTEMPTS\" ]; do\n");
s.push_str(" SVC_JSON=$(curl -sf -m 5 -H \"Authorization: Bearer ${TOKEN}\" --cacert \"$CA_CERT\" \"${K8S_API}/api/v1/namespaces/${NAMESPACE}/services/${SVC_NAME}\" 2>/dev/null || true)\n");
s.push_str(" if [ -n \"$SVC_JSON\" ]; then\n");
s.push_str(" LB_IP=$(echo \"$SVC_JSON\" | tr -d ' \\n' | sed -n 's/.*\"loadBalancer\":{\"ingress\":\\[{\"ip\":\"\\([^\"]*\\)\".*/\\1/p')\n");
s.push_str(" if [ -n \"$LB_IP\" ]; then PUBLIC_IP=\"$LB_IP\"; break; fi\n");
s.push_str(" fi\n");
s.push_str(" ATTEMPT=$((ATTEMPT + 1)); sleep 5\n");
s.push_str(" done\n");
s.push_str(" fi\n");
s.push_str("fi\n");
s.push_str("if [ -z \"$PUBLIC_IP\" ]; then PUBLIC_IP=$(hostname -i); fi\n\n");
// EVM chain configs
let all_chain_ids: Vec<&str> = cfg.chains.iter().map(|c| c.blockchain_id.as_str()).collect();
if cfg.evm_config.is_some() || !all_chain_ids.is_empty() {
let evm_cfg = cfg.evm_config.as_ref().map_or_else(
|| r#"{"admin-api-enabled":true,"eth-apis":["eth","eth-filter","net","web3","internal-eth","internal-blockchain","internal-transaction","internal-tx-pool","internal-account","internal-personal","debug","internal-debug","admin"],"rpc-gas-cap":50000000,"rpc-tx-fee-cap":100,"pruning-enabled":false,"allow-unfinalized-queries":true,"allow-unprotected-txs":true,"log-level":"info","state-sync-enabled":false,"allow-missing-tries":true}"#.to_string(),
|v| serde_json::to_string(v).unwrap_or_default(),
);
s.push_str("# EVM chain configs\n");
s.push_str(&format!("CHAIN_CFG='{}'\n", evm_cfg));
let mut chains = vec!["C".to_string()];
chains.extend(all_chain_ids.iter().map(|s| s.to_string()));
s.push_str(&format!("for chain in {}; do\n", chains.join(" ")));
s.push_str(" mkdir -p /data/configs/chains/$chain\n");
s.push_str(" echo \"$CHAIN_CFG\" > /data/configs/chains/$chain/config.json\n");
s.push_str("done\n\n");
}
// Build luxd arguments
s.push_str("# Build luxd arguments\n");
s.push_str(&format!("ARGS=\"--network-id={}\"\n", cfg.network_id));
s.push_str("ARGS=\"$ARGS --http-host=0.0.0.0\"\n");
s.push_str(&format!("ARGS=\"$ARGS --http-port={}\"\n", http_port));
let api = cfg.api.unwrap_or_default();
s.push_str(&format!("ARGS=\"$ARGS --http-allowed-hosts={}\"\n", api.http_allowed_hosts));
s.push_str(&format!("ARGS=\"$ARGS --staking-port={}\"\n", staking_port));
s.push_str("ARGS=\"$ARGS --data-dir=/data\"\n");
if spec.genesis.is_some() || spec.genesis_config_map.is_some() {
s.push_str("ARGS=\"$ARGS --genesis-file=/genesis/genesis.json\"\n");
}
s.push_str(&format!("ARGS=\"$ARGS --db-type={}\"\n", cfg.db_type));
if cfg.evm_config.is_some() || !cfg.chains.is_empty() {
s.push_str("ARGS=\"$ARGS --chain-config-dir=/data/configs/chains\"\n");
}
s.push_str(&format!("ARGS=\"$ARGS --index-enabled={}\"\n", api.index_enabled));
s.push_str(&format!("ARGS=\"$ARGS --api-admin-enabled={}\"\n", api.admin_enabled));
s.push_str(&format!("ARGS=\"$ARGS --api-metrics-enabled={}\"\n", api.metrics_enabled));
s.push_str("ARGS=\"$ARGS --api-keystore-enabled=true\"\n");
s.push_str("ARGS=\"$ARGS --log-level=info\"\n");
s.push_str("ARGS=\"$ARGS --plugin-dir=/data/plugins\"\n");
s.push_str("ARGS=\"$ARGS --staking-tls-cert-file=/data/staking/staker.crt\"\n");
s.push_str("ARGS=\"$ARGS --staking-tls-key-file=/data/staking/staker.key\"\n\n");
s.push_str("if [ -f \"/data/staking/signer.key\" ]; then\n");
s.push_str(" ARGS=\"$ARGS --staking-signer-key-file=/data/staking/signer.key\"\n");
s.push_str("fi\n\n");
let compression = cfg.network_compression_type.as_deref().unwrap_or("gzip");
s.push_str(&format!("ARGS=\"$ARGS --network-compression-type={}\"\n", compression));
let consensus = cfg.consensus.unwrap_or_default();
s.push_str(&format!("ARGS=\"$ARGS --consensus-sample-size={}\"\n", consensus.sample_size));
s.push_str(&format!("ARGS=\"$ARGS --consensus-quorum-size={}\"\n", consensus.quorum_size));
let sybil = cfg.sybil_protection_enabled.unwrap_or(cfg.network_id <= 2);
s.push_str(&format!("ARGS=\"$ARGS --sybil-protection-enabled={}\"\n", sybil));
let req_val = cfg.require_validator_to_connect.unwrap_or(false);
s.push_str(&format!("ARGS=\"$ARGS --network-require-validator-to-connect={}\"\n", req_val));
let priv_ips = cfg.allow_private_ips.unwrap_or(true);
s.push_str(&format!("ARGS=\"$ARGS --network-allow-private-ips={}\"\n\n", priv_ips));
// Chain aliases
if let Some(aliases) = &cfg.chain_aliases {
let json = serde_json::to_string(aliases).unwrap_or_default();
s.push_str(&format!("ALIASES_JSON='{}'\n", json.replace('\'', "'\"'\"'")));
s.push_str("ALIASES_B64=$(echo \"$ALIASES_JSON\" | base64 | tr -d '\\n')\n");
s.push_str("ARGS=\"$ARGS --chain-aliases-file-content=$ALIASES_B64\"\n\n");
}
if let Some(import_path) = &cfg.import_chain_data {
s.push_str(&format!("ARGS=\"$ARGS --import-chain-data={}\"\n", import_path));
}
// Public IP and bootstrap
s.push_str("if [ -n \"$PUBLIC_IP\" ]; then ARGS=\"$ARGS --public-ip=$PUBLIC_IP\"; fi\n");
s.push_str("if [ -n \"$BOOTSTRAP_NODES\" ]; then ARGS=\"$ARGS --bootstrap-nodes=$BOOTSTRAP_NODES\"; fi\n\n");
// Chain tracking
if let Some(tracking) = &cfg.chain_tracking {
if tracking.track_all_chains {
s.push_str("ARGS=\"$ARGS --track-all-chains\"\n\n");
} else if !tracking.tracked_chains.is_empty() {
s.push_str(&format!("ARGS=\"$ARGS --track-chains={}\"\n\n", tracking.tracked_chains.join(",")));
} else if !cfg.chains.is_empty() {
let ids: Vec<String> = cfg.chains.iter()
.map(|cr| cr.tracking_id.clone().unwrap_or_else(|| cr.blockchain_id.clone()))
.collect();
s.push_str(&format!("ARGS=\"$ARGS --track-chains={}\"\n\n", ids.join(",")));
}
}
// Upgrade config
if let Some(upgrade) = &cfg.upgrade_config {
let json_str = serde_json::to_string(upgrade).unwrap_or_default();
s.push_str(&format!("UPGRADE_JSON='{}'\n", json_str.replace('\'', "'\"'\"'")));
s.push_str("UPGRADE_B64=$(echo \"$UPGRADE_JSON\" | base64 | tr -d '\\n')\n");
s.push_str("ARGS=\"$ARGS --upgrade-file-content=$UPGRADE_B64\"\n\n");
}
// RLP import: clear C-chain data
if let Some(rlp) = &cfg.rlp_import {
if rlp.enabled {
if let Some(c_chain_id) = &rlp.c_chain_id {
s.push_str(&format!(
"C_CHAIN_DIR=\"/data/chainData/network-{}/{}\"\n", cfg.network_id, c_chain_id
));
s.push_str(&format!("IMPORT_MARKER=\"/data/.bootstrap-{}-import.done\"\n", name));
s.push_str("if [ -d \"$C_CHAIN_DIR\" ]; then\n");
s.push_str(" rm -rf \"$C_CHAIN_DIR\"; rm -f \"$IMPORT_MARKER\"\n");
s.push_str("fi\n\n");
}
}
}
// Start luxd in background
s.push_str("/luxd/build/luxd $ARGS &\nLUXD_PID=$!\n\nset +e\n\n");
// Wait for health
s.push_str("echo \"[BOOTSTRAP] Waiting for node health...\"\ni=0\n");
s.push_str(&format!(
"while [ \"$i\" -lt 180 ]; do\n if curl -sf -m 2 http://127.0.0.1:{}/ext/health >/dev/null 2>&1; then\n echo \"[BOOTSTRAP] Node healthy after ${{i}}s\"; break\n fi\n i=$((i + 1)); sleep 1\ndone\n\n",
http_port
));
// Chain aliases (runtime)
if let Some(tracking) = &cfg.chain_tracking {
for alias in &tracking.aliases {
s.push_str(&format!(
"curl -s -m 5 -X POST -H 'Content-Type: application/json' --data '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"admin.aliasChain\",\"params\":{{\"chain\":\"C\",\"alias\":\"{}\"}}}}' http://127.0.0.1:{}/ext/admin >/dev/null 2>&1 || true\n",
alias, http_port
));
}
}
for chain_ref in &cfg.chains {
s.push_str(&format!(
"curl -s -m 5 -X POST -H 'Content-Type: application/json' --data '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"admin.aliasChain\",\"params\":{{\"chain\":\"{}\",\"alias\":\"{}\"}}}}' http://127.0.0.1:{}/ext/admin >/dev/null 2>&1 || true\n",
chain_ref.blockchain_id, chain_ref.alias, http_port
));
}
// RLP import
if let Some(rlp) = &cfg.rlp_import {
if rlp.enabled {
let marker_name = format!("bootstrap-{}-import", name);
let seed_marker = &spec.seed_restore.marker_path;
s.push_str("\n# C-Chain RLP import\n");
s.push_str(&format!("MARKER=\"/data/.{}.done\"\n", marker_name));
s.push_str(&format!("SEED_MARKER=\"{}\"\n", seed_marker));
s.push_str("BOOTSTRAP_DIR=\"/data/bootstrap\"\n\n");
s.push_str("if [ -f \"$SEED_MARKER\" ]; then\n");
s.push_str(" echo \"[BOOTSTRAP] Seeded from snapshot, skipping RLP\"\n touch \"$MARKER\"\n");
s.push_str("elif [ ! -f \"$MARKER\" ]; then\n");
s.push_str(&format!(
" height_hex=$(curl -s -m 10 -X POST -H 'Content-Type: application/json' --data '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}}' http://127.0.0.1:{}/ext/bc/C/rpc | sed -n 's/.*\"result\":\"\\(0x[0-9A-Fa-f]*\\)\".*/\\1/p' | head -n1)\n",
http_port
));
s.push_str(" [ -z \"$height_hex\" ] && height_hex=\"0x0\"\n");
s.push_str(" height_dec=$(printf '%d' \"$height_hex\" 2>/dev/null || echo 0)\n\n");
s.push_str(&format!(" if [ \"$height_dec\" -lt \"{}\" ]; then\n", rlp.min_height));
s.push_str(" mkdir -p \"$BOOTSTRAP_DIR\"\n");
s.push_str(&format!(" RLP_FILE=\"${{BOOTSTRAP_DIR}}/{}\"\n\n", rlp.filename));
if rlp.multi_part && !rlp.parts.is_empty() {
s.push_str(" if [ ! -s \"$RLP_FILE\" ]; then\n");
let base_name = rlp.filename.replace(".rlp", "");
for part in &rlp.parts {
s.push_str(&format!(" [ ! -s \"${{BOOTSTRAP_DIR}}/{}.part.{}\" ] && curl -fL --retry 5 -o \"${{BOOTSTRAP_DIR}}/{}.part.{}\" \"{}/{}.part.{}\" || true\n",
rlp.filename, part, rlp.filename, part, rlp.base_url, base_name, part));
}
s.push_str(&format!(" cat \"${{BOOTSTRAP_DIR}}\"/{}.part.* > \"$RLP_FILE\" 2>/dev/null || true\n", rlp.filename));
s.push_str(" fi\n");
} else {
s.push_str(&format!(" [ ! -s \"$RLP_FILE\" ] && curl -fL --retry 5 -o \"$RLP_FILE\" \"{}/{}\" || true\n", rlp.base_url, rlp.filename));
}
s.push_str("\n if [ -s \"$RLP_FILE\" ]; then\n");
s.push_str(&format!(
" import_result=$(curl -s -m {} -X POST -H 'Content-Type: application/json' --data \"$(printf '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"admin_importChain\",\"params\":[\"%s\"]}}' \"$RLP_FILE\")\" http://127.0.0.1:{}/ext/bc/C/rpc || true)\n",
rlp.timeout, http_port
));
s.push_str(" echo \"$import_result\" | grep -q '\"success\":true' && touch \"$MARKER\" || true\n");
s.push_str(" fi\n");
s.push_str(" else\n touch \"$MARKER\"\n fi\n");
s.push_str("else\n echo \"[BOOTSTRAP] Import marker exists\"\nfi\n\n");
}
}
s.push_str("wait \"$LUXD_PID\"\n");
Ok(s)
}
fn startup_gate_script(&self, cluster: &Cluster) -> Result<String> {
let cfg = Self::parse_config(cluster);
let spec = &cluster.spec;
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let (_, staking_port) = Self::ports(cluster);
let gate = &spec.startup_gate;
let min_peers = gate.min_peers.min(spec.replicas.saturating_sub(1));
let mut s = String::with_capacity(2048);
s.push_str("#!/bin/sh\nset -e\n\n");
s.push_str("POD_INDEX=${HOSTNAME##*-}\n");
s.push_str(&format!("echo \"[GATE] Startup gate for $HOSTNAME ({}, network-id={})\"\n", name, cfg.network_id));
s.push_str(&format!("DNS_PREFIX=\"{}\"\n", name));
s.push_str(&format!("DNS_SUFFIX=\"{}-headless.{}.svc.cluster.local\"\n", name, namespace));
s.push_str(&format!("GATE_MIN_PEERS={}\nGATE_TIMEOUT={}\nGATE_INTERVAL={}\n", min_peers, gate.timeout_seconds, gate.check_interval_seconds));
s.push_str("GATE_START=$(date +%s)\n\n");
s.push_str("while true; do\n REACHABLE=0\n");
if spec.bootstrap.use_hostnames {
for i in 0..spec.replicas {
s.push_str(&format!(" if [ \"$POD_INDEX\" != \"{}\" ]; then\n", i));
s.push_str(&format!(
" nc -z -w 2 \"${{DNS_PREFIX}}-{}.${{DNS_SUFFIX}}\" {} 2>/dev/null && REACHABLE=$((REACHABLE + 1))\n",
i, staking_port
));
s.push_str(" fi\n");
}
}
s.push_str("\n [ \"$REACHABLE\" -ge \"$GATE_MIN_PEERS\" ] && echo \"[GATE] $REACHABLE peers, proceeding\" && break\n\n");
s.push_str(" ELAPSED=$(( $(date +%s) - GATE_START ))\n");
s.push_str(" if [ \"$ELAPSED\" -ge \"$GATE_TIMEOUT\" ]; then\n");
if gate.on_timeout == "StartAnyway" {
s.push_str(" echo \"[GATE] Timeout, proceeding anyway\"; break\n");
} else {
s.push_str(" echo \"[GATE] Timeout, exiting\"; exit 1\n");
}
s.push_str(" fi\n");
s.push_str(" sleep \"$GATE_INTERVAL\"\ndone\n");
s.push_str("echo \"[GATE] Gate passed.\"\n");
Ok(s)
}
fn init_script(&self, cluster: &Cluster) -> Result<Option<String>> {
let spec = &cluster.spec;
if spec.init.is_none() && spec.staking.is_none() {
return Ok(None);
}
let mut script = String::from("set -e\nIDX=${HOSTNAME##*-}\necho \"=== Init for ${HOSTNAME} ===\"\n");
// Clear data for re-genesis
if spec.init.as_ref().map_or(false, |i| i.clear_data) {
script.push_str("rm -rf /data/chainData /data/db /data/genesis.bytes /data/.bootstrap-*-import.done /data/.seeded\n");
}
// Seed restore
let seed = &spec.seed_restore;
if seed.enabled {
let marker = &seed.marker_path;
let check = if seed.restore_policy == "Always" { "true".into() } else { format!("! -f \"{}\"", marker) };
script.push_str(&format!("if [ {} ]; then\n", check));
match seed.source_type.as_str() {
"ObjectStore" => {
if let Some(url) = &seed.object_store_url {
script.push_str(&format!(" curl -fL --retry 5 -o /tmp/snapshot.tar.zst \"{}\" && \\\n", url));
script.push_str(" (zstd -d /tmp/snapshot.tar.zst -o /tmp/snapshot.tar 2>/dev/null || cp /tmp/snapshot.tar.zst /tmp/snapshot.tar) && \\\n");
script.push_str(&format!(" tar xf /tmp/snapshot.tar -C /data && rm -f /tmp/snapshot.tar* && touch \"{}\" || true\n", marker));
}
}
"PVCClone" | "VolumeSnapshot" => {
script.push_str(" if [ -d \"/data/db\" ] || [ -d \"/data/chainData\" ]; then\n");
script.push_str(&format!(" touch \"{}\"\n fi\n", marker));
}
_ => {}
}
script.push_str("fi\n\n");
}
// Plugin setup
let c_chain_vm_id = "mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6";
let chain_vm_id = "ag3GReYPNuSR17rUP8acMdZipQBikdXNRKDyFszAysmy3vDXE";
if let Some(init) = &spec.init {
if !init.plugins.is_empty() {
script.push_str("mkdir -p /data/plugins\n");
for plugin in &init.plugins {
script.push_str(&format!("cp {} /data/plugins/{}\n", plugin.source, plugin.dest));
}
} else {
script.push_str("mkdir -p /data/plugins\n");
script.push_str(&format!("cp /luxd/build/plugins/{} /data/plugins/\n", c_chain_vm_id));
script.push_str(&format!("cp /luxd/build/plugins/{} /data/plugins/{}\n", c_chain_vm_id, chain_vm_id));
}
}
// Staking key setup
if spec.staking.is_some() {
script.push_str("mkdir -p /data/staking\n");
script.push_str("cp /staking-keys/staker-${IDX}.crt /data/staking/staker.crt\n");
script.push_str("cp /staking-keys/staker-${IDX}.key /data/staking/staker.key\n");
script.push_str("[ -f \"/staking-keys/signer-${IDX}.key\" ] && cp /staking-keys/signer-${IDX}.key /data/staking/signer.key\n");
let uid = spec.security.run_as_user;
let gid = spec.security.run_as_group;
script.push_str(&format!("chown -R {}:{} /data/staking && chmod 600 /data/staking/*\n", uid, gid));
}
script.push_str("echo \"=== Init complete ===\"\n");
Ok(Some(script))
}
fn env_vars(&self, cluster: &Cluster) -> Vec<(String, String)> {
let cfg = Self::parse_config(cluster);
vec![("LUX_NETWORK_ID".into(), cfg.network_id.to_string())]
}
async fn check_node_health(
&self,
cluster: &Cluster,
_pod_name: &str,
pod_ip: &str,
) -> Result<NodeStatus> {
let (http_port, _) = Self::ports(cluster);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|e| OperatorError::Reconcile(format!("HTTP client: {}", e)))?;
// Health check
let url = format!("http://{}:{}/ext/health", pod_ip, http_port);
let healthy = client.get(&url).send().await.map(|r| r.status().is_success()).unwrap_or(false);
// Bootstrap check
let bootstrapped = {
let resp = client.post(&url)
.json(&serde_json::json!({"jsonrpc":"2.0","id":1,"method":"health.health","params":{}}))
.send().await;
resp.ok().and_then(|r| if r.status().is_success() { Some(r) } else { None })
.and_then(|r| futures::executor::block_on(r.json::<serde_json::Value>()).ok())
.and_then(|b| b.get("result")?.get("healthy")?.as_bool())
.unwrap_or(false)
};
// P-chain height
let p_height = {
let url = format!("http://{}:{}/ext/bc/P", pod_ip, http_port);
client.post(&url)
.json(&serde_json::json!({"jsonrpc":"2.0","id":1,"method":"platform.getHeight","params":{}}))
.send().await.ok()
.and_then(|r| if r.status().is_success() { futures::executor::block_on(r.json::<serde_json::Value>()).ok() } else { None })
.and_then(|b| b.get("result")?.get("height")?.as_str()?.parse::<u64>().ok())
};
// C-chain height
let c_height = {
let url = format!("http://{}:{}/ext/bc/C/rpc", pod_ip, http_port);
client.post(&url)
.json(&serde_json::json!({"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}))
.send().await.ok()
.and_then(|r| if r.status().is_success() { futures::executor::block_on(r.json::<serde_json::Value>()).ok() } else { None })
.and_then(|b| {
let hex = b.get("result")?.as_str()?;
u64::from_str_radix(hex.trim_start_matches("0x"), 16).ok()
})
};
// Node ID + peers
let (node_id, connected, inbound, outbound) = {
let info_url = format!("http://{}:{}/ext/info", pod_ip, http_port);
let nid = client.post(&info_url)
.json(&serde_json::json!({"jsonrpc":"2.0","id":1,"method":"info.getNodeID","params":{}}))
.send().await.ok()
.and_then(|r| if r.status().is_success() { futures::executor::block_on(r.json::<serde_json::Value>()).ok() } else { None })
.and_then(|b| b.get("result")?.get("nodeID")?.as_str().map(|s| s.to_string()));
let peers_resp = client.post(&info_url)
.json(&serde_json::json!({"jsonrpc":"2.0","id":1,"method":"info.peers","params":{}}))
.send().await.ok()
.and_then(|r| if r.status().is_success() { futures::executor::block_on(r.json::<serde_json::Value>()).ok() } else { None });
let (total, ib, ob) = peers_resp.and_then(|b| {
let peers = b.get("result")?.get("peers")?.as_array()?;
let total = peers.len() as u32;
let mut ib = 0u32;
let mut ob = 0u32;
for p in peers {
match p.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"inbound" => ib += 1,
_ => ob += 1,
}
}
Some((total, ib, ob))
}).unwrap_or((0, 0, 0));
(nid, total, ib, ob)
};
// Block height = max of P-chain and C-chain
let block_height = p_height.or(c_height);
// Extra driver-specific fields
let mut extra = BTreeMap::new();
if let Some(h) = p_height {
extra.insert("pChainHeight".into(), serde_json::json!(h));
}
if let Some(h) = c_height {
extra.insert("cChainHeight".into(), serde_json::json!(h));
}
Ok(NodeStatus {
node_id,
external_ip: None, // filled by controller from LB services
block_height,
connected_peers: connected,
inbound_peers: inbound,
outbound_peers: outbound,
bootstrapped,
healthy,
start_time: None, // filled by controller from pod status
extra,
})
}
fn validate_config(&self, chain_config: &serde_json::Value) -> Result<()> {
let cfg: LuxConfig = serde_json::from_value(chain_config.clone())
.map_err(|e| OperatorError::Config(format!("Invalid Lux chain_config: {}", e)))?;
if cfg.network_id == 0 {
return Err(OperatorError::Config("networkId must be > 0".into()));
}
Ok(())
}
}
+80
View File
@@ -0,0 +1,80 @@
//! Chain driver abstraction.
//!
//! Every blockchain implements [`ChainDriver`]. The generic controller
//! calls driver methods at the right points — startup script generation,
//! health checks, node identity extraction, etc.
//!
//! Register new drivers in [`DriverRegistry::new`].
pub mod bitcoin;
pub mod ethereum;
pub mod lux;
use crate::crd::{HealthSpec, Cluster, NodeStatus, PortMapping};
use crate::error::Result;
use async_trait::async_trait;
use std::collections::BTreeMap;
use std::sync::Arc;
/// Chain-specific behavior. One implementation per blockchain family.
#[async_trait]
pub trait ChainDriver: Send + Sync {
/// Identifier: "lux", "avalanche", "ethereum", "bitcoin".
fn name(&self) -> &str;
/// Container name for the main process (e.g. "luxd", "geth").
fn container_name(&self) -> &str;
/// Default ports when user doesn't specify any.
fn default_ports(&self) -> Vec<PortMapping>;
/// Default health spec when user doesn't override.
fn default_health(&self) -> HealthSpec;
/// Generate the main startup script (runs as PID 1 in container).
fn startup_script(&self, cluster: &Cluster) -> Result<String>;
/// Generate the startup gate init-container script.
fn startup_gate_script(&self, cluster: &Cluster) -> Result<String>;
/// Generate the init-container script (plugin setup, staking keys, etc.).
fn init_script(&self, cluster: &Cluster) -> Result<Option<String>>;
/// Extra environment variables for the main container.
fn env_vars(&self, cluster: &Cluster) -> Vec<(String, String)>;
/// Check a single node's health. Returns updated NodeStatus.
async fn check_node_health(
&self,
cluster: &Cluster,
pod_name: &str,
pod_ip: &str,
) -> Result<NodeStatus>;
/// Validate chain_config before reconciliation starts.
fn validate_config(&self, chain_config: &serde_json::Value) -> Result<()>;
}
/// Registry of available chain drivers.
pub struct DriverRegistry {
drivers: BTreeMap<String, Arc<dyn ChainDriver>>,
}
impl DriverRegistry {
pub fn new() -> Self {
let mut drivers: BTreeMap<String, Arc<dyn ChainDriver>> = BTreeMap::new();
drivers.insert("lux".into(), Arc::new(lux::LuxDriver));
drivers.insert("avalanche".into(), Arc::new(lux::LuxDriver)); // same family
drivers.insert("ethereum".into(), Arc::new(ethereum::EthereumDriver));
drivers.insert("bitcoin".into(), Arc::new(bitcoin::BitcoinDriver));
Self { drivers }
}
pub fn get(&self, chain: &str) -> Option<Arc<dyn ChainDriver>> {
self.drivers.get(chain).cloned()
}
pub fn chains(&self) -> Vec<&str> {
self.drivers.keys().map(|s| s.as_str()).collect()
}
}
+27
View File
@@ -0,0 +1,27 @@
//! Error types for the operator.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum OperatorError {
#[error("Kubernetes API error: {0}")]
KubeApi(#[from] kube::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Configuration error: {0}")]
Config(String),
#[error("Resource not found: {0}")]
#[allow(dead_code)]
NotFound(String),
#[error("Reconciliation error: {0}")]
Reconcile(String),
#[error("Unknown chain driver: {0}")]
UnknownChain(String),
}
pub type Result<T> = std::result::Result<T, OperatorError>;
+110
View File
@@ -0,0 +1,110 @@
//! Bootnode Operator — generic blockchain node operator for Kubernetes.
//!
//! Manages Cluster and ChainDeployment custom resources for any chain.
//! Chain-specific behavior is pluggable via drivers (see `drivers/`).
mod api;
mod controller;
mod crd;
mod drivers;
mod error;
mod resources;
use clap::Parser;
use kube::Client;
use std::net::SocketAddr;
use std::sync::Arc;
use tracing::{info, Level};
use tracing_subscriber::FmtSubscriber;
#[derive(Parser, Debug)]
#[command(name = "bootnode-operator")]
#[command(about = "Generic blockchain node operator for Kubernetes")]
struct Args {
/// Log level
#[arg(long, default_value = "info")]
log_level: String,
/// Namespace to watch (empty for all namespaces)
#[arg(long, default_value = "")]
namespace: String,
/// Metrics port
#[arg(long, default_value = "8080")]
metrics_port: u16,
/// REST API / health port
#[arg(long, default_value = "8081")]
api_port: u16,
}
async fn metrics() -> &'static str {
// TODO: prometheus metrics
""
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
let level = match args.log_level.to_lowercase().as_str() {
"trace" => Level::TRACE,
"debug" => Level::DEBUG,
"info" => Level::INFO,
"warn" => Level::WARN,
"error" => Level::ERROR,
_ => Level::INFO,
};
let subscriber = FmtSubscriber::builder()
.with_max_level(level)
.with_target(true)
.finish();
tracing::subscriber::set_global_default(subscriber)?;
info!("Starting Bootnode Operator");
info!("Namespace: {}", if args.namespace.is_empty() { "all" } else { &args.namespace });
let client = Client::try_default().await?;
info!("Connected to Kubernetes cluster");
// REST API server (includes /healthz, /readyz)
let api_state = Arc::new(api::ApiState { client: client.clone() });
let api_app = api::router(api_state);
let api_addr = SocketAddr::from(([0, 0, 0, 0], args.api_port));
info!("API server listening on {}", api_addr);
// Metrics server
let metrics_app = axum::Router::new().route("/metrics", axum::routing::get(metrics));
let metrics_addr = SocketAddr::from(([0, 0, 0, 0], args.metrics_port));
info!("Metrics server listening on {}", metrics_addr);
// Run all concurrently
let cluster_client = client.clone();
let chain_client = client.clone();
let cluster_ns = args.namespace.clone();
let chain_ns = args.namespace.clone();
tokio::select! {
res = controller::run_cluster_controller(cluster_client, cluster_ns) => {
if let Err(e) = res { tracing::error!("Cluster controller exited: {:?}", e); }
}
res = controller::run_chain_controller(chain_client, chain_ns) => {
if let Err(e) = res { tracing::error!("Chain controller exited: {:?}", e); }
}
res = axum::serve(
tokio::net::TcpListener::bind(api_addr).await.unwrap(),
api_app.into_make_service(),
) => {
if let Err(e) = res { tracing::error!("API server exited: {:?}", e); }
}
res = axum::serve(
tokio::net::TcpListener::bind(metrics_addr).await.unwrap(),
metrics_app.into_make_service(),
) => {
if let Err(e) = res { tracing::error!("Metrics server exited: {:?}", e); }
}
}
Ok(())
}
+616
View File
@@ -0,0 +1,616 @@
//! K8s resource builders — generic across all chains.
//!
//! Builds StatefulSet, Services, ConfigMaps, RBAC, and PDB from Cluster spec.
//! Chain-specific behavior is injected via the ChainDriver trait.
use crate::crd::{Cluster, PortMapping};
use crate::drivers::ChainDriver;
use crate::error::Result;
use k8s_openapi::api::apps::v1::{
StatefulSet, StatefulSetPersistentVolumeClaimRetentionPolicy, StatefulSetSpec,
};
use k8s_openapi::api::core::v1::{
ConfigMap, Container, ContainerPort, EnvVar, LocalObjectReference,
PersistentVolumeClaim, PersistentVolumeClaimSpec, PodSecurityContext, PodSpec,
PodTemplateSpec, Probe, ResourceRequirements, SecretVolumeSource, Service, ServicePort,
ServiceSpec as K8sServiceSpec, Volume, VolumeMount,
};
use k8s_openapi::api::policy::v1::{PodDisruptionBudget, PodDisruptionBudgetSpec};
use k8s_openapi::api::rbac::v1::{PolicyRule, Role, RoleBinding, RoleRef, Subject};
use k8s_openapi::apimachinery::pkg::api::resource::Quantity;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, OwnerReference};
use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString;
use kube::{Resource, ResourceExt};
use std::collections::BTreeMap;
// ─── Labels & Owner Refs ───
pub fn resource_labels(name: &str, chain: &str) -> BTreeMap<String, String> {
let mut labels = BTreeMap::new();
labels.insert("app.kubernetes.io/name".into(), chain.to_string());
labels.insert("app.kubernetes.io/instance".into(), name.to_string());
labels.insert("app.kubernetes.io/managed-by".into(), "bootnode-operator".into());
labels
}
pub fn owner_reference(cluster: &Cluster) -> OwnerReference {
OwnerReference {
api_version: Cluster::api_version(&()).to_string(),
kind: Cluster::kind(&()).to_string(),
name: cluster.name_any(),
uid: cluster.metadata.uid.clone().unwrap_or_default(),
controller: Some(true),
block_owner_deletion: Some(true),
}
}
fn resolve_ports(cluster: &Cluster, driver: &dyn ChainDriver) -> Vec<PortMapping> {
if cluster.spec.ports.is_empty() {
driver.default_ports()
} else {
cluster.spec.ports.clone()
}
}
fn port_by_name(ports: &[PortMapping], name: &str) -> Option<i32> {
ports.iter().find(|p| p.name == name).map(|p| p.port)
}
fn build_resource_requirements(spec: &crate::crd::ResourceSpec) -> ResourceRequirements {
let mut requests = BTreeMap::new();
let mut limits = BTreeMap::new();
requests.insert("cpu".into(), Quantity(spec.cpu_request.clone().unwrap_or_else(|| "500m".into())));
requests.insert("memory".into(), Quantity(spec.memory_request.clone().unwrap_or_else(|| "1Gi".into())));
limits.insert("cpu".into(), Quantity(spec.cpu_limit.clone().unwrap_or_else(|| "2".into())));
limits.insert("memory".into(), Quantity(spec.memory_limit.clone().unwrap_or_else(|| "4Gi".into())));
ResourceRequirements {
requests: Some(requests),
limits: Some(limits),
..Default::default()
}
}
// ─── ConfigMap ───
pub fn build_config_map(cluster: &Cluster) -> ConfigMap {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let labels = resource_labels(&name, &cluster.spec.chain);
let mut data = BTreeMap::new();
// Config overrides
if !cluster.spec.config.is_empty() {
let config_json = serde_json::to_string_pretty(&cluster.spec.config).unwrap_or_default();
data.insert("config.json".into(), config_json);
}
// Inline genesis
if let Some(genesis) = &cluster.spec.genesis {
let genesis_json = serde_json::to_string_pretty(genesis).unwrap_or_default();
data.insert("genesis.json".into(), genesis_json);
}
ConfigMap {
metadata: kube::core::ObjectMeta {
name: Some(format!("{}-config", name)),
namespace: Some(namespace),
labels: Some(labels),
owner_references: Some(vec![owner_reference(cluster)]),
..Default::default()
},
data: Some(data),
..Default::default()
}
}
pub fn build_startup_configmap(cluster: &Cluster, script: String) -> ConfigMap {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let labels = resource_labels(&name, &cluster.spec.chain);
let mut data = BTreeMap::new();
data.insert("startup.sh".into(), script);
ConfigMap {
metadata: kube::core::ObjectMeta {
name: Some(format!("{}-startup", name)),
namespace: Some(namespace),
labels: Some(labels),
owner_references: Some(vec![owner_reference(cluster)]),
..Default::default()
},
data: Some(data),
..Default::default()
}
}
// ─── Services ───
pub fn build_headless_service(cluster: &Cluster, driver: &dyn ChainDriver) -> Service {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let labels = resource_labels(&name, &cluster.spec.chain);
let ports = resolve_ports(cluster, driver);
Service {
metadata: kube::core::ObjectMeta {
name: Some(format!("{}-headless", name)),
namespace: Some(namespace),
labels: Some(labels.clone()),
owner_references: Some(vec![owner_reference(cluster)]),
..Default::default()
},
spec: Some(K8sServiceSpec {
cluster_ip: Some("None".into()),
selector: Some(labels),
ports: Some(ports.iter().map(|p| ServicePort {
name: Some(p.name.clone()),
port: p.port,
target_port: Some(IntOrString::Int(p.port)),
..Default::default()
}).collect()),
publish_not_ready_addresses: Some(true),
..Default::default()
}),
..Default::default()
}
}
pub fn build_rpc_service(cluster: &Cluster, driver: &dyn ChainDriver) -> Service {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let labels = resource_labels(&name, &cluster.spec.chain);
let ports = resolve_ports(cluster, driver);
let mut annotations = cluster.spec.service.annotations.clone();
annotations.insert(
"service.beta.kubernetes.io/do-loadbalancer-name".into(),
format!("k8s-{}", name),
);
Service {
metadata: kube::core::ObjectMeta {
name: Some(name.clone()),
namespace: Some(namespace),
labels: Some(labels.clone()),
annotations: Some(annotations),
owner_references: Some(vec![owner_reference(cluster)]),
..Default::default()
},
spec: Some(K8sServiceSpec {
type_: Some(cluster.spec.service.service_type.clone()),
selector: Some(labels),
ports: Some(ports.iter().map(|p| ServicePort {
name: Some(p.name.clone()),
port: p.port,
target_port: Some(IntOrString::Int(p.port)),
..Default::default()
}).collect()),
..Default::default()
}),
..Default::default()
}
}
pub fn build_per_pod_service(
cluster: &Cluster,
driver: &dyn ChainDriver,
index: u32,
) -> Service {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let svc_name = format!("{}-{}", name, index);
let ports = resolve_ports(cluster, driver);
let mut labels = resource_labels(&name, &cluster.spec.chain);
labels.insert("pod-index".into(), index.to_string());
let mut selector = BTreeMap::new();
selector.insert("app.kubernetes.io/name".into(), cluster.spec.chain.clone());
selector.insert("app.kubernetes.io/instance".into(), name.clone());
selector.insert("statefulset.kubernetes.io/pod-name".into(), format!("{}-{}", name, index));
let mut annotations = cluster.spec.service.annotations.clone();
annotations.insert(
"service.beta.kubernetes.io/do-loadbalancer-name".into(),
format!("k8s-{}-{}-{}", name, cluster.spec.network, index),
);
Service {
metadata: kube::core::ObjectMeta {
name: Some(svc_name),
namespace: Some(namespace),
labels: Some(labels),
annotations: Some(annotations),
owner_references: Some(vec![owner_reference(cluster)]),
..Default::default()
},
spec: Some(K8sServiceSpec {
type_: Some("LoadBalancer".into()),
selector: Some(selector),
ports: Some(ports.iter().map(|p| ServicePort {
name: Some(p.name.clone()),
port: p.port,
target_port: Some(IntOrString::Int(p.port)),
..Default::default()
}).collect()),
..Default::default()
}),
..Default::default()
}
}
// ─── RBAC ───
pub fn build_role(cluster: &Cluster) -> Role {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
Role {
metadata: kube::core::ObjectMeta {
name: Some(format!("{}-service-reader", name)),
namespace: Some(namespace),
owner_references: Some(vec![owner_reference(cluster)]),
..Default::default()
},
rules: Some(vec![PolicyRule {
api_groups: Some(vec!["".into()]),
resources: Some(vec!["services".into()]),
verbs: vec!["get".into()],
..Default::default()
}]),
}
}
pub fn build_role_binding(cluster: &Cluster) -> RoleBinding {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let role_name = format!("{}-service-reader", name);
RoleBinding {
metadata: kube::core::ObjectMeta {
name: Some(role_name.clone()),
namespace: Some(namespace.clone()),
owner_references: Some(vec![owner_reference(cluster)]),
..Default::default()
},
role_ref: RoleRef {
api_group: "rbac.authorization.k8s.io".into(),
kind: "Role".into(),
name: role_name,
},
subjects: Some(vec![Subject {
kind: "ServiceAccount".into(),
name: "default".into(),
namespace: Some(namespace),
..Default::default()
}]),
}
}
// ─── PDB ───
pub fn build_pdb(cluster: &Cluster) -> PodDisruptionBudget {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let labels = resource_labels(&name, &cluster.spec.chain);
PodDisruptionBudget {
metadata: kube::core::ObjectMeta {
name: Some(format!("{}-pdb", name)),
namespace: Some(namespace),
owner_references: Some(vec![owner_reference(cluster)]),
..Default::default()
},
spec: Some(PodDisruptionBudgetSpec {
max_unavailable: Some(IntOrString::Int(1)),
selector: Some(LabelSelector {
match_labels: Some(labels),
..Default::default()
}),
..Default::default()
}),
..Default::default()
}
}
// ─── StatefulSet ───
pub fn build_statefulset(
cluster: &Cluster,
driver: &dyn ChainDriver,
) -> Result<StatefulSet> {
let name = cluster.name_any();
let namespace = cluster.namespace().unwrap_or_else(|| "default".into());
let spec = &cluster.spec;
let labels = resource_labels(&name, &spec.chain);
let ports = resolve_ports(cluster, driver);
let resources = build_resource_requirements(&spec.resources);
// Health check port
let health = if spec.health == crate::crd::HealthSpec::default() {
driver.default_health()
} else {
spec.health.clone()
};
let health_port = port_by_name(&ports, &health.port_name)
.unwrap_or_else(|| ports.first().map(|p| p.port).unwrap_or(8080));
// Volumes
let startup_cm = spec.startup_script.clone().unwrap_or_else(|| format!("{}-startup", name));
let mut volumes = vec![
Volume {
name: "config".into(),
config_map: Some(k8s_openapi::api::core::v1::ConfigMapVolumeSource {
name: Some(format!("{}-config", name)),
..Default::default()
}),
..Default::default()
},
Volume {
name: "startup-script".into(),
config_map: Some(k8s_openapi::api::core::v1::ConfigMapVolumeSource {
name: Some(startup_cm),
default_mode: Some(0o755),
..Default::default()
}),
..Default::default()
},
];
let mut volume_mounts = vec![
VolumeMount { name: "data".into(), mount_path: "/data".into(), ..Default::default() },
VolumeMount { name: "startup-script".into(), mount_path: "/scripts".into(), ..Default::default() },
];
// Genesis volume
if spec.genesis.is_some() {
volume_mounts.push(VolumeMount {
name: "genesis".into(), mount_path: "/genesis".into(),
read_only: Some(true), ..Default::default()
});
volumes.push(Volume {
name: "genesis".into(),
config_map: Some(k8s_openapi::api::core::v1::ConfigMapVolumeSource {
name: Some(format!("{}-config", name)),
..Default::default()
}),
..Default::default()
});
} else if let Some(genesis_cm) = &spec.genesis_config_map {
volume_mounts.push(VolumeMount {
name: "genesis".into(), mount_path: "/genesis".into(),
read_only: Some(true), ..Default::default()
});
volumes.push(Volume {
name: "genesis".into(),
config_map: Some(k8s_openapi::api::core::v1::ConfigMapVolumeSource {
name: Some(genesis_cm.clone()),
..Default::default()
}),
..Default::default()
});
}
// Staking keys volume
if let Some(staking) = &spec.staking {
volumes.push(Volume {
name: "staking-keys".into(),
secret: Some(SecretVolumeSource {
secret_name: Some(staking.secret_name.clone()),
..Default::default()
}),
..Default::default()
});
volume_mounts.push(VolumeMount {
name: "staking-keys".into(), mount_path: "/staking-keys".into(),
read_only: Some(true), ..Default::default()
});
}
// Env vars from driver
let mut env = vec![
EnvVar {
name: "POD_NAME".into(),
value_from: Some(k8s_openapi::api::core::v1::EnvVarSource {
field_ref: Some(k8s_openapi::api::core::v1::ObjectFieldSelector {
field_path: "metadata.name".into(),
..Default::default()
}),
..Default::default()
}),
..Default::default()
},
];
for (k, v) in driver.env_vars(cluster) {
env.push(EnvVar { name: k, value: Some(v), ..Default::default() });
}
// Main container
let container = Container {
name: driver.container_name().into(),
image: Some(format!("{}:{}", spec.image.repository, spec.image.tag)),
image_pull_policy: Some(spec.image.pull_policy.clone()),
command: Some(vec!["/bin/sh".into(), "/scripts/startup.sh".into()]),
ports: Some(ports.iter().map(|p| ContainerPort {
name: Some(p.name.clone()),
container_port: p.port,
..Default::default()
}).collect()),
env: Some(env),
volume_mounts: Some(volume_mounts.clone()),
resources: Some(resources),
startup_probe: Some(Probe {
tcp_socket: Some(k8s_openapi::api::core::v1::TCPSocketAction {
port: IntOrString::Int(health_port),
..Default::default()
}),
initial_delay_seconds: Some(5),
period_seconds: Some(10),
failure_threshold: Some(30),
..Default::default()
}),
liveness_probe: Some(Probe {
http_get: Some(k8s_openapi::api::core::v1::HTTPGetAction {
path: Some(health.path.clone()),
port: IntOrString::Int(health_port),
..Default::default()
}),
period_seconds: Some(15),
failure_threshold: Some(3),
timeout_seconds: Some(5),
..Default::default()
}),
readiness_probe: Some(Probe {
http_get: Some(k8s_openapi::api::core::v1::HTTPGetAction {
path: Some(health.path),
port: IntOrString::Int(health_port),
..Default::default()
}),
period_seconds: Some(10),
failure_threshold: Some(3),
timeout_seconds: Some(5),
..Default::default()
}),
..Default::default()
};
// Init containers
let mut init_containers = Vec::new();
// Startup gate
let gate = &spec.startup_gate;
if gate.enabled && spec.replicas > 1 {
let gate_script = driver.startup_gate_script(cluster)?;
init_containers.push(Container {
name: "startup-gate".into(),
image: Some(format!("{}:{}", spec.image.repository, spec.image.tag)),
image_pull_policy: Some(spec.image.pull_policy.clone()),
command: Some(vec!["/bin/sh".into(), "-c".into()]),
args: Some(vec![gate_script]),
..Default::default()
});
}
// Driver init script
if let Some(init_script) = driver.init_script(cluster)? {
let init_image = spec.init.as_ref()
.map(|i| i.image.clone())
.unwrap_or_else(|| format!("{}:{}", spec.image.repository, spec.image.tag));
let mut init_vm = vec![
VolumeMount { name: "data".into(), mount_path: "/data".into(), ..Default::default() },
];
if spec.staking.is_some() {
init_vm.push(VolumeMount {
name: "staking-keys".into(), mount_path: "/staking-keys".into(),
read_only: Some(true), ..Default::default()
});
}
init_containers.push(Container {
name: "init-plugins".into(),
image: Some(init_image),
image_pull_policy: Some(spec.init.as_ref().map(|i| i.pull_policy.clone()).unwrap_or_else(|| "IfNotPresent".into())),
security_context: Some(k8s_openapi::api::core::v1::SecurityContext {
run_as_user: Some(0),
run_as_group: Some(0),
..Default::default()
}),
command: Some(vec!["/bin/sh".into(), "-c".into()]),
args: Some(vec![init_script]),
volume_mounts: Some(init_vm),
..Default::default()
});
}
// PVC template
let pvc_template = PersistentVolumeClaim {
metadata: kube::core::ObjectMeta {
name: Some("data".into()),
..Default::default()
},
spec: Some(PersistentVolumeClaimSpec {
access_modes: Some(vec!["ReadWriteOnce".into()]),
storage_class_name: spec.storage.storage_class.clone(),
resources: Some(ResourceRequirements {
requests: Some({
let mut m = BTreeMap::new();
m.insert("storage".into(), Quantity(spec.storage.size.clone()));
m
}),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let pull_secrets: Option<Vec<LocalObjectReference>> = if spec.image.pull_secrets.is_empty() {
None
} else {
Some(spec.image.pull_secrets.iter().map(|s| LocalObjectReference { name: Some(s.clone()) }).collect())
};
let update_strategy = match spec.upgrade_strategy.strategy_type.as_str() {
"RollingUpdate" => k8s_openapi::api::apps::v1::StatefulSetUpdateStrategy {
type_: Some("RollingUpdate".into()),
..Default::default()
},
_ => k8s_openapi::api::apps::v1::StatefulSetUpdateStrategy {
type_: Some("OnDelete".into()),
..Default::default()
},
};
Ok(StatefulSet {
metadata: kube::core::ObjectMeta {
name: Some(name.clone()),
namespace: Some(namespace),
labels: Some(labels.clone()),
annotations: Some({
let mut a = BTreeMap::new();
a.insert("bootnode.dev/protect-pvc".into(), "true".into());
a
}),
owner_references: Some(vec![owner_reference(cluster)]),
..Default::default()
},
spec: Some(StatefulSetSpec {
replicas: Some(spec.replicas as i32),
selector: LabelSelector { match_labels: Some(labels.clone()), ..Default::default() },
service_name: format!("{}-headless", name),
template: PodTemplateSpec {
metadata: Some(kube::core::ObjectMeta { labels: Some(labels), ..Default::default() }),
spec: Some(PodSpec {
security_context: Some(PodSecurityContext {
fs_group: Some(spec.security.fs_group),
run_as_user: Some(spec.security.run_as_user),
run_as_group: Some(spec.security.run_as_group),
..Default::default()
}),
init_containers: if init_containers.is_empty() { None } else { Some(init_containers) },
containers: vec![container],
volumes: Some(volumes),
image_pull_secrets: pull_secrets,
termination_grace_period_seconds: Some(30),
..Default::default()
}),
},
volume_claim_templates: Some(vec![pvc_template]),
pod_management_policy: Some("Parallel".into()),
update_strategy: Some(update_strategy),
persistent_volume_claim_retention_policy: Some(
StatefulSetPersistentVolumeClaimRetentionPolicy {
when_deleted: Some("Retain".into()),
when_scaled: Some("Retain".into()),
},
),
..Default::default()
}),
..Default::default()
})
}
+22 -19
View File
@@ -210,7 +210,7 @@ export default function LuxFleetsPage() {
if (!selectedCluster) return
if (!silent) setLoading(true)
try {
const data = await apiFetch(`/v1/lux/fleets?cluster_id=${selectedCluster}`)
const data = await apiFetch(`/v1/fleets?cluster_id=${selectedCluster}&chain=lux`)
setFleets(Array.isArray(data) ? data : [])
setError(null)
} catch (e) {
@@ -225,7 +225,7 @@ export default function LuxFleetsPage() {
const fetchStats = useCallback(async () => {
try {
const data = await apiFetch("/v1/lux/stats")
const data = await apiFetch("/v1/fleets/stats?cluster_id=${selectedCluster}&chain=lux")
setStats(data)
} catch {
// best-effort
@@ -234,7 +234,7 @@ export default function LuxFleetsPage() {
const fetchNetworks = useCallback(async () => {
try {
const data = await apiFetch("/v1/lux/networks")
const data = await apiFetch("/v1/fleets/networks?chain=lux")
if (Array.isArray(data)) setNetworks(data)
} catch {
// best-effort
@@ -246,7 +246,7 @@ export default function LuxFleetsPage() {
if (!selectedCluster) return
try {
const data: LuxFleetResponse = await apiFetch(
`/v1/lux/fleets/${selectedCluster}/${network}`,
`/v1/fleets/lux/${selectedCluster}/${network}`,
)
setFleetDetails((prev) => ({ ...prev, [`${selectedCluster}/${network}`]: data }))
} catch {
@@ -294,15 +294,18 @@ export default function LuxFleetsPage() {
setCreating(true)
setCreateError(null)
try {
await apiFetch("/v1/lux/fleets", {
await apiFetch("/v1/fleets", {
method: "POST",
body: JSON.stringify({
name: formName.trim(),
cluster_id: selectedCluster,
chain: "lux",
network: formNetwork,
replicas: formReplicas,
image_tag: formImageTag.trim(),
chain_tracking: formChainTracking,
chain_config: {
image_tag: formImageTag.trim(),
chain_tracking: formChainTracking,
},
}),
})
setCreateOpen(false)
@@ -329,7 +332,7 @@ export default function LuxFleetsPage() {
setActionLoading(network)
try {
await apiFetch(
`/v1/lux/fleets/${selectedCluster}/${network}/scale?replicas=${replicas}`,
`/v1/fleets/lux/${selectedCluster}/${network}/scale?replicas=${replicas}`,
{ method: "POST" },
)
setScaleTarget(null)
@@ -344,7 +347,7 @@ export default function LuxFleetsPage() {
async function handleRestart(network: NetworkType) {
setActionLoading(network)
try {
await apiFetch(`/v1/lux/fleets/${selectedCluster}/${network}/restart`, {
await apiFetch(`/v1/fleets/lux/${selectedCluster}/${network}/restart`, {
method: "POST",
})
setConfirmRestart(null)
@@ -359,7 +362,7 @@ export default function LuxFleetsPage() {
async function handleDestroy(network: NetworkType) {
setActionLoading(network)
try {
await apiFetch(`/v1/lux/fleets/${selectedCluster}/${network}`, {
await apiFetch(`/v1/fleets/lux/${selectedCluster}/${network}`, {
method: "DELETE",
})
setConfirmDestroy(null)
@@ -918,15 +921,15 @@ export default function LuxFleetsPage() {
<CardContent>
<div className="space-y-2">
{[
{ method: "GET", path: "/v1/lux/fleets?cluster_id=xxx", desc: "List fleets" },
{ method: "GET", path: "/v1/lux/fleets/{cluster_id}/{network}", desc: "Fleet detail with nodes" },
{ method: "POST", path: "/v1/lux/fleets", desc: "Create fleet" },
{ method: "PATCH", path: "/v1/lux/fleets/{cluster_id}/{network}", desc: "Update fleet config" },
{ method: "DELETE", path: "/v1/lux/fleets/{cluster_id}/{network}", desc: "Destroy fleet" },
{ method: "POST", path: "/v1/lux/fleets/{cluster_id}/{network}/scale?replicas=N", desc: "Scale replicas" },
{ method: "POST", path: "/v1/lux/fleets/{cluster_id}/{network}/restart", desc: "Rolling restart" },
{ method: "GET", path: "/v1/lux/stats", desc: "Fleet statistics" },
{ method: "GET", path: "/v1/lux/networks", desc: "Network configs" },
{ method: "GET", path: "/v1/fleets?cluster_id=xxx", desc: "List fleets" },
{ method: "GET", path: "/v1/fleets/{cluster_id}/{network}", desc: "Fleet detail with nodes" },
{ method: "POST", path: "/v1/fleets", desc: "Create fleet" },
{ method: "PATCH", path: "/v1/fleets/{cluster_id}/{network}", desc: "Update fleet config" },
{ method: "DELETE", path: "/v1/fleets/{cluster_id}/{network}", desc: "Destroy fleet" },
{ method: "POST", path: "/v1/fleets/{cluster_id}/{network}/scale?replicas=N", desc: "Scale replicas" },
{ method: "POST", path: "/v1/fleets/{cluster_id}/{network}/restart", desc: "Rolling restart" },
{ method: "GET", path: "/v1/fleets/stats?cluster_id=${selectedCluster}&chain=lux", desc: "Fleet statistics" },
{ method: "GET", path: "/v1/fleets/networks?chain=lux", desc: "Network configs" },
].map((ep) => (
<div
key={ep.method + ep.path}
@@ -0,0 +1,317 @@
"use client"
import { useState, useEffect } from "react"
import {
Activity,
AlertTriangle,
CheckCircle2,
XCircle,
BarChart3,
Clock,
Zap,
RefreshCw,
Bell,
Eye,
ArrowUpRight,
ArrowDownRight,
Minus,
} from "lucide-react"
interface HealthOverview {
total_services: number
healthy: number
unhealthy: number
degraded: number
uptime_percent: number
services: { name: string; healthy: boolean; replicas: number; ready_replicas: number; latency_ms: number | null }[]
checked_at: string
}
interface MetricsData {
requests_per_sec: number
latency_p50_ms: number
latency_p95_ms: number
latency_p99_ms: number
error_rate: number
active_connections: number
total_requests_24h: number
}
interface PlatformEvent {
id: string
type: string
service: string | null
message: string
metadata: Record<string, any>
timestamp: string
}
interface Alert {
id: string
severity: "critical" | "warning" | "info"
service: string
message: string
fired_at: string
resolved_at: string | null
}
function StatCard({
label,
value,
unit,
trend,
icon: Icon,
}: {
label: string
value: string | number
unit?: string
trend?: "up" | "down" | "flat"
icon: any
}) {
return (
<div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold">{value}</span>
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
{trend && (
<span className={`ml-auto flex items-center text-xs ${
trend === "up" ? "text-green-400" : trend === "down" ? "text-red-400" : "text-muted-foreground"
}`}>
{trend === "up" && <ArrowUpRight className="h-3 w-3" />}
{trend === "down" && <ArrowDownRight className="h-3 w-3" />}
{trend === "flat" && <Minus className="h-3 w-3" />}
</span>
)}
</div>
</div>
)
}
function SeverityBadge({ severity }: { severity: string }) {
const styles: Record<string, string> = {
critical: "bg-red-500/10 text-red-400 border-red-500/20",
warning: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
info: "bg-blue-500/10 text-blue-400 border-blue-500/20",
}
return (
<span className={`text-xs px-2 py-0.5 rounded-full border ${styles[severity] || styles.info}`}>
{severity}
</span>
)
}
export default function ObservabilityPage() {
const [health, setHealth] = useState<HealthOverview | null>(null)
const [metrics, setMetrics] = useState<MetricsData | null>(null)
const [events, setEvents] = useState<PlatformEvent[]>([])
const [alerts, setAlerts] = useState<Alert[]>([])
const [loading, setLoading] = useState(true)
const fetchData = async () => {
setLoading(true)
try {
const [healthRes, metricsRes, eventsRes, alertsRes] = await Promise.allSettled([
fetch("/api/v1/o11y/health").then((r) => r.ok ? r.json() : null),
fetch("/api/v1/o11y/metrics").then((r) => r.ok ? r.json() : null),
fetch("/api/v1/o11y/events?limit=20").then((r) => r.ok ? r.json() : []),
fetch("/api/v1/o11y/alerts").then((r) => r.ok ? r.json() : []),
])
if (healthRes.status === "fulfilled" && healthRes.value) setHealth(healthRes.value)
if (metricsRes.status === "fulfilled" && metricsRes.value) setMetrics(metricsRes.value)
if (eventsRes.status === "fulfilled") setEvents(eventsRes.value || [])
if (alertsRes.status === "fulfilled") setAlerts(alertsRes.value || [])
} catch {
// silently handle
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
const interval = setInterval(fetchData, 30000)
return () => clearInterval(interval)
}, [])
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Observability</h1>
<p className="text-muted-foreground mt-1">
Platform health, metrics, alerts, and events
</p>
</div>
<div className="flex items-center gap-3">
<a
href="https://grafana.internal/d/bootnode"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 bg-card border border-border rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
>
<BarChart3 className="h-4 w-4" />
Grafana
</a>
<button
onClick={fetchData}
className="flex items-center gap-2 bg-card border border-border rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</button>
</div>
</div>
{/* Health overview cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
label="Total Services"
value={health?.total_services ?? "-"}
icon={Eye}
/>
<StatCard
label="Healthy"
value={health?.healthy ?? "-"}
icon={CheckCircle2}
trend={health ? (health.healthy === health.total_services ? "up" : "down") : undefined}
/>
<StatCard
label="Active Alerts"
value={alerts.length}
icon={Bell}
trend={alerts.length > 0 ? "down" : "flat"}
/>
<StatCard
label="Uptime"
value={health?.uptime_percent ?? "-"}
unit="%"
icon={Activity}
/>
</div>
{/* Metrics row */}
{metrics && (
<div>
<h2 className="text-lg font-semibold mb-4">Metrics</h2>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4">
<StatCard label="Req/s" value={metrics.requests_per_sec.toFixed(1)} icon={Zap} />
<StatCard label="p50" value={metrics.latency_p50_ms.toFixed(0)} unit="ms" icon={Clock} />
<StatCard label="p95" value={metrics.latency_p95_ms.toFixed(0)} unit="ms" icon={Clock} />
<StatCard label="p99" value={metrics.latency_p99_ms.toFixed(0)} unit="ms" icon={Clock} />
<StatCard label="Error Rate" value={(metrics.error_rate * 100).toFixed(2)} unit="%" icon={AlertTriangle} />
<StatCard label="Connections" value={metrics.active_connections} icon={Activity} />
<StatCard label="24h Requests" value={metrics.total_requests_24h.toLocaleString()} icon={BarChart3} />
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Active alerts */}
<div>
<h2 className="text-lg font-semibold mb-4">Active Alerts</h2>
<div className="bg-card border border-border rounded-lg divide-y divide-border">
{alerts.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<CheckCircle2 className="h-8 w-8 mx-auto mb-2 text-green-400" />
<p>No active alerts</p>
</div>
) : (
alerts.map((alert) => (
<div key={alert.id} className="p-4 flex items-start gap-3">
<SeverityBadge severity={alert.severity} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{alert.message}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{alert.service} &middot; {new Date(alert.fired_at).toLocaleString()}
</p>
</div>
</div>
))
)}
</div>
</div>
{/* Recent events */}
<div>
<h2 className="text-lg font-semibold mb-4">Recent Events</h2>
<div className="bg-card border border-border rounded-lg divide-y divide-border">
{events.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Activity className="h-8 w-8 mx-auto mb-2" />
<p>No recent events</p>
</div>
) : (
events.map((event) => (
<div key={event.id} className="p-4 flex items-start gap-3">
<span className="text-xs bg-card border border-border px-2 py-0.5 rounded-full">
{event.type}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm">{event.message}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{event.service && `${event.service} · `}
{new Date(event.timestamp).toLocaleString()}
</p>
</div>
</div>
))
)}
</div>
</div>
</div>
{/* Service health table */}
{health && (
<div>
<h2 className="text-lg font-semibold mb-4">Service Health</h2>
<div className="bg-card border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="text-left p-3 font-medium">Service</th>
<th className="text-left p-3 font-medium">Status</th>
<th className="text-left p-3 font-medium">Replicas</th>
<th className="text-left p-3 font-medium">Latency</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{health.services.map((svc) => (
<tr key={svc.name} className="hover:bg-muted/30 transition-colors">
<td className="p-3 font-mono text-xs">{svc.name}</td>
<td className="p-3">
{svc.healthy ? (
<span className="flex items-center gap-1.5 text-green-400 text-xs">
<CheckCircle2 className="h-3 w-3" /> Healthy
</span>
) : svc.replicas > 0 ? (
<span className="flex items-center gap-1.5 text-red-400 text-xs">
<XCircle className="h-3 w-3" /> Unhealthy
</span>
) : (
<span className="flex items-center gap-1.5 text-muted-foreground text-xs">
<Minus className="h-3 w-3" /> Not deployed
</span>
)}
</td>
<td className="p-3 text-xs text-muted-foreground">
{svc.ready_replicas}/{svc.replicas}
</td>
<td className="p-3 text-xs text-muted-foreground">
{svc.latency_ms != null ? `${svc.latency_ms.toFixed(0)}ms` : "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,259 @@
"use client"
import { useState, useEffect } from "react"
import {
Server,
Search,
ArrowLeftRight,
ArrowUpDown,
ShoppingBag,
Wallet,
Shield,
Droplets,
Coins,
Globe,
Boxes,
Activity,
RefreshCw,
Play,
Square,
RotateCw,
ChevronDown,
AlertCircle,
CheckCircle2,
XCircle,
Minus,
} from "lucide-react"
interface ServiceInfo {
name: string
running: boolean
replicas: number
ready_replicas: number
cpu_usage: number | null
memory_usage: number | null
image: string | null
started_at: string | null
}
interface ServiceCategory {
name: string
icon: any
services: string[]
}
const SERVICE_CATEGORIES: ServiceCategory[] = [
{
name: "Core",
icon: Server,
services: ["api", "web", "indexer", "bundler", "webhook-worker"],
},
{
name: "Blockchain",
icon: Boxes,
services: ["explorer", "graph-node", "bridge", "exchange", "validator", "rpc-proxy", "staking"],
},
{
name: "Security",
icon: Shield,
services: ["mpc", "mpc-postgres", "safe", "safe-transaction", "safe-config"],
},
{
name: "Tools",
icon: Globe,
services: ["faucet", "market", "gateway", "graph-postgres", "graph-ipfs"],
},
]
const NETWORKS = ["mainnet", "testnet", "devnet"]
function StatusBadge({ running, ready, total }: { running: boolean; ready: number; total: number }) {
if (!running && total === 0) {
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />
Not deployed
</span>
)
}
if (!running) {
return (
<span className="flex items-center gap-1.5 text-xs text-red-400">
<XCircle className="h-3 w-3" />
Stopped
</span>
)
}
if (ready < total) {
return (
<span className="flex items-center gap-1.5 text-xs text-yellow-400">
<AlertCircle className="h-3 w-3" />
Degraded ({ready}/{total})
</span>
)
}
return (
<span className="flex items-center gap-1.5 text-xs text-green-400">
<CheckCircle2 className="h-3 w-3" />
Running ({ready}/{total})
</span>
)
}
export default function ServicesPage() {
const [services, setServices] = useState<ServiceInfo[]>([])
const [loading, setLoading] = useState(true)
const [network, setNetwork] = useState("mainnet")
const [error, setError] = useState<string | null>(null)
const fetchServices = async () => {
try {
setLoading(true)
const res = await fetch("/api/v1/infra/services")
if (res.ok) {
const data = await res.json()
setServices(data.services || [])
setError(null)
} else {
setError("Failed to load services")
}
} catch {
setError("Failed to connect to API")
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchServices()
}, [network])
const getServiceStatus = (name: string): ServiceInfo | undefined =>
services.find((s) => s.name === name)
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Services</h1>
<p className="text-muted-foreground mt-1">
Manage blockchain infrastructure services per network
</p>
</div>
<div className="flex items-center gap-3">
{/* Network selector */}
<div className="relative">
<select
value={network}
onChange={(e) => setNetwork(e.target.value)}
className="appearance-none bg-card border border-border rounded-md px-3 py-2 pr-8 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
{NETWORKS.map((n) => (
<option key={n} value={n}>
{n.charAt(0).toUpperCase() + n.slice(1)}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 pointer-events-none text-muted-foreground" />
</div>
<button
onClick={fetchServices}
className="flex items-center gap-2 bg-card border border-border rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</button>
<button className="flex items-center gap-2 bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium hover:bg-primary/90 transition-colors">
<Play className="h-4 w-4" />
Deploy Service
</button>
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-400 text-sm">
{error}
</div>
)}
{/* Service categories */}
{SERVICE_CATEGORIES.map((category) => (
<div key={category.name}>
<div className="flex items-center gap-2 mb-4">
<category.icon className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold">{category.name}</h2>
<span className="text-xs text-muted-foreground bg-card px-2 py-0.5 rounded-full">
{category.services.length} services
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{category.services.map((svcName) => {
const svc = getServiceStatus(svcName)
const isRunning = svc?.running ?? false
const replicas = svc?.replicas ?? 0
const readyReplicas = svc?.ready_replicas ?? 0
return (
<div
key={svcName}
className="bg-card border border-border rounded-lg p-4 hover:border-primary/50 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-medium">{svcName}</h3>
{svc?.image && (
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[200px]">
{svc.image}
</p>
)}
</div>
<StatusBadge running={isRunning} ready={readyReplicas} total={replicas} />
</div>
{/* Metrics */}
{isRunning && (
<div className="flex gap-4 mb-3 text-xs text-muted-foreground">
{svc?.cpu_usage != null && (
<span>CPU: {(svc.cpu_usage * 100).toFixed(0)}%</span>
)}
{svc?.memory_usage != null && (
<span>Mem: {(svc.memory_usage * 100).toFixed(0)}%</span>
)}
</div>
)}
{/* Actions */}
<div className="flex gap-2 mt-auto pt-2 border-t border-border">
{isRunning ? (
<>
<button className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-accent">
<RotateCw className="h-3 w-3" />
Restart
</button>
<button className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-accent">
<Activity className="h-3 w-3" />
Logs
</button>
<button className="flex items-center gap-1 text-xs text-red-400 hover:text-red-300 transition-colors px-2 py-1 rounded hover:bg-red-500/10 ml-auto">
<Square className="h-3 w-3" />
Stop
</button>
</>
) : (
<button className="flex items-center gap-1 text-xs text-green-400 hover:text-green-300 transition-colors px-2 py-1 rounded hover:bg-green-500/10">
<Play className="h-3 w-3" />
Deploy
</button>
)}
</div>
</div>
)
})}
</div>
</div>
))}
</div>
)
}
+30 -4
View File
@@ -13,7 +13,9 @@ import {
Code2,
Database,
Globe,
KeyRound,
Layers,
Search,
Shield,
Wallet,
Webhook,
@@ -46,8 +48,8 @@ export default function HomePage() {
</h1>
<p className="mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
The complete blockchain development platform. Multi-chain RPC, Token
APIs, NFT APIs, Smart Wallets, Account Abstraction, and more. Ship
faster with {brand.name}.
APIs, Smart Wallets, Block Explorers, MPC key management, custom
Rollups, and more. Ship faster with {brand.name}.
</p>
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
<Button size="xl" asChild>
@@ -76,7 +78,7 @@ export default function HomePage() {
complete infrastructure stack for blockchain developers.
</p>
</div>
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<ProductCard
icon={<Globe className="h-6 w-6" />}
title="Node"
@@ -95,6 +97,18 @@ export default function HomePage() {
description="Onboard users with secure, easy-to-use smart wallets. No seed phrase or gas required."
href="/products/wallets"
/>
<ProductCard
icon={<Search className="h-6 w-6" />}
title="Explorer"
description="Deploy your own block explorer with full transaction search, contract verification, and analytics."
href="/products/explorer"
/>
<ProductCard
icon={<KeyRound className="h-6 w-6" />}
title="MPC"
description="Launch a threshold MPC network for institutional-grade key management. No single point of compromise."
href="/products/mpc"
/>
<ProductCard
icon={<Layers className="h-6 w-6" />}
title="Rollups"
@@ -237,7 +251,7 @@ export default function HomePage() {
handle the heavy lifting so you can focus on building.
</p>
</div>
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<APICard
icon={<Globe className="h-6 w-6" />}
title="RPC API"
@@ -274,6 +288,18 @@ export default function HomePage() {
description="ERC-4337 account abstraction with gas sponsorship, batched transactions, and social login."
features={["Gasless txs", "Session keys", "Social recovery"]}
/>
<APICard
icon={<Search className="h-6 w-6" />}
title="Explorer API"
description="Programmatic access to your block explorer. Search transactions, verify contracts, export data."
features={["Contract verification", "Token tracking", "Analytics"]}
/>
<APICard
icon={<KeyRound className="h-6 w-6" />}
title="MPC Signing"
description="Threshold signatures via distributed MPC nodes. ECDSA and EdDSA across 15+ chains."
features={["CGGMP21 & FROST", "Key resharing", "Audit trails"]}
/>
</div>
</div>
</section>
+412
View File
@@ -0,0 +1,412 @@
"use client"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Navbar } from "@/components/navbar"
import { Footer } from "@/components/footer"
import {
ArrowRight,
CheckCircle2,
Code2,
FileSearch,
Globe,
LayoutDashboard,
Search,
Shield,
Zap,
Database,
BarChart3,
Braces,
Palette,
} from "lucide-react"
import { getBrand } from "@/lib/brand"
export default function ExplorerProductPage() {
const brand = getBrand()
return (
<div className="flex min-h-screen flex-col">
<Navbar />
{/* Hero */}
<section className="relative overflow-hidden 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-24 md:py-32 lg:py-40">
<div className="mx-auto flex max-w-4xl flex-col items-center text-center">
<Badge variant="secondary" className="mb-4">
<Search className="mr-1 h-3 w-3" />
Block Explorer
</Badge>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl">
Your chain,{" "}
<span className="bg-gradient-to-r from-emerald-500 to-teal-500 bg-clip-text text-transparent">
fully visible
</span>
</h1>
<p className="mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
Deploy a branded block explorer for any EVM chain, rollup, or
subnet. Full transaction search, contract verification, token
tracking, and analytics—ready in minutes, managed by {brand.name}.
</p>
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
<Button size="xl" asChild>
<Link href="/dashboard">
Deploy Explorer
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button size="xl" variant="outline" asChild>
<Link href="/docs/explorer">Read the Docs</Link>
</Button>
</div>
</div>
</div>
</section>
{/* Stats */}
<section className="border-b bg-muted/30 py-12">
<div className="container">
<div className="grid grid-cols-2 gap-8 md:grid-cols-4">
<StatItem label="Explorers Deployed" value="120+" />
<StatItem label="Indexed Blocks" value="5B+" />
<StatItem label="Verified Contracts" value="800K+" />
<StatItem label="Time to Deploy" value="<5 min" />
</div>
</div>
</section>
{/* Why Your Own Explorer */}
<section className="border-b py-24">
<div className="container">
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16">
<div className="flex flex-col justify-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Why deploy your own explorer?
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Public explorers are generic. Your chain deserves a dedicated
explorer with your branding, your analytics, and an API your
developers can rely on.
</p>
<ul className="mt-8 space-y-4">
{[
{
title: "Full brand control",
desc: "Your logo, colors, domain, and custom links. The explorer feels like part of your ecosystem, not a third-party tool.",
},
{
title: "Dedicated indexing",
desc: "No shared infrastructure. Your explorer indexes your chain with priority, delivering sub-second search results.",
},
{
title: "Developer API",
desc: "Every explorer includes a full REST API for contract verification, transaction lookup, token balances, and analytics export.",
},
{
title: "Works with any EVM chain",
desc: "Ethereum L1, OP Stack rollups, Arbitrum Orbit, Lux subnets, or any EVM-compatible network.",
},
].map((item) => (
<li key={item.title} className="flex items-start gap-3">
<CheckCircle2 className="mt-1 h-5 w-5 shrink-0 text-green-500" />
<div>
<div className="font-semibold">{item.title}</div>
<div className="text-sm text-muted-foreground">
{item.desc}
</div>
</div>
</li>
))}
</ul>
</div>
<div className="flex flex-col justify-center gap-6">
<Card className="border-emerald-500/20 bg-gradient-to-br from-emerald-500/5 to-teal-500/5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileSearch className="h-5 w-5 text-emerald-500" />
Transaction Search
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Search by address, transaction hash, block number, token
name, or ENS. Advanced filters for value ranges, method
signatures, and date ranges. Results in milliseconds.
</p>
</CardContent>
</Card>
<Card className="border-teal-500/20 bg-gradient-to-br from-teal-500/5 to-cyan-500/5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-teal-500" />
Contract Verification
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Solidity and Vyper source verification with multi-file and
JSON input support. Verified contracts get read/write UI,
ABI decoding, and event log parsing automatically.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
{/* What's Included */}
<section className="border-b bg-muted/30 py-24">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Everything included out of the box
</h2>
<p className="mt-4 text-lg text-muted-foreground">
A production-ready block explorer with indexing, API, analytics, and
branding—fully managed by {brand.name}.
</p>
</div>
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<FeatureCard
icon={<Search className="h-6 w-6" />}
title="Full-Text Search"
description="Instant search across transactions, addresses, tokens, blocks, and verified contracts. Autocomplete and fuzzy matching included."
/>
<FeatureCard
icon={<Shield className="h-6 w-6" />}
title="Contract Verification"
description="Solidity and Vyper multi-file verification. Automatically generates read/write UI, event decoding, and ABI explorer for verified contracts."
/>
<FeatureCard
icon={<Database className="h-6 w-6" />}
title="Token Tracking"
description="ERC-20, ERC-721, and ERC-1155 token pages with holder lists, transfer history, and real-time supply tracking out of the box."
/>
<FeatureCard
icon={<BarChart3 className="h-6 w-6" />}
title="On-Chain Analytics"
description="Built-in charts for daily transactions, active addresses, gas usage, contract deployments, and token transfer volume."
/>
<FeatureCard
icon={<Braces className="h-6 w-6" />}
title="REST & GraphQL API"
description="Etherscan-compatible REST API plus a GraphQL endpoint. Your developers can integrate programmatic access from day one."
/>
<FeatureCard
icon={<Palette className="h-6 w-6" />}
title="Custom Branding"
description="Your logo, color scheme, favicon, domain, and footer links. White-label the explorer as a seamless part of your chain ecosystem."
/>
<FeatureCard
icon={<Zap className="h-6 w-6" />}
title="Real-Time Indexing"
description="New blocks and transactions appear within seconds. WebSocket subscriptions for live updates on addresses and contract events."
/>
<FeatureCard
icon={<LayoutDashboard className="h-6 w-6" />}
title="Admin Dashboard"
description={`Manage verified contracts, featured tokens, banner announcements, and API keys through the ${brand.name} dashboard.`}
/>
<FeatureCard
icon={<Globe className="h-6 w-6" />}
title="Multi-Chain Ready"
description="Deploy explorers for multiple chains from one account. Each explorer gets its own domain, branding, and indexer."
/>
</div>
</div>
</section>
{/* How It Works */}
<section className="border-b py-24">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Live in under five minutes
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Connect your chain's RPC, customize the look, and {brand.name} handles
the rest.
</p>
</div>
<div className="mt-16 grid gap-6 md:grid-cols-4">
<StepCard
step="1"
title="Connect"
description="Provide your chain's RPC endpoint and chain ID. Works with any EVM-compatible network."
/>
<StepCard
step="2"
title="Brand"
description="Upload your logo, choose colors, set your custom domain, and configure footer links."
/>
<StepCard
step="3"
title="Index"
description={`${brand.name} spins up a dedicated indexer. Historical blocks are backfilled while new blocks stream in real-time.`}
/>
<StepCard
step="4"
title="Launch"
description="Your explorer is live. Share the URL, enable the API, and let your community explore."
/>
</div>
</div>
</section>
{/* Code Example */}
<section className="border-b bg-muted/30 py-24">
<div className="container">
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16">
<div className="flex flex-col justify-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Etherscan-compatible API included
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Every explorer ships with a REST API that is compatible with the
Etherscan API standard. Existing tools, scripts, and integrations
work without changes.
</p>
<div className="mt-8">
<Button asChild>
<Link href="/docs/explorer/api">
API Reference
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
<div className="flex items-center">
<Card className="w-full">
<CardContent className="p-0">
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-6 text-sm text-zinc-100">
<code>{`# Verify a contract
curl -X POST https://explorer.yourchain.com/api \\
-d "module=contract" \\
-d "action=verifysourcecode" \\
-d "contractaddress=0x..." \\
-d "sourceCode=@MyContract.sol" \\
-d "compilerversion=v0.8.20"
# Get token holders
curl "https://explorer.yourchain.com/api\\
?module=token\\
&action=tokenholderlist\\
&contractaddress=0x...\\
&page=1&offset=100"
# Transaction list for address
curl "https://explorer.yourchain.com/api\\
?module=account\\
&action=txlist\\
&address=0x...\\
&startblock=0&endblock=latest"`}</code>
</pre>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
{/* CTA */}
<section className="py-24">
<div className="container">
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-emerald-600 to-teal-600 px-6 py-20 sm:px-12 sm:py-28">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff1a_1px,transparent_1px),linear-gradient(to_bottom,#ffffff1a_1px,transparent_1px)] bg-[size:14px_24px]" />
<div className="relative mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
Every chain deserves a great explorer
</h2>
<p className="mt-4 text-lg text-white/80">
Deploy a branded, fully-featured block explorer for your network
in minutes. Free tier available for testnets.
</p>
<div className="mt-10 flex flex-col justify-center gap-4 sm:flex-row">
<Button
size="xl"
className="bg-white text-emerald-600 hover:bg-white/90"
asChild
>
<Link href="/dashboard">
Deploy Explorer
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button
size="xl"
variant="outline"
className="border-white/30 bg-transparent text-white hover:bg-white/10"
asChild
>
<Link href="/docs/explorer">Read the Docs</Link>
</Button>
</div>
</div>
</div>
</div>
</section>
<Footer />
</div>
)
}
function StatItem({ label, value }: { label: string; value: string }) {
return (
<div className="text-center">
<div className="text-2xl font-bold md:text-3xl">{value}</div>
<div className="mt-1 text-sm text-muted-foreground">{label}</div>
</div>
)
}
function FeatureCard({
icon,
title,
description,
}: {
icon: React.ReactNode
title: string
description: string
}) {
return (
<Card className="h-full">
<CardHeader>
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
{icon}
</div>
<CardTitle className="mt-4">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{description}</p>
</CardContent>
</Card>
)
}
function StepCard({
step,
title,
description,
}: {
step: string
title: string
description: string
}) {
return (
<Card className="h-full">
<CardHeader>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
{step}
</div>
<CardTitle className="mt-4">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{description}</p>
</CardContent>
</Card>
)
}
+574
View File
@@ -0,0 +1,574 @@
"use client"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Navbar } from "@/components/navbar"
import { Footer } from "@/components/footer"
import {
ArrowRight,
CheckCircle2,
Globe,
KeyRound,
Lock,
Network,
RefreshCw,
Server,
Shield,
ShieldCheck,
Users,
Wallet,
Zap,
} from "lucide-react"
import { getBrand } from "@/lib/brand"
export default function MPCProductPage() {
const brand = getBrand()
return (
<div className="flex min-h-screen flex-col">
<Navbar />
{/* Hero */}
<section className="relative overflow-hidden 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-24 md:py-32 lg:py-40">
<div className="mx-auto flex max-w-4xl flex-col items-center text-center">
<Badge variant="secondary" className="mb-4">
<KeyRound className="mr-1 h-3 w-3" />
MPC Key Management
</Badge>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl">
No single point{" "}
<span className="bg-gradient-to-r from-violet-500 to-purple-500 bg-clip-text text-transparent">
of compromise
</span>
</h1>
<p className="mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
Launch a distributed MPC node cluster for institutional-grade key
management. Threshold signatures across 15+ chains—no single node
ever holds the full private key. Powered by Lux MPC.
</p>
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
<Button size="xl" asChild>
<Link href="/contact">
Request Access
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button size="xl" variant="outline" asChild>
<Link href="/docs/mpc">Read the Docs</Link>
</Button>
</div>
</div>
</div>
</section>
{/* Stats */}
<section className="border-b bg-muted/30 py-12">
<div className="container">
<div className="grid grid-cols-2 gap-8 md:grid-cols-4">
<StatItem label="Supported Chains" value="15+" />
<StatItem label="Signing Latency" value="<500ms" />
<StatItem label="Key Schemes" value="3" />
<StatItem label="Uptime SLA" value="99.99%" />
</div>
</div>
</section>
{/* Why MPC */}
<section className="border-b py-24">
<div className="container">
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16">
<div className="flex flex-col justify-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Why MPC over traditional wallets?
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Hardware wallets create a single point of failure. Multisig
wallets are expensive on-chain. MPC splits the private key across
distributed nodes so no single party—or attacker—can sign alone.
</p>
<ul className="mt-8 space-y-4">
{[
{
title: "No single point of compromise",
desc: "Keys are split into threshold shares across independent nodes. Even if t-1 nodes are compromised, the key is safe.",
},
{
title: "Byzantine fault tolerance",
desc: "The cluster continues signing even when some nodes go offline. Configurable t-of-n threshold (e.g., 3-of-5).",
},
{
title: "Chain-agnostic",
desc: "One MPC cluster signs for Bitcoin, Ethereum, Solana, XRP, Lux, TON, and 10+ more chains. ECDSA and EdDSA.",
},
{
title: "Key resharing",
desc: "Rotate participants, change the threshold, or add new nodes—without generating new keys or moving funds.",
},
].map((item) => (
<li key={item.title} className="flex items-start gap-3">
<CheckCircle2 className="mt-1 h-5 w-5 shrink-0 text-green-500" />
<div>
<div className="font-semibold">{item.title}</div>
<div className="text-sm text-muted-foreground">
{item.desc}
</div>
</div>
</li>
))}
</ul>
</div>
<div className="flex flex-col justify-center gap-6">
<Card className="border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-purple-500/5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lock className="h-5 w-5 text-violet-500" />
CGGMP21 (ECDSA)
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
State-of-the-art threshold ECDSA protocol for Bitcoin,
Ethereum, Polygon, BNB, XRP, and all secp256k1 chains.
Replaces the older GG18 scheme with stronger security
proofs and fewer communication rounds.
</p>
</CardContent>
</Card>
<Card className="border-purple-500/20 bg-gradient-to-br from-purple-500/5 to-fuchsia-500/5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-purple-500" />
FROST (EdDSA)
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Threshold Schnorr signatures for Ed25519 chains like
Solana and TON. Supports Bitcoin Taproot. Efficient
two-round signing with native aggregation.
</p>
</CardContent>
</Card>
<Card className="border-fuchsia-500/20 bg-gradient-to-br from-fuchsia-500/5 to-pink-500/5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RefreshCw className="h-5 w-5 text-fuchsia-500" />
LSS (Resharing)
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Linear Secret Sharing enables dynamic resharing—change
participants, rotate shares, or adjust the threshold
without regenerating keys or moving assets on-chain.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
{/* Supported Chains */}
<section className="border-b bg-muted/30 py-24">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
One cluster, every chain
</h2>
<p className="mt-4 text-lg text-muted-foreground">
A single MPC deployment generates wallets and signs transactions
across all supported networks.
</p>
</div>
<div className="mt-16 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<ChainCard
protocol="ECDSA"
chains={[
"Bitcoin (BTC)",
"Ethereum (ETH)",
"Lux Network (LUX)",
"XRP Ledger (XRPL)",
"Polygon",
"BNB Chain",
"Arbitrum",
"Base",
"Optimism",
]}
/>
<ChainCard
protocol="EdDSA"
chains={[
"Solana (SOL)",
"TON",
]}
/>
<ChainCard
protocol="Coming Soon"
chains={[
"Cosmos (ATOM)",
"Sui",
"Aptos",
"Polkadot",
]}
/>
</div>
</div>
</section>
{/* What's Included */}
<section className="border-b py-24">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Production-grade MPC infrastructure
</h2>
<p className="mt-4 text-lg text-muted-foreground">
{brand.name} deploys and manages the full MPC stack: nodes, messaging,
encrypted storage, API, and monitoring.
</p>
</div>
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<FeatureCard
icon={<Server className="h-6 w-6" />}
title="Managed Node Cluster"
description={`${brand.name} provisions a Kubernetes StatefulSet of MPC nodes with stable DNS, persistent encrypted storage, health checks, and auto-recovery.`}
/>
<FeatureCard
icon={<KeyRound className="h-6 w-6" />}
title="Distributed Key Generation"
description="Keys are generated collaboratively across all nodes. No single node—or operator—ever sees the full private key. Zero-knowledge proofs verify each share."
/>
<FeatureCard
icon={<Zap className="h-6 w-6" />}
title="Threshold Signing"
description="Only t-of-n nodes are needed to produce a valid signature. Sub-second signing latency for both ECDSA and EdDSA across all supported chains."
/>
<FeatureCard
icon={<RefreshCw className="h-6 w-6" />}
title="Key Resharing"
description="Rotate participants, change threshold, or add new nodes without regenerating keys. Assets stay at the same addresses throughout."
/>
<FeatureCard
icon={<ShieldCheck className="h-6 w-6" />}
title="Encrypted at Rest & In Transit"
description="Key shares are encrypted with AES-256 in BadgerDB. Node-to-node communication uses NATS with TLS. Post-quantum TLS available."
/>
<FeatureCard
icon={<Users className="h-6 w-6" />}
title="JWT & OIDC Authentication"
description="REST API secured with JWT tokens, OIDC login via Lux ID / Hanzo ID, role-based API keys, MFA support, and full audit logging."
/>
<FeatureCard
icon={<Globe className="h-6 w-6" />}
title="REST API & Go Client"
description="Full HTTP API for wallet creation, signing, and resharing. Go client library for direct integration. Etherscan-compatible bridge endpoint."
/>
<FeatureCard
icon={<Network className="h-6 w-6" />}
title="NATS + Consul Messaging"
description="Nodes coordinate via NATS pub/sub with JetStream for reliable delivery. Consul handles service discovery and health checks."
/>
<FeatureCard
icon={<Wallet className="h-6 w-6" />}
title="Multi-Chain Wallets"
description="One keygen ceremony produces addresses for all supported chains. Derive BTC, ETH, SOL, XRP, and LUX wallets from a single distributed key."
/>
</div>
</div>
</section>
{/* How It Works */}
<section className="border-b bg-muted/30 py-24">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
From zero to signing in minutes
</h2>
<p className="mt-4 text-lg text-muted-foreground">
{brand.name} handles node provisioning, networking, and key ceremony
orchestration. You call the API.
</p>
</div>
<div className="mt-16 grid gap-6 md:grid-cols-4">
<StepCard
step="1"
title="Configure"
description="Choose your threshold (e.g., 3-of-5), select supported chains, and configure authentication. All through the dashboard or API."
/>
<StepCard
step="2"
title="Deploy"
description={`${brand.name} provisions MPC nodes as a Kubernetes StatefulSet with encrypted storage, NATS messaging, and Consul discovery.`}
/>
<StepCard
step="3"
title="Generate Keys"
description="Trigger a distributed keygen ceremony via the API. Nodes collaboratively generate key shares—no full key is ever assembled."
/>
<StepCard
step="4"
title="Sign"
description="Submit signing requests via REST API. Any t-of-n nodes produce a valid ECDSA or EdDSA signature in under 500ms."
/>
</div>
</div>
</section>
{/* Code Example */}
<section className="border-b py-24">
<div className="container">
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16">
<div className="flex flex-col justify-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Simple API, serious security
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Create wallets and sign transactions with straightforward REST
calls. The MPC protocol complexity is handled by the node cluster.
</p>
<div className="mt-8">
<Button asChild>
<Link href="/docs/mpc/api">
API Reference
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
<div className="flex items-center">
<Card className="w-full">
<CardContent className="p-0">
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-6 text-sm text-zinc-100">
<code>{`# Create a distributed wallet
curl -X POST https://mpc.yourorg.com/api/v1/wallets \\
-H "Authorization: Bearer \$TOKEN" \\
-d '{"name": "treasury", "chains": ["ETH", "BTC", "SOL"]}'
# Response: addresses for every chain
{
"wallet_id": "w_8f3a...",
"addresses": {
"ETH": "0x7c2e...9a1f",
"BTC": "bc1q...xm4p",
"SOL": "7Kf2...Rn3v"
},
"threshold": "3-of-5",
"protocol": "CGGMP21+FROST"
}
# Sign an Ethereum transaction
curl -X POST https://mpc.yourorg.com/api/v1/sign \\
-H "Authorization: Bearer \$TOKEN" \\
-d '{
"wallet_id": "w_8f3a...",
"chain": "ETH",
"tx": {
"to": "0xdead...beef",
"value": "1000000000000000000",
"gas": 21000
}
}'`}</code>
</pre>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
{/* Use Cases */}
<section className="border-b bg-muted/30 py-24">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Built for institutions
</h2>
<p className="mt-4 text-lg text-muted-foreground">
From treasury management to cross-chain bridges, MPC key management
provides the security foundation.
</p>
</div>
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<UseCaseCard
title="Treasury Management"
description="Multi-sig-grade security without on-chain overhead. CFOs and treasury teams sign with policy-controlled approval flows."
/>
<UseCaseCard
title="Cross-Chain Bridges"
description="Secure bridge validators with threshold signatures. No single validator can authorize a withdrawal."
/>
<UseCaseCard
title="Custodial Wallets"
description="Offer institutional custody with distributed key management. SOC 2 and regulatory compliance built in."
/>
<UseCaseCard
title="DeFi Protocols"
description="Protect protocol-owned liquidity with MPC-secured admin keys. Eliminate single-key risks for governance actions."
/>
<UseCaseCard
title="Exchange Hot Wallets"
description="Replace single-key hot wallets with threshold signing. Faster than multisig, more secure than HSMs."
/>
<UseCaseCard
title="Automated Signing"
description="Integrate MPC signing into CI/CD pipelines, bots, and automated trading systems with API key authentication."
/>
</div>
</div>
</section>
{/* CTA */}
<section className="py-24">
<div className="container">
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-violet-600 to-purple-600 px-6 py-20 sm:px-12 sm:py-28">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff1a_1px,transparent_1px),linear-gradient(to_bottom,#ffffff1a_1px,transparent_1px)] bg-[size:14px_24px]" />
<div className="relative mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
Your keys. Distributed. Never exposed.
</h2>
<p className="mt-4 text-lg text-white/80">
Talk to our team about deploying an MPC network for your
organization. Custom thresholds, dedicated infrastructure, and
white-glove onboarding.
</p>
<div className="mt-10 flex flex-col justify-center gap-4 sm:flex-row">
<Button
size="xl"
className="bg-white text-violet-600 hover:bg-white/90"
asChild
>
<Link href="/contact">
Request Access
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button
size="xl"
variant="outline"
className="border-white/30 bg-transparent text-white hover:bg-white/10"
asChild
>
<Link href="/docs/mpc">Read the Docs</Link>
</Button>
</div>
</div>
</div>
</div>
</section>
<Footer />
</div>
)
}
function StatItem({ label, value }: { label: string; value: string }) {
return (
<div className="text-center">
<div className="text-2xl font-bold md:text-3xl">{value}</div>
<div className="mt-1 text-sm text-muted-foreground">{label}</div>
</div>
)
}
function FeatureCard({
icon,
title,
description,
}: {
icon: React.ReactNode
title: string
description: string
}) {
return (
<Card className="h-full">
<CardHeader>
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
{icon}
</div>
<CardTitle className="mt-4">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{description}</p>
</CardContent>
</Card>
)
}
function StepCard({
step,
title,
description,
}: {
step: string
title: string
description: string
}) {
return (
<Card className="h-full">
<CardHeader>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
{step}
</div>
<CardTitle className="mt-4">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{description}</p>
</CardContent>
</Card>
)
}
function ChainCard({
protocol,
chains,
}: {
protocol: string
chains: string[]
}) {
return (
<Card className="h-full">
<CardHeader>
<Badge variant="secondary" className="w-fit">
{protocol}
</Badge>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{chains.map((chain) => (
<li
key={chain}
className="flex items-center text-sm text-muted-foreground"
>
<div className="mr-2 h-1.5 w-1.5 rounded-full bg-primary" />
{chain}
</li>
))}
</ul>
</CardContent>
</Card>
)
}
function UseCaseCard({
title,
description,
}: {
title: string
description: string
}) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{description}</p>
</CardContent>
</Card>
)
}
+301
View File
@@ -0,0 +1,301 @@
"use client"
import { useState, useRef, useEffect, useCallback } from "react"
import {
Bot,
Send,
X,
Loader2,
Maximize2,
Minimize2,
Sparkles,
Server,
Activity,
Zap,
RotateCw,
} from "lucide-react"
import { useBrand } from "@/components/brand-logo"
interface ChatMessage {
role: "user" | "assistant"
content: string
timestamp: Date
}
const QUICK_ACTIONS = [
{ label: "Fleet status", prompt: "Show me the current fleet status across all networks", icon: Server },
{ label: "Health check", prompt: "Run a health check on all services", icon: Activity },
{ label: "Recent alerts", prompt: "Are there any active alerts?", icon: Zap },
{ label: "Scale nodes", prompt: "How do I scale my validator nodes?", icon: RotateCw },
]
export function AiChat({
isOpen,
onClose,
initialPrompt,
}: {
isOpen: boolean
onClose: () => void
initialPrompt?: string
}) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const brand = useBrand()
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
useEffect(() => {
scrollToBottom()
}, [messages])
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus()
}
}, [isOpen])
useEffect(() => {
if (initialPrompt && isOpen && messages.length === 0) {
sendMessage(initialPrompt)
}
}, [initialPrompt, isOpen])
const sendMessage = useCallback(async (content: string) => {
if (!content.trim() || isLoading) return
const userMsg: ChatMessage = { role: "user", content: content.trim(), timestamp: new Date() }
setMessages((prev) => [...prev, userMsg])
setInput("")
setIsLoading(true)
try {
const allMessages = [...messages, userMsg].map((m) => ({
role: m.role,
content: m.content,
}))
const res = await fetch("/api/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(typeof localStorage !== "undefined" && localStorage.getItem("bootnode_token")
? { Authorization: `Bearer ${localStorage.getItem("bootnode_token")}` }
: {}),
...(typeof localStorage !== "undefined" && localStorage.getItem("bootnode_api_key")
? { "X-API-Key": localStorage.getItem("bootnode_api_key")! }
: {}),
},
body: JSON.stringify({
messages: allMessages,
stream: true,
}),
})
if (!res.ok) {
throw new Error(`Chat API error: ${res.status}`)
}
// Stream the response
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let assistantContent = ""
const assistantMsg: ChatMessage = {
role: "assistant",
content: "",
timestamp: new Date(),
}
setMessages((prev) => [...prev, assistantMsg])
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split("\n")
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6)
if (data === "[DONE]") continue
try {
const parsed = JSON.parse(data)
const delta = parsed.choices?.[0]?.delta?.content
if (delta) {
assistantContent += delta
setMessages((prev) => {
const updated = [...prev]
const last = updated[updated.length - 1]
if (last?.role === "assistant") {
updated[updated.length - 1] = { ...last, content: assistantContent }
}
return updated
})
}
} catch {
// Skip non-JSON lines
}
}
}
}
}
} catch (error) {
setMessages((prev) => [
...prev,
{
role: "assistant",
content: "Sorry, I'm having trouble connecting to the AI service. Please try again.",
timestamp: new Date(),
},
])
} finally {
setIsLoading(false)
}
}, [messages, isLoading])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
sendMessage(input)
}
}
if (!isOpen) return null
return (
<div
className={`fixed z-50 bg-background border border-border rounded-xl shadow-2xl flex flex-col transition-all duration-200 ${
isExpanded
? "inset-4"
: "bottom-4 right-4 w-[420px] h-[600px] max-h-[80vh]"
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center">
<Sparkles className="h-4 w-4 text-primary" />
</div>
<div>
<h3 className="text-sm font-semibold">{brand.name} AI</h3>
<p className="text-[10px] text-muted-foreground">Infrastructure assistant</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1.5 rounded-md hover:bg-accent transition-colors"
>
{isExpanded ? (
<Minimize2 className="h-4 w-4 text-muted-foreground" />
) : (
<Maximize2 className="h-4 w-4 text-muted-foreground" />
)}
</button>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-accent transition-colors"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<Bot className="h-6 w-6 text-primary" />
</div>
<h4 className="text-sm font-semibold mb-1">How can I help?</h4>
<p className="text-xs text-muted-foreground mb-6">
Ask about your infrastructure, scale services, check fleet status, or search logs.
</p>
<div className="grid grid-cols-2 gap-2 w-full">
{QUICK_ACTIONS.map((action) => (
<button
key={action.label}
onClick={() => sendMessage(action.prompt)}
className="flex items-center gap-2 p-2.5 rounded-lg border border-border hover:bg-accent text-left transition-colors"
>
<action.icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs">{action.label}</span>
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`flex gap-3 ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
{msg.role === "assistant" && (
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles className="h-3 w-3 text-primary" />
</div>
)}
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted"
}`}
>
<div className="whitespace-pre-wrap break-words">{msg.content}</div>
</div>
</div>
))}
{isLoading && messages[messages.length - 1]?.role === "user" && (
<div className="flex gap-3">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<Sparkles className="h-3 w-3 text-primary" />
</div>
<div className="bg-muted rounded-lg px-3 py-2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 border-t border-border">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about your infrastructure..."
rows={1}
className="flex-1 resize-none bg-muted rounded-lg px-3 py-2.5 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-primary/50 max-h-[120px]"
style={{ minHeight: "40px" }}
/>
<button
onClick={() => sendMessage(input)}
disabled={!input.trim() || isLoading}
className="p-2.5 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send className="h-4 w-4" />
</button>
</div>
<p className="text-[10px] text-muted-foreground mt-1.5 text-center">
AI can make mistakes. Verify critical infrastructure changes.
</p>
</div>
</div>
)
}
+166 -15
View File
@@ -1,21 +1,37 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef, useCallback } from "react"
import { useRouter } from "next/navigation"
import { Command } from "cmdk"
import {
ArrowRight,
Bot,
Database,
FileText,
Globe,
KeyRound,
Layers,
Search,
Wallet,
Webhook,
Zap,
Server,
Activity,
Eye,
Boxes,
BarChart3,
RotateCw,
Play,
Square,
MessageSquare,
Sparkles,
ArrowUpRight,
Terminal,
} from "lucide-react"
import { useBrand } from "@/components/brand-logo"
import { AiChat } from "@/components/ai-chat"
// Navigation pages
const pages = [
{ name: "Dashboard", href: "/dashboard", icon: Zap, group: "Navigation" },
{ name: "Documentation", href: "/docs", icon: FileText, group: "Navigation" },
@@ -24,6 +40,8 @@ const pages = [
{ name: "Token API", href: "/products/data", icon: Database, group: "Products" },
{ name: "NFT API", href: "/products/data", icon: Database, group: "Products" },
{ name: "Smart Wallets", href: "/products/wallets", icon: Wallet, group: "Products" },
{ name: "Explorer", href: "/products/explorer", icon: Search, group: "Products" },
{ name: "MPC", href: "/products/mpc", icon: KeyRound, group: "Products" },
{ name: "Webhooks", href: "/products/webhooks", icon: Webhook, group: "Products" },
{ name: "Rollups", href: "/products/rollups", icon: Layers, group: "Products" },
{ name: "Ethereum", href: "/chains/ethereum", icon: Globe, group: "Chains" },
@@ -37,15 +55,38 @@ const pages = [
{ name: "SDKs", href: "/docs/sdks", icon: FileText, group: "Docs" },
]
// Infrastructure quick actions
const infraActions = [
{ name: "Services", href: "/dashboard/infrastructure/services", icon: Boxes, group: "Infrastructure" },
{ name: "Observability", href: "/dashboard/infrastructure/observability", icon: Eye, group: "Infrastructure" },
{ name: "Nodes", href: "/dashboard/infrastructure/nodes", icon: Server, group: "Infrastructure" },
{ name: "Monitoring", href: "/dashboard/infrastructure/monitoring", icon: Activity, group: "Infrastructure" },
{ name: "Fleet Status", href: "/dashboard/infrastructure/nodes", icon: BarChart3, group: "Infrastructure" },
]
// AI-powered actions (trigger chat with pre-filled prompts)
const aiActions = [
{ name: "Check fleet health", prompt: "Check the health of all fleets and services", icon: Activity, group: "AI Actions" },
{ name: "Show active alerts", prompt: "Are there any active alerts or incidents?", icon: Eye, group: "AI Actions" },
{ name: "Search logs", prompt: "Search recent logs for errors across all services", icon: Terminal, group: "AI Actions" },
{ name: "Scale validators", prompt: "How do I scale my validator fleet?", icon: ArrowUpRight, group: "AI Actions" },
{ name: "Deploy service", prompt: "Help me deploy a new infrastructure service", icon: Play, group: "AI Actions" },
{ name: "RPC status", prompt: "What's the current RPC endpoint status and latency?", icon: Zap, group: "AI Actions" },
]
export function CommandMenu() {
const [open, setOpen] = useState(false)
const [chatOpen, setChatOpen] = useState(false)
const [chatPrompt, setChatPrompt] = useState<string | undefined>()
const [mode, setMode] = useState<"search" | "ai">("search")
const router = useRouter()
const brand = useBrand()
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") {
// Don't trigger if typing in an input
// cmd+K = command palette
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
@@ -54,16 +95,50 @@ export function CommandMenu() {
}
e.preventDefault()
setOpen((o) => !o)
setMode("search")
}
// cmd+J = AI chat directly
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return
}
e.preventDefault()
setChatPrompt(undefined)
setChatOpen((o) => !o)
}
// "/" = search (not in input)
if (e.key === "/" && !(
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)) {
e.preventDefault()
setOpen(true)
setMode("search")
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [])
const runCommand = (href: string) => {
const runCommand = useCallback((href: string) => {
setOpen(false)
router.push(href)
}
}, [router])
const runAiAction = useCallback((prompt: string) => {
setOpen(false)
setChatPrompt(prompt)
setChatOpen(true)
}, [])
const switchToAi = useCallback(() => {
setOpen(false)
setChatPrompt(undefined)
setChatOpen(true)
}, [])
const groups = pages.reduce(
(acc, page) => {
@@ -74,7 +149,7 @@ export function CommandMenu() {
{} as Record<string, typeof pages>
)
// Add brand ecosystem links
// Ecosystem links from brand
const ecosystemLinks = brand.footerLinks.map((link) => ({
name: link.name,
href: link.href,
@@ -84,26 +159,26 @@ export function CommandMenu() {
return (
<>
{/* Trigger button for mobile / click */}
{/* Trigger button */}
<button
onClick={() => setOpen(true)}
onClick={() => { setOpen(true); setMode("search") }}
className="relative hidden items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm text-muted-foreground hover:bg-accent md:flex md:w-[200px] lg:w-[300px]"
>
<Search className="h-4 w-4" />
<span>Search...</span>
<span>Search or ask AI...</span>
<kbd className="pointer-events-none absolute right-3 hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium sm:flex">
<span className="text-xs">⌘</span>K
</kbd>
</button>
{/* Command dialog */}
{/* Command palette dialog */}
{open && (
<div className="fixed inset-0 z-50">
<div
className="fixed inset-0 bg-black/50"
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
<div className="fixed left-1/2 top-[20%] z-50 w-full max-w-lg -translate-x-1/2">
<div className="fixed left-1/2 top-[15%] z-50 w-full max-w-xl -translate-x-1/2">
<Command
className="overflow-hidden rounded-xl border bg-background shadow-2xl"
loop
@@ -111,15 +186,64 @@ export function CommandMenu() {
<div className="flex items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Command.Input
placeholder={`Search ${brand.name}...`}
ref={inputRef}
placeholder={`Search ${brand.name} or type a question...`}
className="flex h-12 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
autoFocus
/>
<button
onClick={switchToAi}
className="ml-2 flex items-center gap-1.5 shrink-0 rounded-md bg-primary/10 px-2.5 py-1.5 text-xs font-medium text-primary hover:bg-primary/20 transition-colors"
title="Open AI Chat (⌘J)"
>
<Sparkles className="h-3 w-3" />
AI
</button>
</div>
<Command.List className="max-h-[300px] overflow-y-auto p-2">
<Command.List className="max-h-[400px] overflow-y-auto p-2">
<Command.Empty className="py-6 text-center text-sm text-muted-foreground">
No results found.
No results found. Press the AI button to ask a question.
</Command.Empty>
{/* AI Actions */}
<Command.Group
heading="AI Actions"
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
>
{aiActions.map((action) => (
<Command.Item
key={action.name}
value={`ai ${action.name}`}
onSelect={() => runAiAction(action.prompt)}
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 text-sm aria-selected:bg-accent aria-selected:text-accent-foreground"
>
<action.icon className="h-4 w-4 text-primary" />
<span>{action.name}</span>
<Sparkles className="h-3 w-3 ml-auto text-primary/50" />
</Command.Item>
))}
</Command.Group>
{/* Infrastructure */}
<Command.Group
heading="Infrastructure"
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
>
{infraActions.map((item) => (
<Command.Item
key={item.href + item.name}
value={item.name}
onSelect={() => runCommand(item.href)}
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 text-sm aria-selected:bg-accent aria-selected:text-accent-foreground"
>
<item.icon className="h-4 w-4 text-muted-foreground" />
{item.name}
</Command.Item>
))}
</Command.Group>
{/* Navigation groups */}
{Object.entries(groups).map(([group, items]) => (
<Command.Group
key={group}
@@ -139,6 +263,8 @@ export function CommandMenu() {
))}
</Command.Group>
))}
{/* Ecosystem */}
{ecosystemLinks.length > 0 && (
<Command.Group
heading="Ecosystem"
@@ -165,10 +291,35 @@ export function CommandMenu() {
</Command.Group>
)}
</Command.List>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
<div className="flex items-center gap-3">
<span>
<kbd className="rounded border bg-muted px-1 font-mono">↑↓</kbd> Navigate
</span>
<span>
<kbd className="rounded border bg-muted px-1 font-mono">↵</kbd> Select
</span>
<span>
<kbd className="rounded border bg-muted px-1 font-mono">esc</kbd> Close
</span>
</div>
<span className="flex items-center gap-1">
<kbd className="rounded border bg-muted px-1 font-mono">⌘J</kbd> AI Chat
</span>
</div>
</Command>
</div>
</div>
)}
{/* AI Chat Panel */}
<AiChat
isOpen={chatOpen}
onClose={() => setChatOpen(false)}
initialPrompt={chatPrompt}
/>
</>
)
}
+15 -1
View File
@@ -16,8 +16,10 @@ import {
import {
Database,
Globe,
KeyRound,
Layers,
Menu,
Search,
Wallet,
X,
} from "lucide-react"
@@ -43,6 +45,18 @@ const products = [
description: "Smart wallets with account abstraction",
icon: Wallet,
},
{
title: "Explorer",
href: "/products/explorer",
description: "Deploy your own block explorer",
icon: Search,
},
{
title: "MPC",
href: "/products/mpc",
description: "Threshold MPC key management",
icon: KeyRound,
},
{
title: "Rollups",
href: "/products/rollups",
@@ -78,7 +92,7 @@ export function Navbar() {
<NavigationMenuItem>
<NavigationMenuTrigger>Products</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2">
<ul className="grid w-[400px] gap-3 p-4 md:w-[600px] md:grid-cols-3">
{products.map((product) => (
<li key={product.title}>
<NavigationMenuLink asChild>
+94 -35
View File
@@ -19,20 +19,20 @@ import {
} from "@/components/ui/dropdown-menu"
import { useAuth, OrgBadge } from "@/lib/auth"
import { getBrand } from "@/lib/brand"
import {
Activity,
Server,
Network,
Database,
Users,
Settings,
CreditCard,
Key,
Webhook,
BarChart3,
Coins,
Shield,
Building,
import {
Activity,
Server,
Network,
Database,
Users,
Settings,
CreditCard,
Key,
Webhook,
BarChart3,
Coins,
Shield,
Building,
UserPlus,
ChevronDown,
Menu,
@@ -42,8 +42,24 @@ import {
Globe,
Code,
FileText,
Bell
Bell,
Boxes,
Eye,
ExternalLink,
Search,
ArrowLeftRight,
ArrowUpDown,
ShoppingBag,
Wallet,
Droplets,
Vote,
Bot,
Brain,
MessageCircle,
MessageSquare,
Sparkles,
} from "lucide-react"
import { CommandMenu } from "@/components/command-menu"
interface NavigationItem {
name: string
@@ -73,10 +89,12 @@ export function UnifiedNavigation() {
name: "Infrastructure",
href: "/dashboard/infrastructure",
icon: Server,
description: "Manage nodes, networks, and infrastructure",
description: "Manage nodes, networks, services, and infrastructure",
children: [
{ name: "Nodes", href: "/dashboard/infrastructure/nodes", icon: Server },
{ name: "Networks", href: "/dashboard/infrastructure/networks", icon: Network },
{ name: "Services", href: "/dashboard/infrastructure/services", icon: Boxes },
{ name: "Observability", href: "/dashboard/infrastructure/observability", icon: Eye },
{ name: "Monitoring", href: "/dashboard/infrastructure/monitoring", icon: Activity },
]
},
@@ -128,6 +146,22 @@ export function UnifiedNavigation() {
}
]
// Ecosystem apps from brand config
const brand = getBrand()
const ecosystemNavigation: NavigationItem[] = brand.ecosystemApps.length > 0
? [{
name: "Ecosystem",
href: "/dashboard/ecosystem",
icon: Globe,
description: "Ecosystem apps and services",
children: brand.ecosystemApps.map(app => ({
name: app.name,
href: app.href,
icon: ExternalLink,
})),
}]
: []
// Admin-only navigation
const adminNavigation: NavigationItem[] = [
{
@@ -152,7 +186,7 @@ export function UnifiedNavigation() {
const organizations = [
{ id: "hanzo", name: "Hanzo", domain: "hanzo.id", color: "#0066ff" },
{ id: "zoo", name: "Zoo Labs", domain: "zoo.id", color: "#00cc66" },
{ id: "zoo", name: "Zoo Labs", domain: "id.zoo.network", color: "#00cc66" },
{ id: "lux", name: "Lux Network", domain: "lux.id", color: "#8b5cf6" },
{ id: "pars", name: "Pars", domain: "pars.id", color: "#f59e0b" },
]
@@ -215,6 +249,9 @@ export function UnifiedNavigation() {
{/* Right side */}
<div className="flex items-center space-x-4">
{/* Command Menu + AI Chat */}
<CommandMenu />
{/* Notifications */}
<Button variant="ghost" size="sm">
<Bell className="h-5 w-5" />
@@ -274,6 +311,20 @@ export function UnifiedNavigation() {
<NavItem key={item.name} item={item} isActive={isActive} />
))}
{/* Ecosystem Apps */}
{ecosystemNavigation.length > 0 && (
<>
<div className="pt-4 mt-4 border-t border-border">
<h3 className="px-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Ecosystem
</h3>
</div>
{ecosystemNavigation.map((item) => (
<NavItem key={item.name} item={item} isActive={isActive} />
))}
</>
)}
{/* Admin Navigation (if admin) */}
{isAdmin && (
<>
@@ -312,7 +363,7 @@ export function UnifiedNavigation() {
<div className="absolute inset-0 bg-black opacity-25" onClick={() => setMobileMenuOpen(false)} />
<div className="absolute left-0 top-16 w-64 h-full bg-background border-r border-border overflow-y-auto">
<nav className="p-4 space-y-2">
{navigation.concat(isAdmin ? adminNavigation : []).map((item) => (
{navigation.concat(ecosystemNavigation).concat(isAdmin ? adminNavigation : []).map((item) => (
<NavItem
key={item.name}
item={item}
@@ -382,23 +433,31 @@ function NavItem({
{/* Sub-navigation */}
{hasChildren && isExpanded && (
<div className="ml-8 mt-1 space-y-1">
{item.children!.map((child) => (
<Link
key={child.name}
href={child.href}
onClick={onClick}
className={`
flex items-center space-x-2 px-3 py-2 text-sm rounded-md transition-colors
${isActive(child.href)
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent'
}
`}
>
<child.icon className="h-4 w-4" />
<span>{child.name}</span>
</Link>
))}
{item.children!.map((child) => {
const isExternal = child.href.startsWith("http")
const linkProps = isExternal
? { target: "_blank" as const, rel: "noopener noreferrer" }
: {}
return (
<Link
key={child.name}
href={child.href}
onClick={onClick}
{...linkProps}
className={`
flex items-center space-x-2 px-3 py-2 text-sm rounded-md transition-colors
${!isExternal && isActive(child.href)
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent'
}
`}
>
<child.icon className="h-4 w-4" />
<span>{child.name}</span>
{isExternal && <ExternalLink className="h-3 w-3 ml-auto opacity-50" />}
</Link>
)
})}
</div>
)}
</div>
+54
View File
@@ -185,6 +185,60 @@ export const team = {
api(`/team/${memberId}`, { method: "DELETE" }),
}
// Infrastructure
export const infra = {
listServices: () => api("/infra/services"),
serviceStatus: (service: string) => api(`/infra/services/${service}`),
}
// Observability
export const o11y = {
health: () => api("/o11y/health"),
metrics: () => api("/o11y/metrics"),
serviceMetrics: (service: string) => api(`/o11y/metrics/${service}`),
alerts: (severity?: string) => api(`/o11y/alerts${severity ? `?severity=${severity}` : ""}`),
createAlertRule: (data: { service: string; metric: string; threshold: number; severity?: string }) =>
api("/o11y/alerts", { method: "POST", body: JSON.stringify(data) }),
serviceMap: () => api("/o11y/service-map"),
logs: (params?: { q?: string; service?: string; level?: string; limit?: number }) => {
const qs = params ? `?${new URLSearchParams(Object.entries(params).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]))}` : ""
return api(`/o11y/logs${qs}`)
},
events: (params?: { type?: string; service?: string; limit?: number }) => {
const qs = params ? `?${new URLSearchParams(Object.entries(params).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]))}` : ""
return api(`/o11y/events${qs}`)
},
}
// AI Chat
export const chat = {
completions: (messages: { role: string; content: string }[], stream = true, model?: string) =>
fetch(`${getApiConfig().API_URL}/v1/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}),
...(getApiKey() ? { "X-API-Key": getApiKey()! } : {}),
},
body: JSON.stringify({ messages, stream, model }),
}),
models: () => api("/chat/models"),
}
// Fleet Management
export const fleets = {
list: (chain?: string) => api(`/fleets${chain ? `?chain=${chain}` : ""}`),
get: (chain: string, clusterId: string, network: string) =>
api(`/fleets/${chain}/${clusterId}/${network}`),
scale: (chain: string, clusterId: string, network: string, replicas: number) =>
api(`/fleets/${chain}/${clusterId}/${network}/scale`, {
method: "POST",
body: JSON.stringify({ replicas }),
}),
restart: (chain: string, clusterId: string, network: string) =>
api(`/fleets/${chain}/${clusterId}/${network}/restart`, { method: "POST" }),
}
// Types
export interface TeamMember {
id: string
+48 -10
View File
@@ -35,6 +35,14 @@ export type BrandConfig = {
href: string
external?: boolean
}[]
// Ecosystem apps — retail-facing services linked from dashboard
ecosystemApps: {
name: string
href: string
description: string
icon: string // lucide icon name
category: "explore" | "defi" | "tools" | "governance"
}[]
}
// Brand presets
@@ -71,6 +79,7 @@ const brands: Record<string, BrandConfig> = {
{ name: "GitHub", href: "https://github.com/bootnode", external: true },
{ name: "Twitter", href: "https://twitter.com/bootnode", external: true },
],
ecosystemApps: [],
},
hanzo: {
name: "Hanzo Web3",
@@ -81,8 +90,8 @@ const brands: Record<string, BrandConfig> = {
logoWhite: "/logo/hanzo-logo-white.svg",
favicon: "/logo/hanzo-icon.svg",
domain: "web3.hanzo.ai",
apiUrl: "https://api.web3.hanzo.ai",
wsUrl: "wss://ws.web3.hanzo.ai",
apiUrl: "https://api.cloud.hanzo.ai",
wsUrl: "wss://ws.cloud.hanzo.ai",
statusUrl: "https://status.hanzo.ai",
defaultTheme: "dark",
colors: {
@@ -108,6 +117,10 @@ const brands: Record<string, BrandConfig> = {
{ name: "Hanzo Network", href: "https://hanzo.network", external: true },
{ name: "GitHub", href: "https://github.com/hanzoai", external: true },
],
ecosystemApps: [
{ name: "Hanzo Chat", href: "https://chat.hanzo.ai", description: "AI chat with 100+ models", icon: "MessageSquare", category: "tools" },
{ name: "Hanzo Console", href: "https://console.hanzo.ai", description: "LLM observability & evaluation", icon: "BarChart3", category: "tools" },
],
},
lux: {
name: "Lux Cloud",
@@ -133,8 +146,8 @@ const brands: Record<string, BrandConfig> = {
github: "https://github.com/luxfi",
},
iam: {
url: "https://iam.lux.network",
clientId: "lux-web3",
url: "https://lux.id",
clientId: "lux-app-client-id",
domain: "lux.id",
},
footerLinks: [
@@ -145,6 +158,19 @@ const brands: Record<string, BrandConfig> = {
{ name: "Lux Exchange", href: "https://exchange.lux.network", external: true },
{ name: "GitHub", href: "https://github.com/luxfi", external: true },
],
ecosystemApps: [
{ name: "Explorer", href: "https://explore.lux.network", description: "Block explorer for all Lux chains", icon: "Search", category: "explore" },
{ name: "Bridge", href: "https://bridge.lux.network", description: "Cross-chain asset bridge", icon: "ArrowLeftRight", category: "defi" },
{ name: "Exchange", href: "https://exchange.lux.network", description: "Decentralized exchange (DEX)", icon: "ArrowUpDown", category: "defi" },
{ name: "Markets", href: "https://markets.lux.network", description: "NFT marketplace", icon: "ShoppingBag", category: "defi" },
{ name: "Wallet", href: "https://wallet.lux.network", description: "Multi-chain wallet", icon: "Wallet", category: "tools" },
{ name: "Safe", href: "https://safe.lux.network", description: "Multisig wallet (FROST)", icon: "Shield", category: "tools" },
{ name: "Faucet", href: "https://faucet.lux.network", description: "Testnet token faucet", icon: "Droplets", category: "tools" },
{ name: "Staking", href: "https://stake.lux.network", description: "Validator staking", icon: "Coins", category: "defi" },
{ name: "Governance", href: "https://vote.lux.network", description: "On-chain governance", icon: "Vote", category: "governance" },
{ name: "Status", href: "https://status.lux.network", description: "Network status & uptime", icon: "Activity", category: "explore" },
{ name: "Lux Build", href: "https://lux.build", description: "Developer portal & docs", icon: "Code", category: "tools" },
],
},
pars: {
name: "Pars Cloud",
@@ -170,8 +196,8 @@ const brands: Record<string, BrandConfig> = {
github: "https://github.com/pars-network",
},
iam: {
url: "https://iam.lux.network",
clientId: "lux-web3",
url: "https://pars.id",
clientId: "pars-app-client-id",
domain: "pars.id",
},
footerLinks: [
@@ -181,6 +207,12 @@ const brands: Record<string, BrandConfig> = {
{ name: "SessionVM", href: "https://session.pars.network", external: true },
{ name: "GitHub", href: "https://github.com/pars-network", external: true },
],
ecosystemApps: [
{ name: "Explorer", href: "https://explore.pars.network", description: "Pars block explorer", icon: "Search", category: "explore" },
{ name: "Wallet", href: "https://wallet.pars.network", description: "Pars wallet", icon: "Wallet", category: "tools" },
{ name: "SessionVM", href: "https://session.pars.network", description: "Private messaging VM", icon: "MessageCircle", category: "tools" },
{ name: "Faucet", href: "https://faucet.pars.network", description: "Testnet tokens", icon: "Droplets", category: "tools" },
],
},
zoo: {
name: "Zoo Labs",
@@ -206,9 +238,9 @@ const brands: Record<string, BrandConfig> = {
github: "https://github.com/zoolabs",
},
iam: {
url: "https://iam.lux.network",
clientId: "lux-web3",
domain: "zoo.id",
url: "https://id.zoo.network",
clientId: "zoo-app-client-id",
domain: "id.zoo.network",
},
footerLinks: [
{ name: "Zoo Labs", href: "https://zoo.ngo", external: true },
@@ -217,6 +249,12 @@ const brands: Record<string, BrandConfig> = {
{ name: "Zen LM", href: "https://zenlm.ai", external: true },
{ name: "GitHub", href: "https://github.com/zooai", external: true },
],
ecosystemApps: [
{ name: "Zoo AI Chat", href: "https://ai.zoo.ngo", description: "Decentralized AI chat", icon: "Bot", category: "tools" },
{ name: "Zen LM", href: "https://zenlm.ai", description: "Open frontier models", icon: "Brain", category: "tools" },
{ name: "Explorer", href: "https://explore.zoo.ngo", description: "Zoo chain explorer", icon: "Search", category: "explore" },
{ name: "Faucet", href: "https://faucet.zoo.ngo", description: "Testnet tokens", icon: "Droplets", category: "tools" },
],
},
}
@@ -227,7 +265,7 @@ function getBrandKey(): string {
const hostname = window.location.hostname
if (hostname.includes("lux.cloud") || hostname.includes("lux.network")) return "lux"
if (hostname.includes("pars.network") || hostname.includes("pars.id")) return "pars"
if (hostname.includes("zoo.ngo") || hostname.includes("zoo.id")) return "zoo"
if (hostname.includes("zoo.ngo") || hostname.includes("zoo.network")) return "zoo"
if (hostname.includes("hanzo.ai")) return "hanzo"
if (hostname.includes("bootno.de") || hostname.includes("bootnode.io")) return "bootnode"
}
File diff suppressed because one or more lines are too long