feat(api): phase 3 step 1 — topology request/response models + router skeleton

Add Pydantic DTOs in decnet/web/db/models.py covering every phase-3
endpoint shape: TopologyGenerateRequest, TopologySummary/Detail, child
create/update requests, MutationEnqueueRequest (Literal op guard),
MutationRow with JSON-payload decoder, validation/version/not-editable
error envelopes, and the three catalog responses.

Create decnet/web/router/topology/ as an import-safe package exporting
topology_router (prefix /topologies) — sub-routers land step-by-step in
subsequent commits. Mount under the main api router alongside swarm_mgmt.

tests/api/topology/test_models.py pins repo-dict ↔ DTO parity so future
repo-row drift breaks the contract test before the endpoints.
This commit is contained in:
2026-04-20 18:16:30 -04:00
parent a76b9ecdf9
commit 2379b2aeda
5 changed files with 379 additions and 0 deletions

View File

@@ -644,3 +644,228 @@ class RollbackResponse(BaseModel):
status: Literal["rolled-back", "failed"]
http_status: Optional[int] = None
detail: Optional[str] = None
# --- 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)
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
status: str
version: int
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: 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",
"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 NextIPResponse(BaseModel):
subnet: str
ip: str
class NextSubnetResponse(BaseModel):
subnet: str
class DeployAcceptedResponse(BaseModel):
topology_id: str
status: str
dry_run: bool = False

View File

@@ -24,6 +24,7 @@ from .artifacts.api_get_artifact import router as artifacts_router
from .swarm_updates import swarm_updates_router
from .swarm_mgmt import swarm_mgmt_router
from .system import system_router
from .topology import topology_router
api_router = APIRouter(
# Every route under /api/v1 is auth-guarded (either by an explicit
@@ -83,3 +84,6 @@ api_router.include_router(swarm_mgmt_router)
# System info (deployment-mode auto-detection, etc.)
api_router.include_router(system_router)
# MazeNET Topologies (nested topology CRUD + mutation queue)
api_router.include_router(topology_router)

View File

@@ -0,0 +1,18 @@
"""MazeNET topology REST endpoints (phase 3).
Thin FastAPI layer over the phase-2 topology machinery:
generate/validate/deploy/teardown, pending-only child CRUD, and the
live-mutation queue for active|degraded topologies.
Mounted at ``/api/v1/topologies`` by the main api router. Sub-routers
live one-per-file and are aggregated here.
"""
from fastapi import APIRouter
topology_router = APIRouter(prefix="/topologies", tags=["topologies"])
# Sub-routers land in later steps; this skeleton keeps the package
# import-safe so the main api router can mount it immediately.
__all__ = ["topology_router"]