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"]
|
status: Literal["rolled-back", "failed"]
|
||||||
http_status: Optional[int] = None
|
http_status: Optional[int] = None
|
||||||
detail: Optional[str] = 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_updates import swarm_updates_router
|
||||||
from .swarm_mgmt import swarm_mgmt_router
|
from .swarm_mgmt import swarm_mgmt_router
|
||||||
from .system import system_router
|
from .system import system_router
|
||||||
|
from .topology import topology_router
|
||||||
|
|
||||||
api_router = APIRouter(
|
api_router = APIRouter(
|
||||||
# Every route under /api/v1 is auth-guarded (either by an explicit
|
# 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.)
|
# System info (deployment-mode auto-detection, etc.)
|
||||||
api_router.include_router(system_router)
|
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