From 2379b2aeda6372a07f15f564b78c91e378d46e05 Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 20 Apr 2026 18:16:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(api):=20phase=203=20step=201=20=E2=80=94?= =?UTF-8?q?=20topology=20request/response=20models=20+=20router=20skeleton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- decnet/web/db/models.py | 225 +++++++++++++++++++++++++ decnet/web/router/__init__.py | 4 + decnet/web/router/topology/__init__.py | 18 ++ tests/api/topology/__init__.py | 0 tests/api/topology/test_models.py | 132 +++++++++++++++ 5 files changed, 379 insertions(+) create mode 100644 decnet/web/router/topology/__init__.py create mode 100644 tests/api/topology/__init__.py create mode 100644 tests/api/topology/test_models.py diff --git a/decnet/web/db/models.py b/decnet/web/db/models.py index 28a03b1c..00087875 100644 --- a/decnet/web/db/models.py +++ b/decnet/web/db/models.py @@ -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 diff --git a/decnet/web/router/__init__.py b/decnet/web/router/__init__.py index cbbb99cb..dca36ce0 100644 --- a/decnet/web/router/__init__.py +++ b/decnet/web/router/__init__.py @@ -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) diff --git a/decnet/web/router/topology/__init__.py b/decnet/web/router/topology/__init__.py new file mode 100644 index 00000000..b0b5605f --- /dev/null +++ b/decnet/web/router/topology/__init__.py @@ -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"] diff --git a/tests/api/topology/__init__.py b/tests/api/topology/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/api/topology/test_models.py b/tests/api/topology/test_models.py new file mode 100644 index 00000000..354f25c3 --- /dev/null +++ b/tests/api/topology/test_models.py @@ -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 == []