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:
2026-07-26 13:49:23 -07:00
parent fb201315da
commit a386c9bf3c
8 changed files with 202 additions and 18 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
JWT_SECRET: test-secret-key-for-ci JWT_SECRET: test-secret-key-for-ci
API_KEY_SALT: test-salt-for-ci API_KEY_SALT: test-salt-for-ci
run: | run: |
.venv/bin/pytest -v --tb=short || echo "No tests yet" .venv/bin/pytest -v --tb=short
test-web: test-web:
runs-on: hanzo-build-linux-amd64 runs-on: hanzo-build-linux-amd64
+6 -6
View File
@@ -12,7 +12,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from bootnode.db import get_db from bootnode.db import get_db
from bootnode.db.models import Node, Cluster from bootnode.db.models import Node, OrgCluster
router = APIRouter() router = APIRouter()
@@ -170,8 +170,8 @@ def _node_to_response(node: Node) -> NodeResponse:
) )
def _cluster_to_response(cluster: Cluster) -> ClusterResponse: def _cluster_to_response(cluster: OrgCluster) -> ClusterResponse:
"""Convert a SQLAlchemy Cluster to a ClusterResponse dict.""" """Convert a SQLAlchemy OrgCluster to a ClusterResponse dict."""
return ClusterResponse( return ClusterResponse(
id=str(cluster.id), id=str(cluster.id),
name=cluster.name, name=cluster.name,
@@ -262,7 +262,7 @@ async def get_node_stats(db: AsyncSession = Depends(get_db)):
@router.get("/clusters", response_model=List[ClusterResponse]) @router.get("/clusters", response_model=List[ClusterResponse])
async def list_clusters(db: AsyncSession = Depends(get_db)): async def list_clusters(db: AsyncSession = Depends(get_db)):
"""List all registered clusters.""" """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() clusters = result.scalars().all()
return [_cluster_to_response(c) for c in clusters] 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) @router.post("/clusters", response_model=ClusterResponse, status_code=201)
async def create_cluster(body: ClusterCreate, db: AsyncSession = Depends(get_db)): async def create_cluster(body: ClusterCreate, db: AsyncSession = Depends(get_db)):
"""Register a new cluster.""" """Register a new cluster."""
cluster = Cluster( cluster = OrgCluster(
name=body.name, name=body.name,
provider=body.provider, provider=body.provider,
provider_id=body.provider_id, 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 # Create clusters first, keep a map of name -> id
cluster_map: Dict[str, uuid.UUID] = {} cluster_map: Dict[str, uuid.UUID] = {}
for c in SEED_CLUSTERS: for c in SEED_CLUSTERS:
cluster = Cluster( cluster = OrgCluster(
name=c["name"], name=c["name"],
provider=c.get("provider", "digitalocean"), provider=c.get("provider", "digitalocean"),
provider_id=c.get("provider_id"), provider_id=c.get("provider_id"),
+22
View File
@@ -46,6 +46,28 @@ class NodeStatus(str, Enum):
TERMINATED = "terminated" 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 # Chain network configs — per-chain reference data
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2 -2
View File
@@ -3,7 +3,7 @@
from bootnode.db.models import ( from bootnode.db.models import (
ApiKey, ApiKey,
Base, Base,
Cluster, OrgCluster,
MPCWalletModel, MPCWalletModel,
Node, Node,
Project, Project,
@@ -18,7 +18,7 @@ __all__ = [
"async_session", "async_session",
"init_db", "init_db",
"Base", "Base",
"Cluster", "OrgCluster",
"Node", "Node",
"Project", "Project",
"ApiKey", "ApiKey",
+162
View File
@@ -2,11 +2,14 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from enum import Enum
from typing import Any from typing import Any
from sqlalchemy import ( from sqlalchemy import (
Boolean, Boolean,
DateTime, DateTime,
Enum as SAEnum,
Float,
ForeignKey, ForeignKey,
Integer, Integer,
String, String,
@@ -16,6 +19,12 @@ from sqlalchemy import (
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 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): class Base(DeclarativeBase):
"""Base class for all models.""" """Base class for all models."""
@@ -344,3 +353,156 @@ class OrgCluster(Base):
# Relationships # Relationships
project: Mapped["Project"] = relationship(back_populates="clusters") 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}>"
+6 -6
View File
@@ -7,7 +7,7 @@ from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bootnode.db.models import Cluster, Node from bootnode.db.models import OrgCluster, Node
# Reproducible randomness # Reproducible randomness
random.seed(42) random.seed(42)
@@ -22,7 +22,7 @@ SEED_CLUSTERS: list[dict] = [
"provider": "digitalocean", "provider": "digitalocean",
"provider_id": "1a153000-90a6-48ad-9375-7ef901a9bf7f", "provider_id": "1a153000-90a6-48ad-9375-7ef901a9bf7f",
"region": "sfo3", "region": "sfo3",
"status": "running", "status": "healthy",
"kubernetes_version": "1.31", "kubernetes_version": "1.31",
"endpoint": "https://1a153000-90a6-48ad-9375-7ef901a9bf7f.k8s.ondigitalocean.com", "endpoint": "https://1a153000-90a6-48ad-9375-7ef901a9bf7f.k8s.ondigitalocean.com",
"node_count": 6, "node_count": 6,
@@ -33,7 +33,7 @@ SEED_CLUSTERS: list[dict] = [
"provider": "digitalocean", "provider": "digitalocean",
"provider_id": "04c46df5-5986-429a-9c7f-3f11a81c8539", "provider_id": "04c46df5-5986-429a-9c7f-3f11a81c8539",
"region": "sfo3", "region": "sfo3",
"status": "running", "status": "healthy",
"kubernetes_version": "1.31", "kubernetes_version": "1.31",
"endpoint": "https://04c46df5-5986-429a-9c7f-3f11a81c8539.k8s.ondigitalocean.com", "endpoint": "https://04c46df5-5986-429a-9c7f-3f11a81c8539.k8s.ondigitalocean.com",
"node_count": 8, "node_count": 8,
@@ -87,7 +87,7 @@ def get_seed_nodes() -> list[dict]:
"chain": "lux", "chain": "lux",
"network": network, "network": network,
"provider": "kubernetes", "provider": "kubernetes",
"status": "running", "status": "healthy",
"node_type": "validator", "node_type": "validator",
"cluster_name": "lux-k8s", # resolved at insert time "cluster_name": "lux-k8s", # resolved at insert time
"region": "sfo3", "region": "sfo3",
@@ -137,7 +137,7 @@ async def seed_database(session: AsyncSession) -> None:
for cdata in get_seed_clusters(): for cdata in get_seed_clusters():
# Check if cluster already exists by provider_id # Check if cluster already exists by provider_id
result = await session.execute( 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() existing = result.scalar_one_or_none()
if existing is not 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}") print(f"[seed_nodes] Cluster '{cdata['name']}' already exists: {existing.id}")
continue continue
cluster = Cluster( cluster = OrgCluster(
name=cdata["name"], name=cdata["name"],
provider=cdata["provider"], provider=cdata["provider"],
provider_id=cdata["provider_id"], provider_id=cdata["provider_id"],
+2 -2
View File
@@ -119,10 +119,10 @@ export default function MonitoringPage() {
for (const node of nodesData) { for (const node of nodesData) {
checks.push({ checks.push({
service: `${node.name} (${node.chain}/${node.network})`, 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, latency_ms: node.metrics?.latency_ms || 0,
last_check: new Date().toISOString(), last_check: new Date().toISOString(),
uptime_pct: node.status === "running" ? 99.5 : 0, uptime_pct: node.status === "healthy" ? 99.5 : 0,
details: node.status, details: node.status,
}) })
} }
+1 -1
View File
@@ -117,7 +117,7 @@ export default function DashboardPage() {
setStats({ setStats({
api_requests: 0, // Would come from usage tracking api_requests: 0, // Would come from usage tracking
api_requests_change: 0, 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, nodes_change: 0,
networks: Array.isArray(chainsData) ? chainsData.length : 0, networks: Array.isArray(chainsData) ? chainsData.length : 0,
uptime: 99.9, // Would come from monitoring uptime: 99.9, // Would come from monitoring