mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,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"])
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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 +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."""
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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))
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{- $network := index .Values.networks .Values.network }}
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: {{ $network.namespace }}
|
||||
labels:
|
||||
app: lux
|
||||
network: {{ .Values.network }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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: {}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}]}}}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.)
|
||||
- {}
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Rust build artifacts
|
||||
target/
|
||||
Generated
+3087
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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(),
|
||||
})))
|
||||
}
|
||||
@@ -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, ¶ms, &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))
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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} · {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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user