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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
18
decnet/web/router/topology/__init__.py
Normal file
18
decnet/web/router/topology/__init__.py
Normal 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"]
|
||||
0
tests/api/topology/__init__.py
Normal file
0
tests/api/topology/__init__.py
Normal file
132
tests/api/topology/test_models.py
Normal file
132
tests/api/topology/test_models.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Phase 3 Step 1 — parity between repo dict output and Pydantic DTOs.
|
||||
|
||||
These tests pin the contract that repo-hydrated dicts deserialize
|
||||
cleanly into the REST DTOs. If a repo-row shape drifts, the DTO test
|
||||
fails before any endpoint rides on the stale contract.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.topology.config import TopologyConfig
|
||||
from decnet.topology.generator import generate
|
||||
from decnet.topology.persistence import hydrate, persist, transition_status
|
||||
from decnet.topology.status import TopologyStatus
|
||||
from decnet.web.db.factory import get_repository
|
||||
from decnet.web.db.models import (
|
||||
DeckyRow,
|
||||
EdgeRow,
|
||||
LANRow,
|
||||
MutationEnqueueRequest,
|
||||
MutationRow,
|
||||
TopologyDetail,
|
||||
TopologyGenerateRequest,
|
||||
TopologyListResponse,
|
||||
TopologyStatusEventRow,
|
||||
TopologySummary,
|
||||
)
|
||||
from decnet.web.router.topology import topology_router
|
||||
|
||||
|
||||
def _cfg() -> TopologyConfig:
|
||||
return TopologyConfig(
|
||||
name="dto-parity",
|
||||
depth=1,
|
||||
branching_factor=1,
|
||||
deckies_per_lan_min=1,
|
||||
deckies_per_lan_max=1,
|
||||
services_explicit=["ssh"],
|
||||
randomize_services=False,
|
||||
seed=0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def repo(tmp_path):
|
||||
r = get_repository(db_path=str(tmp_path / "dto.db"))
|
||||
await r.initialize()
|
||||
return r
|
||||
|
||||
|
||||
def test_router_skeleton_mounted():
|
||||
"""topology_router lives under /topologies and is import-safe."""
|
||||
assert topology_router.prefix == "/topologies"
|
||||
assert "topologies" in (topology_router.tags or [])
|
||||
|
||||
|
||||
def test_generate_request_accepts_cli_shape():
|
||||
"""TopologyGenerateRequest mirrors the CLI flags."""
|
||||
req = TopologyGenerateRequest(
|
||||
name="n",
|
||||
depth=2,
|
||||
branching_factor=2,
|
||||
deckies_per_lan_min=1,
|
||||
deckies_per_lan_max=3,
|
||||
services_explicit=["ssh", "ftp"],
|
||||
randomize_services=False,
|
||||
seed=7,
|
||||
)
|
||||
assert req.depth == 2
|
||||
assert req.services_explicit == ["ssh", "ftp"]
|
||||
|
||||
|
||||
def test_mutation_request_rejects_unknown_op():
|
||||
"""Literal guard is what gives the frontend a free 422 contract."""
|
||||
with pytest.raises(ValueError):
|
||||
MutationEnqueueRequest(op="teleport_lan", payload={})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_summary_accepts_repo_topology_row(repo):
|
||||
plan = generate(_cfg())
|
||||
tid = await persist(repo, plan)
|
||||
row = await repo.get_topology(tid)
|
||||
summary = TopologySummary(**row)
|
||||
assert summary.id == tid
|
||||
assert summary.version == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_detail_accepts_hydrated_shape(repo):
|
||||
plan = generate(_cfg())
|
||||
tid = await persist(repo, plan)
|
||||
hydrated = await hydrate(repo, tid)
|
||||
detail = TopologyDetail(
|
||||
topology=TopologySummary(**hydrated["topology"]),
|
||||
lans=[LANRow(**l) for l in hydrated["lans"]],
|
||||
deckies=[DeckyRow(**d) for d in hydrated["deckies"]],
|
||||
edges=[EdgeRow(**e) for e in hydrated["edges"]],
|
||||
)
|
||||
assert detail.topology.id == tid
|
||||
assert len(detail.lans) == len(hydrated["lans"])
|
||||
assert len(detail.deckies) == len(hydrated["deckies"])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mutation_row_accepts_repo_row(repo):
|
||||
plan = generate(_cfg())
|
||||
tid = await persist(repo, plan)
|
||||
mid = await repo.enqueue_topology_mutation(
|
||||
tid, "add_lan", {"name": "LAN-X"}
|
||||
)
|
||||
rows = await repo.list_topology_mutations(tid)
|
||||
assert rows and rows[0]["id"] == mid
|
||||
m = MutationRow(**rows[0])
|
||||
assert m.op == "add_lan"
|
||||
assert m.payload == {"name": "LAN-X"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_status_event_row_accepts_repo_row(repo):
|
||||
plan = generate(_cfg())
|
||||
tid = await persist(repo, plan)
|
||||
await transition_status(repo, tid, TopologyStatus.DEPLOYING)
|
||||
events = await repo.list_topology_status_events(tid)
|
||||
assert events
|
||||
TopologyStatusEventRow(**events[0])
|
||||
|
||||
|
||||
def test_list_response_envelope_shape():
|
||||
resp = TopologyListResponse(total=0, limit=50, offset=0, data=[])
|
||||
assert resp.total == 0
|
||||
assert resp.data == []
|
||||
Reference in New Issue
Block a user