refactor(models): split models.py into topical submodules
decnet/web/db/models.py was approaching 1000 lines across User/Log/ Attacker/Swarm/Topology/Workers/Updater/Health domains. Split into a package with one module per domain; __init__.py re-exports every symbol so all 52 call sites keep importing from decnet.web.db.models unchanged.
This commit is contained in:
422
decnet/web/db/models/topology.py
Normal file
422
decnet/web/db/models/topology.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""MazeNET topology tables + the REST DTOs that wrap them."""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any, Literal, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field as PydanticField
|
||||
from sqlalchemy import Column, Index, Text, UniqueConstraint
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from ._base import _BIG_TEXT
|
||||
|
||||
|
||||
# --- MazeNET tables ---
|
||||
# Nested deception topologies: an arbitrary-depth DAG of LANs connected by
|
||||
# multi-homed "bridge" deckies. Purpose-built; disjoint from DeckyShard which
|
||||
# remains SWARM-only.
|
||||
|
||||
class Topology(SQLModel, table=True):
|
||||
__tablename__ = "topologies"
|
||||
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
||||
name: str = Field(index=True, unique=True)
|
||||
mode: str = Field(default="unihost") # unihost|agent
|
||||
# When ``mode == "agent"``, pins this topology to a specific enrolled
|
||||
# worker. ``None`` for unihost topologies (master-local deploy).
|
||||
target_host_uuid: Optional[str] = Field(
|
||||
default=None, foreign_key="swarm_hosts.uuid", index=True
|
||||
)
|
||||
# Full TopologyConfig snapshot (including seed) used at generation time.
|
||||
config_snapshot: str = Field(
|
||||
sa_column=Column("config_snapshot", _BIG_TEXT, nullable=False, default="{}")
|
||||
)
|
||||
status: str = Field(
|
||||
default="pending", index=True
|
||||
) # pending|deploying|active|degraded|failed|tearing_down|torn_down
|
||||
status_changed_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), index=True
|
||||
)
|
||||
# Optimistic-concurrency token. Bumped by repo methods that mutate
|
||||
# the topology or any child row when an expected_version is supplied.
|
||||
# Callers pass their last-seen version; mismatch raises VersionConflict.
|
||||
version: int = Field(default=1, nullable=False)
|
||||
# Set by the heartbeat handler when an agent's reported
|
||||
# ``applied_version_hash`` diverges from what we expect it to be
|
||||
# running. Drained by the mutator watch loop, which re-pushes via
|
||||
# AgentClient and clears the flag. NULL for unihost topologies.
|
||||
needs_resync: bool = Field(default=False, nullable=False)
|
||||
|
||||
|
||||
class LAN(SQLModel, table=True):
|
||||
__tablename__ = "lans"
|
||||
__table_args__ = (UniqueConstraint("topology_id", "name", name="uq_lan_topology_name"),)
|
||||
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
||||
topology_id: str = Field(foreign_key="topologies.id", index=True)
|
||||
name: str
|
||||
# Populated after the Docker network is created; nullable before deploy.
|
||||
docker_network_id: Optional[str] = Field(default=None)
|
||||
subnet: str
|
||||
is_dmz: bool = Field(default=False)
|
||||
# Canvas layout coordinates (set by the web editor). Nullable so
|
||||
# generator-emitted LANs don't need auto-layout at generation time.
|
||||
x: Optional[float] = Field(default=None)
|
||||
y: Optional[float] = Field(default=None)
|
||||
|
||||
|
||||
class TopologyDecky(SQLModel, table=True):
|
||||
"""A decky belonging to a MazeNET topology.
|
||||
|
||||
Disjoint from DeckyShard (which is SWARM-only). UUID PK; decky name is
|
||||
unique only within a topology, so two topologies can both have a
|
||||
``decky-01`` without colliding.
|
||||
"""
|
||||
__tablename__ = "topology_deckies"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("topology_id", "name", name="uq_topology_decky_name"),
|
||||
)
|
||||
uuid: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
||||
topology_id: str = Field(foreign_key="topologies.id", index=True)
|
||||
name: str
|
||||
# JSON list[str] of service names on this decky (snapshot of assignment).
|
||||
services: str = Field(
|
||||
sa_column=Column("services", _BIG_TEXT, nullable=False, default="[]")
|
||||
)
|
||||
# Full serialised DeckyConfig snapshot — lets the dashboard render the
|
||||
# same card shape as DeckyShard without a live round-trip.
|
||||
decky_config: Optional[str] = Field(
|
||||
default=None, sa_column=Column("decky_config", _BIG_TEXT, nullable=True)
|
||||
)
|
||||
ip: Optional[str] = Field(default=None)
|
||||
# Same vocabulary as DeckyShard.state to keep dashboard rendering uniform.
|
||||
state: str = Field(
|
||||
default="pending", index=True
|
||||
) # pending|running|failed|torn_down|degraded|tearing_down|teardown_failed
|
||||
last_error: Optional[str] = Field(
|
||||
default=None, sa_column=Column("last_error", Text, nullable=True)
|
||||
)
|
||||
compose_hash: Optional[str] = Field(default=None)
|
||||
last_seen: Optional[datetime] = Field(default=None)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
# Canvas layout coordinates (set by the web editor). Nullable so
|
||||
# generator-emitted deckies don't need auto-layout at generation time.
|
||||
x: Optional[float] = Field(default=None)
|
||||
y: Optional[float] = Field(default=None)
|
||||
|
||||
|
||||
class TopologyEdge(SQLModel, table=True):
|
||||
"""Membership edge: a decky attached to a LAN.
|
||||
|
||||
A decky appearing in ≥2 edges is multi-homed (a bridge decky).
|
||||
"""
|
||||
__tablename__ = "topology_edges"
|
||||
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
||||
topology_id: str = Field(foreign_key="topologies.id", index=True)
|
||||
decky_uuid: str = Field(foreign_key="topology_deckies.uuid", index=True)
|
||||
lan_id: str = Field(foreign_key="lans.id", index=True)
|
||||
is_bridge: bool = Field(default=False)
|
||||
forwards_l3: bool = Field(default=False)
|
||||
|
||||
|
||||
class TopologyStatusEvent(SQLModel, table=True):
|
||||
"""Append-only audit log of topology status transitions."""
|
||||
__tablename__ = "topology_status_events"
|
||||
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
||||
topology_id: str = Field(foreign_key="topologies.id", index=True)
|
||||
from_status: str
|
||||
to_status: str
|
||||
at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), index=True
|
||||
)
|
||||
reason: Optional[str] = Field(
|
||||
default=None, sa_column=Column("reason", Text, nullable=True)
|
||||
)
|
||||
|
||||
|
||||
class TopologyMutation(SQLModel, table=True):
|
||||
"""Operator-requested live mutation for an active MazeNET topology.
|
||||
|
||||
Each row is one intent (add LAN, attach decky, etc.). The mutator's
|
||||
reconciler claims ``pending`` rows atomically (see
|
||||
``SQLModelRepository.claim_next_mutation``), applies them against
|
||||
Docker, and writes ``applied`` or ``failed`` back. The ``(state,
|
||||
topology_id)`` composite index keeps the watch-loop guard query
|
||||
cheap even with years of mutation history.
|
||||
"""
|
||||
__tablename__ = "topology_mutations"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_topology_mutations_state_topology",
|
||||
"state",
|
||||
"topology_id",
|
||||
),
|
||||
)
|
||||
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
||||
topology_id: str = Field(foreign_key="topologies.id", index=True)
|
||||
# add_lan|remove_lan|add_decky|attach_decky|detach_decky|
|
||||
# remove_decky|update_decky|update_lan
|
||||
op: str = Field(index=True)
|
||||
# JSON-serialised op payload (keys depend on ``op``).
|
||||
payload: str = Field(
|
||||
sa_column=Column("payload", _BIG_TEXT, nullable=False, default="{}")
|
||||
)
|
||||
# pending|applying|applied|failed
|
||||
state: str = Field(default="pending", index=True)
|
||||
requested_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), index=True
|
||||
)
|
||||
applied_at: Optional[datetime] = Field(default=None)
|
||||
reason: Optional[str] = Field(
|
||||
default=None, sa_column=Column("reason", Text, nullable=True)
|
||||
)
|
||||
|
||||
|
||||
# --- MazeNET Topology REST DTOs (phase 3) ---
|
||||
# Request/response shapes for /api/v1/topologies. All write paths are
|
||||
# admin-only; reads accept admin or viewer. Child CRUD is pending-only;
|
||||
# mutations of active|degraded topologies go through the queue.
|
||||
|
||||
|
||||
class TopologyGenerateRequest(BaseModel):
|
||||
"""Body for POST /topologies — mirrors the `topology generate` CLI."""
|
||||
name: str = PydanticField(..., min_length=1, max_length=64)
|
||||
mode: str = PydanticField(default="unihost", pattern=r"^(unihost|agent)$")
|
||||
target_host_uuid: Optional[str] = None
|
||||
depth: int = PydanticField(..., ge=1, le=16)
|
||||
branching_factor: int = PydanticField(..., ge=1, le=8)
|
||||
deckies_per_lan_min: int = PydanticField(default=1, ge=0, le=32)
|
||||
deckies_per_lan_max: int = PydanticField(default=3, ge=1, le=32)
|
||||
bridge_forward_probability: float = PydanticField(default=1.0, ge=0.0, le=1.0)
|
||||
cross_edge_probability: float = PydanticField(default=0.0, ge=0.0, le=1.0)
|
||||
services_explicit: Optional[list[str]] = None
|
||||
randomize_services: bool = True
|
||||
seed: Optional[int] = PydanticField(default=None, ge=0)
|
||||
|
||||
|
||||
class TopologySummary(BaseModel):
|
||||
"""List-row shape for GET /topologies."""
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str
|
||||
name: str
|
||||
mode: str
|
||||
target_host_uuid: Optional[str] = None
|
||||
status: str
|
||||
version: int
|
||||
needs_resync: bool = False
|
||||
created_at: datetime
|
||||
status_changed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TopologyListResponse(BaseModel):
|
||||
total: int
|
||||
limit: Optional[int] = None
|
||||
offset: Optional[int] = None
|
||||
data: list[TopologySummary]
|
||||
|
||||
|
||||
class LANRow(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str
|
||||
topology_id: str
|
||||
name: str
|
||||
subnet: str
|
||||
is_dmz: bool = False
|
||||
docker_network_id: Optional[str] = None
|
||||
x: Optional[float] = None
|
||||
y: Optional[float] = None
|
||||
|
||||
|
||||
class DeckyRow(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
uuid: str
|
||||
topology_id: str
|
||||
name: str
|
||||
services: list[str] = PydanticField(default_factory=list)
|
||||
decky_config: Optional[dict[str, Any]] = None
|
||||
ip: Optional[str] = None
|
||||
state: str
|
||||
last_error: Optional[str] = None
|
||||
x: Optional[float] = None
|
||||
y: Optional[float] = None
|
||||
|
||||
|
||||
class EdgeRow(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str
|
||||
topology_id: str
|
||||
decky_uuid: str
|
||||
lan_id: str
|
||||
is_bridge: bool = False
|
||||
forwards_l3: bool = False
|
||||
|
||||
|
||||
class TopologyDetail(BaseModel):
|
||||
"""Hydrated topology — mirrors persistence.hydrate() output.
|
||||
|
||||
``topology`` uses :class:`TopologySummary` which already exposes
|
||||
``target_host_uuid`` — agent-targeted topologies surface their
|
||||
pinned host through that field.
|
||||
"""
|
||||
topology: TopologySummary
|
||||
lans: list[LANRow]
|
||||
deckies: list[DeckyRow]
|
||||
edges: list[EdgeRow]
|
||||
|
||||
|
||||
class TopologyStatusEventRow(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str
|
||||
topology_id: str
|
||||
from_status: str
|
||||
to_status: str
|
||||
at: datetime
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class LANCreateRequest(BaseModel):
|
||||
name: str = PydanticField(..., min_length=1, max_length=64)
|
||||
subnet: Optional[str] = None
|
||||
is_dmz: bool = False
|
||||
x: Optional[float] = None
|
||||
y: Optional[float] = None
|
||||
expected_version: Optional[int] = None
|
||||
|
||||
|
||||
class LANUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
subnet: Optional[str] = None
|
||||
is_dmz: Optional[bool] = None
|
||||
x: Optional[float] = None
|
||||
y: Optional[float] = None
|
||||
expected_version: Optional[int] = None
|
||||
|
||||
|
||||
class DeckyCreateRequest(BaseModel):
|
||||
name: str = PydanticField(..., min_length=1, max_length=64)
|
||||
services: list[str] = PydanticField(default_factory=list)
|
||||
decky_config: Optional[dict[str, Any]] = None
|
||||
x: Optional[float] = None
|
||||
y: Optional[float] = None
|
||||
expected_version: Optional[int] = None
|
||||
|
||||
|
||||
class DeckyUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
services: Optional[list[str]] = None
|
||||
decky_config: Optional[dict[str, Any]] = None
|
||||
x: Optional[float] = None
|
||||
y: Optional[float] = None
|
||||
expected_version: Optional[int] = None
|
||||
|
||||
|
||||
class EdgeCreateRequest(BaseModel):
|
||||
decky_uuid: str
|
||||
lan_id: str
|
||||
is_bridge: bool = False
|
||||
forwards_l3: bool = False
|
||||
expected_version: Optional[int] = None
|
||||
|
||||
|
||||
_MUTATION_OPS = Literal[
|
||||
"add_lan",
|
||||
"remove_lan",
|
||||
"add_decky",
|
||||
"attach_decky",
|
||||
"detach_decky",
|
||||
"remove_decky",
|
||||
"update_decky",
|
||||
"update_lan",
|
||||
]
|
||||
|
||||
|
||||
class MutationEnqueueRequest(BaseModel):
|
||||
op: _MUTATION_OPS
|
||||
payload: dict[str, Any] = PydanticField(default_factory=dict)
|
||||
expected_version: Optional[int] = None
|
||||
|
||||
|
||||
def _decode_json_payload(v: Any) -> Any:
|
||||
"""Accept either a dict or a JSON-encoded string for mutation payloads."""
|
||||
if isinstance(v, str):
|
||||
import json as _json
|
||||
return _json.loads(v) if v else {}
|
||||
return v
|
||||
|
||||
|
||||
_MutationPayload = Annotated[dict[str, Any], BeforeValidator(_decode_json_payload)]
|
||||
|
||||
|
||||
class MutationRow(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str
|
||||
topology_id: str
|
||||
op: str
|
||||
payload: _MutationPayload = PydanticField(default_factory=dict)
|
||||
state: str
|
||||
requested_at: datetime
|
||||
applied_at: Optional[datetime] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class MutationEnqueueResponse(BaseModel):
|
||||
mutation_id: str
|
||||
state: str = "pending"
|
||||
|
||||
|
||||
class ValidationIssueResponse(BaseModel):
|
||||
severity: str
|
||||
code: str
|
||||
message: str
|
||||
target: dict[str, Any] = PydanticField(default_factory=dict)
|
||||
|
||||
|
||||
class ValidationErrorResponse(BaseModel):
|
||||
detail: str = "Topology validation failed"
|
||||
issues: list[ValidationIssueResponse]
|
||||
|
||||
|
||||
class VersionConflictResponse(BaseModel):
|
||||
detail: str = "Topology version conflict"
|
||||
current: int
|
||||
expected: int
|
||||
|
||||
|
||||
class NotEditableResponse(BaseModel):
|
||||
detail: str = "Topology not editable"
|
||||
status: str
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class ServiceCatalogResponse(BaseModel):
|
||||
services: list[str]
|
||||
|
||||
|
||||
class ArchetypeEntry(BaseModel):
|
||||
slug: str
|
||||
display_name: str
|
||||
description: str
|
||||
services: list[str]
|
||||
preferred_distros: list[str]
|
||||
nmap_os: str
|
||||
|
||||
|
||||
class ArchetypeCatalogResponse(BaseModel):
|
||||
archetypes: list[ArchetypeEntry]
|
||||
|
||||
|
||||
class NextIPResponse(BaseModel):
|
||||
subnet: str
|
||||
ip: str
|
||||
|
||||
|
||||
class NextSubnetResponse(BaseModel):
|
||||
subnet: str
|
||||
|
||||
|
||||
class DeployAcceptedResponse(BaseModel):
|
||||
topology_id: str
|
||||
status: str
|
||||
dry_run: bool = False
|
||||
Reference in New Issue
Block a user