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
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
+6 -6
View File
@@ -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"),
+22
View File
@@ -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
# ---------------------------------------------------------------------------
+2 -2
View File
@@ -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",
+162
View File
@@ -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}>"
+6 -6
View File
@@ -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"],
+2 -2
View File
@@ -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,
})
}
+1 -1
View File
@@ -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