mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
fix(api): the db package could not import — define the models it declares
`bootnode/db/__init__.py` imported and re-exported three names that models.py
does not define, so `import bootnode.db` raised ImportError on main. Every
consumer died with it: the e2e job's init_db(), the nodes API, the MPC API, the
keys API, the seed. Prod is unaffected only because it runs image 1.0.1, built
before this landed.
It was invisible because test-api ran
pytest -v --tb=short || echo "No tests yet"
The `|| echo` makes the step exit 0 unconditionally, so CI reported GREEN while
every test module failed to import. That mask is removed; CI can fail honestly.
The consequence was not subtle: docker-release triggers on
`workflow_run: ["CI"] completed`, CI was red, so the image build was SKIPPED
every time — which is why the deployed web image sat at 0.0.1-web from June
while main moved on.
Cluster → renamed OrgCluster at some point; call sites updated.
The `"kind": "Cluster"` CRD strings are correct and untouched.
Node → never existed in ANY revision (checked back to 2026-01-26).
Defined here from the contract the code already declares.
MPCWalletModel → likewise. Distinct from SmartWallet, which is ERC-4337
account abstraction, not threshold signing.
Design, in the terms asked for:
DECOMPLECTED — Node stores DESIRED state only. Sync progress, peer count, block
height, cpu/memory, uptime and the backing pod are facts about the cluster, and
fleet_manager already reads them from the Cluster CR. A copy in this table would
be a second source of truth, stale the instant it is written. That split is why
the model is small; it is the spec half of the spec/status pair the CRD layer
already uses, not a new idea.
TYPED — status/node_type/sync_mode are closed Enums at both the Python and the
column level, not free strings. This class of bug was live: the seed wrote
"running" while NodeStatus offers pending/init/starting/bootstrapping/healthy/
unhealthy/terminated — provably DISJOINT sets, so the dashboard's
`filter(n => n.status === "running")` could never match a backend value and
"active nodes" was structurally always 0. One vocabulary now, defined once in
fleet_models and imported everywhere; seed and both frontend call sites moved
onto it.
SMALL INTERFACE — two properties (`is_serving`, `slug`) and a repr. Everything
else is a column read. No cleverness to undo later.
Flagged, not silently reproduced: MPCWalletModel.encrypted_shares holds key
material in the database. It is encrypted, but the standing rule is that secrets
live in KMS. That column is a migration target, documented in the model.
Verified: `import bootnode.db` succeeds, all three enums resolve, every seed
status value is a member of NodeStatus, web tsc 0 errors, web build exits 0.
This commit is contained in:
@@ -58,7 +58,7 @@ jobs:
|
||||
JWT_SECRET: test-secret-key-for-ci
|
||||
API_KEY_SALT: test-salt-for-ci
|
||||
run: |
|
||||
.venv/bin/pytest -v --tb=short || echo "No tests yet"
|
||||
.venv/bin/pytest -v --tb=short
|
||||
|
||||
test-web:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
|
||||
@@ -12,7 +12,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from bootnode.db import get_db
|
||||
from bootnode.db.models import Node, Cluster
|
||||
from bootnode.db.models import Node, OrgCluster
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -170,8 +170,8 @@ def _node_to_response(node: Node) -> NodeResponse:
|
||||
)
|
||||
|
||||
|
||||
def _cluster_to_response(cluster: Cluster) -> ClusterResponse:
|
||||
"""Convert a SQLAlchemy Cluster to a ClusterResponse dict."""
|
||||
def _cluster_to_response(cluster: OrgCluster) -> ClusterResponse:
|
||||
"""Convert a SQLAlchemy OrgCluster to a ClusterResponse dict."""
|
||||
return ClusterResponse(
|
||||
id=str(cluster.id),
|
||||
name=cluster.name,
|
||||
@@ -262,7 +262,7 @@ async def get_node_stats(db: AsyncSession = Depends(get_db)):
|
||||
@router.get("/clusters", response_model=List[ClusterResponse])
|
||||
async def list_clusters(db: AsyncSession = Depends(get_db)):
|
||||
"""List all registered clusters."""
|
||||
result = await db.execute(select(Cluster).order_by(Cluster.created_at.desc()))
|
||||
result = await db.execute(select(OrgCluster).order_by(OrgCluster.created_at.desc()))
|
||||
clusters = result.scalars().all()
|
||||
return [_cluster_to_response(c) for c in clusters]
|
||||
|
||||
@@ -270,7 +270,7 @@ async def list_clusters(db: AsyncSession = Depends(get_db)):
|
||||
@router.post("/clusters", response_model=ClusterResponse, status_code=201)
|
||||
async def create_cluster(body: ClusterCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Register a new cluster."""
|
||||
cluster = Cluster(
|
||||
cluster = OrgCluster(
|
||||
name=body.name,
|
||||
provider=body.provider,
|
||||
provider_id=body.provider_id,
|
||||
@@ -500,7 +500,7 @@ async def seed_database(db: AsyncSession) -> Dict[str, int]:
|
||||
# Create clusters first, keep a map of name -> id
|
||||
cluster_map: Dict[str, uuid.UUID] = {}
|
||||
for c in SEED_CLUSTERS:
|
||||
cluster = Cluster(
|
||||
cluster = OrgCluster(
|
||||
name=c["name"],
|
||||
provider=c.get("provider", "digitalocean"),
|
||||
provider_id=c.get("provider_id"),
|
||||
|
||||
@@ -46,6 +46,28 @@ class NodeStatus(str, Enum):
|
||||
TERMINATED = "terminated"
|
||||
|
||||
|
||||
class NodeType(str, Enum):
|
||||
"""What a node is FOR. Drives client flags, storage sizing and peering.
|
||||
|
||||
Lives beside NodeStatus so the whole node vocabulary is in one module —
|
||||
the db model, the API and the seed all import from here rather than each
|
||||
spelling its own set of strings.
|
||||
"""
|
||||
|
||||
FULL = "full"
|
||||
ARCHIVE = "archive"
|
||||
RPC = "rpc"
|
||||
VALIDATOR = "validator"
|
||||
|
||||
|
||||
class SyncMode(str, Enum):
|
||||
"""How much history a node keeps while syncing."""
|
||||
|
||||
SNAP = "snap"
|
||||
ARCHIVE = "archive"
|
||||
LIGHT = "light"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chain network configs — per-chain reference data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from bootnode.db.models import (
|
||||
ApiKey,
|
||||
Base,
|
||||
Cluster,
|
||||
OrgCluster,
|
||||
MPCWalletModel,
|
||||
Node,
|
||||
Project,
|
||||
@@ -18,7 +18,7 @@ __all__ = [
|
||||
"async_session",
|
||||
"init_db",
|
||||
"Base",
|
||||
"Cluster",
|
||||
"OrgCluster",
|
||||
"Node",
|
||||
"Project",
|
||||
"ApiKey",
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum as SAEnum,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
@@ -16,6 +19,12 @@ from sqlalchemy import (
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
# One vocabulary for nodes, defined once in the CRD layer and imported here.
|
||||
# Duplicating these as loose strings is what let the seed write "running"
|
||||
# while NodeStatus offered only pending/…/healthy — two disjoint sets for
|
||||
# one concept, so nothing could ever match.
|
||||
from bootnode.core.chains.fleet_models import NodeStatus, NodeType, SyncMode
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all models."""
|
||||
@@ -344,3 +353,156 @@ class OrgCluster(Base):
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship(back_populates="clusters")
|
||||
|
||||
|
||||
class Node(Base):
|
||||
"""A blockchain node this platform is asked to run.
|
||||
|
||||
DESIRED STATE ONLY. What the node *is doing right now* — sync progress,
|
||||
peer count, block height, cpu/memory, uptime, the pod that currently backs
|
||||
it — is deliberately NOT stored here. Those are facts about the cluster,
|
||||
and the cluster already knows them: fleet_manager reads them from the
|
||||
Cluster CR. A copy in this table would be a second source of truth that is
|
||||
stale the instant it is written, and the two would drift silently. Read
|
||||
live state from the fleet; store intent here.
|
||||
|
||||
That split is the reason this model is small. It is the spec half of a
|
||||
spec/status pair, which is the shape the CRD layer (core/chains/fleet_models)
|
||||
already uses — not a new idea introduced here.
|
||||
"""
|
||||
|
||||
__tablename__ = "nodes"
|
||||
|
||||
# ── identity ────────────────────────────────────────────────────────────
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
chain: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
network: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
# ── placement ───────────────────────────────────────────────────────────
|
||||
cluster_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(50), nullable=False, default="kubernetes")
|
||||
region: Mapped[str] = mapped_column(String(50), nullable=False, default="sfo3")
|
||||
# Coordinates are for the map view and are genuinely optional — a node in a
|
||||
# region we have no fix for renders without a pin rather than at (0, 0).
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# ── desired configuration ───────────────────────────────────────────────
|
||||
node_type: Mapped[NodeType] = mapped_column(
|
||||
SAEnum(NodeType, name="node_type", native_enum=False, length=32),
|
||||
nullable=False,
|
||||
default=NodeType.FULL,
|
||||
)
|
||||
sync_mode: Mapped[SyncMode] = mapped_column(
|
||||
SAEnum(SyncMode, name="sync_mode", native_enum=False, length=32),
|
||||
nullable=False,
|
||||
default=SyncMode.SNAP,
|
||||
)
|
||||
image: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||
execution_client: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
consensus_client: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# ── last known status ───────────────────────────────────────────────────
|
||||
# ONE vocabulary, the NodeStatus the CRD layer already defines. Before this
|
||||
# model existed the seed wrote "running" while NodeStatus offers
|
||||
# pending/init/starting/bootstrapping/healthy/unhealthy/terminated — two
|
||||
# disjoint sets for one concept, so a frontend filtering `status ===
|
||||
# "running"` could never match a backend value. A closed Enum column makes
|
||||
# that class of drift unrepresentable rather than merely discouraged.
|
||||
status: Mapped[NodeStatus] = mapped_column(
|
||||
SAEnum(NodeStatus, name="node_status", native_enum=False, length=32),
|
||||
nullable=False,
|
||||
default=NodeStatus.PENDING,
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
# ── the whole interface ─────────────────────────────────────────────────
|
||||
# Three questions callers actually ask. Everything else is a column read.
|
||||
|
||||
@property
|
||||
def is_serving(self) -> bool:
|
||||
"""Ready to take traffic. The one predicate the UI needs."""
|
||||
return self.status is NodeStatus.HEALTHY
|
||||
|
||||
@property
|
||||
def slug(self) -> str:
|
||||
"""Stable human handle, e.g. `ethereum-mainnet-validator`."""
|
||||
return f"{self.chain}-{self.network}-{self.node_type.value}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Node {self.slug} {self.status.value}>"
|
||||
|
||||
|
||||
class MPCWalletStatus(str, Enum):
|
||||
"""Lifecycle of an MPC wallet. Closed set — a wallet is usable or it is not."""
|
||||
|
||||
ACTIVE = "active"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
class MPCWalletModel(Base):
|
||||
"""A threshold-signature wallet: `threshold`-of-`num_shares` to sign.
|
||||
|
||||
Distinct from SmartWallet, which is ERC-4337 account abstraction. This is
|
||||
multi-party computation — no single holder can sign alone. Keeping them as
|
||||
two models is the point; collapsing them would braid two unrelated custody
|
||||
schemes into one row.
|
||||
|
||||
⚠️ `encrypted_shares` holds key material. It is encrypted at rest here, but
|
||||
the standing rule is that secrets live in KMS and nowhere else. Treat this
|
||||
column as a migration target, not a pattern to copy: the shares belong in
|
||||
~/work/hanzo/kms with only a reference stored here. Flagged rather than
|
||||
silently reproduced.
|
||||
"""
|
||||
|
||||
__tablename__ = "mpc_wallets"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# Lowercased on write by the API; unique so one address maps to one wallet.
|
||||
address: Mapped[str] = mapped_column(String(42), nullable=False, unique=True)
|
||||
chain: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
network: Mapped[str] = mapped_column(String(50), nullable=False, default="mainnet")
|
||||
|
||||
# The threshold scheme. Both required: a wallet with an unknown threshold
|
||||
# cannot be signed with, so there is no meaningful null here.
|
||||
threshold: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
num_shares: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
share_holders: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
encrypted_shares: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
|
||||
status: Mapped[MPCWalletStatus] = mapped_column(
|
||||
SAEnum(MPCWalletStatus, name="mpc_wallet_status", native_enum=False, length=16),
|
||||
nullable=False,
|
||||
default=MPCWalletStatus.ACTIVE,
|
||||
)
|
||||
|
||||
signing_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
last_signed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
@property
|
||||
def is_usable(self) -> bool:
|
||||
"""Signable: active, and enough shares exist to reach the threshold."""
|
||||
return self.status is MPCWalletStatus.ACTIVE and self.num_shares >= self.threshold
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MPCWallet {self.address} {self.threshold}/{self.num_shares} {self.status.value}>"
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import UTC, datetime
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bootnode.db.models import Cluster, Node
|
||||
from bootnode.db.models import OrgCluster, Node
|
||||
|
||||
# Reproducible randomness
|
||||
random.seed(42)
|
||||
@@ -22,7 +22,7 @@ SEED_CLUSTERS: list[dict] = [
|
||||
"provider": "digitalocean",
|
||||
"provider_id": "1a153000-90a6-48ad-9375-7ef901a9bf7f",
|
||||
"region": "sfo3",
|
||||
"status": "running",
|
||||
"status": "healthy",
|
||||
"kubernetes_version": "1.31",
|
||||
"endpoint": "https://1a153000-90a6-48ad-9375-7ef901a9bf7f.k8s.ondigitalocean.com",
|
||||
"node_count": 6,
|
||||
@@ -33,7 +33,7 @@ SEED_CLUSTERS: list[dict] = [
|
||||
"provider": "digitalocean",
|
||||
"provider_id": "04c46df5-5986-429a-9c7f-3f11a81c8539",
|
||||
"region": "sfo3",
|
||||
"status": "running",
|
||||
"status": "healthy",
|
||||
"kubernetes_version": "1.31",
|
||||
"endpoint": "https://04c46df5-5986-429a-9c7f-3f11a81c8539.k8s.ondigitalocean.com",
|
||||
"node_count": 8,
|
||||
@@ -87,7 +87,7 @@ def get_seed_nodes() -> list[dict]:
|
||||
"chain": "lux",
|
||||
"network": network,
|
||||
"provider": "kubernetes",
|
||||
"status": "running",
|
||||
"status": "healthy",
|
||||
"node_type": "validator",
|
||||
"cluster_name": "lux-k8s", # resolved at insert time
|
||||
"region": "sfo3",
|
||||
@@ -137,7 +137,7 @@ async def seed_database(session: AsyncSession) -> None:
|
||||
for cdata in get_seed_clusters():
|
||||
# Check if cluster already exists by provider_id
|
||||
result = await session.execute(
|
||||
select(Cluster).where(Cluster.provider_id == cdata["provider_id"])
|
||||
select(OrgCluster).where(OrgCluster.provider_id == cdata["provider_id"])
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is not None:
|
||||
@@ -145,7 +145,7 @@ async def seed_database(session: AsyncSession) -> None:
|
||||
print(f"[seed_nodes] Cluster '{cdata['name']}' already exists: {existing.id}")
|
||||
continue
|
||||
|
||||
cluster = Cluster(
|
||||
cluster = OrgCluster(
|
||||
name=cdata["name"],
|
||||
provider=cdata["provider"],
|
||||
provider_id=cdata["provider_id"],
|
||||
|
||||
@@ -119,10 +119,10 @@ export default function MonitoringPage() {
|
||||
for (const node of nodesData) {
|
||||
checks.push({
|
||||
service: `${node.name} (${node.chain}/${node.network})`,
|
||||
status: node.status === "running" ? "healthy" : node.status === "starting" ? "degraded" : "down",
|
||||
status: node.status === "healthy" ? "healthy" : node.status === "starting" ? "degraded" : "down",
|
||||
latency_ms: node.metrics?.latency_ms || 0,
|
||||
last_check: new Date().toISOString(),
|
||||
uptime_pct: node.status === "running" ? 99.5 : 0,
|
||||
uptime_pct: node.status === "healthy" ? 99.5 : 0,
|
||||
details: node.status,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function DashboardPage() {
|
||||
setStats({
|
||||
api_requests: 0, // Would come from usage tracking
|
||||
api_requests_change: 0,
|
||||
active_nodes: Array.isArray(nodesData) ? nodesData.filter((n: any) => n.status === "running").length : 0,
|
||||
active_nodes: Array.isArray(nodesData) ? nodesData.filter((n: any) => n.status === "healthy").length : 0,
|
||||
nodes_change: 0,
|
||||
networks: Array.isArray(chainsData) ? chainsData.length : 0,
|
||||
uptime: 99.9, // Would come from monitoring
|
||||
|
||||
Reference in New Issue
Block a user