From ff0b2efbb093bc63f3f55bfd16934f6e25321f44 Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 20 Apr 2026 19:37:16 -0400 Subject: [PATCH] feat(api/topology): pending-only child CRUD for LANs, deckies, edges --- decnet/web/router/topology/__init__.py | 6 + decnet/web/router/topology/_guards.py | 53 +++++ decnet/web/router/topology/api_decky_crud.py | 136 +++++++++++ decnet/web/router/topology/api_edge_crud.py | 110 +++++++++ decnet/web/router/topology/api_lan_crud.py | 149 ++++++++++++ tests/api/topology/test_child_crud.py | 224 +++++++++++++++++++ 6 files changed, 678 insertions(+) create mode 100644 decnet/web/router/topology/_guards.py create mode 100644 decnet/web/router/topology/api_decky_crud.py create mode 100644 decnet/web/router/topology/api_edge_crud.py create mode 100644 decnet/web/router/topology/api_lan_crud.py create mode 100644 tests/api/topology/test_child_crud.py diff --git a/decnet/web/router/topology/__init__.py b/decnet/web/router/topology/__init__.py index a28dd58c..b24e584a 100644 --- a/decnet/web/router/topology/__init__.py +++ b/decnet/web/router/topology/__init__.py @@ -11,9 +11,12 @@ from fastapi import APIRouter from .api_catalog import router as _catalog_router from .api_create_topology import router as _create_router +from .api_decky_crud import router as _decky_router from .api_delete_topology import router as _delete_router from .api_deploy_topology import router as _deploy_router +from .api_edge_crud import router as _edge_router from .api_get_topology import router as _get_router +from .api_lan_crud import router as _lan_router from .api_list_topologies import router as _list_router topology_router = APIRouter(prefix="/topologies", tags=["topologies"]) @@ -28,6 +31,9 @@ topology_router.include_router(_list_router) topology_router.include_router(_create_router) topology_router.include_router(_deploy_router) topology_router.include_router(_delete_router) +topology_router.include_router(_lan_router) +topology_router.include_router(_decky_router) +topology_router.include_router(_edge_router) topology_router.include_router(_get_router) diff --git a/decnet/web/router/topology/_guards.py b/decnet/web/router/topology/_guards.py new file mode 100644 index 00000000..c3c20fa5 --- /dev/null +++ b/decnet/web/router/topology/_guards.py @@ -0,0 +1,53 @@ +"""Shared helpers for the Phase-3 child-CRUD routes.""" +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException + +from decnet.topology.status import ( + TopologyNotEditable, + TopologyStatus, + VersionConflict, +) +from decnet.web.dependencies import repo + + +async def get_topology_or_404(topology_id: str) -> dict[str, Any]: + topo = await repo.get_topology(topology_id) + if topo is None: + raise HTTPException(status_code=404, detail="Topology not found") + return topo + + +async def assert_pending_or_409(topology_id: str) -> dict[str, Any]: + """Ensure the topology exists and is in ``pending`` state. + + The repo layer enforces the same rule inside mutation methods, but the + ``add_*`` helpers don't — re-check here so every write route agrees on + the pre-condition before any side effect. + """ + topo = await get_topology_or_404(topology_id) + if topo["status"] != TopologyStatus.PENDING: + raise HTTPException( + status_code=409, + detail=( + f"Topology is {topo['status']!r}; free-form child edits are " + f"pending-only. Use the mutation queue for active topologies." + ), + ) + return topo + + +def map_repo_exception(exc: Exception) -> HTTPException: + """Translate repo-layer exceptions to HTTP status codes.""" + if isinstance(exc, TopologyNotEditable): + return HTTPException(status_code=409, detail=str(exc)) + if isinstance(exc, VersionConflict): + return HTTPException( + status_code=409, + detail=f"Version conflict: expected {exc.expected}, current {exc.current}", + ) + if isinstance(exc, ValueError): + return HTTPException(status_code=400, detail=str(exc)) + return HTTPException(status_code=500, detail="Internal error") diff --git a/decnet/web/router/topology/api_decky_crud.py b/decnet/web/router/topology/api_decky_crud.py new file mode 100644 index 00000000..5525cdab --- /dev/null +++ b/decnet/web/router/topology/api_decky_crud.py @@ -0,0 +1,136 @@ +"""Decky CRUD endpoints — pending-only child mutations. + + POST /topologies/{id}/deckies + PATCH /topologies/{id}/deckies/{uuid} + DELETE /topologies/{id}/deckies/{uuid} +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Response, status + +from decnet.telemetry import traced as _traced +from decnet.topology.status import ( + TopologyNotEditable, + VersionConflict, +) +from decnet.web.db.models import DeckyCreateRequest, DeckyRow, DeckyUpdateRequest +from decnet.web.dependencies import repo, require_admin + +from ._guards import assert_pending_or_409, map_repo_exception + +router = APIRouter() + + +@router.post( + "/{topology_id}/deckies", + tags=["MazeNET Topologies"], + response_model=DeckyRow, + status_code=status.HTTP_201_CREATED, + responses={ + 400: {"description": "Malformed body or invalid decky fields"}, + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Topology not found"}, + 409: {"description": "Topology not editable or version conflict"}, + }, +) +@_traced("api.topology.decky.create") +async def api_create_decky( + topology_id: str, + body: DeckyCreateRequest, + _admin: dict = Depends(require_admin), +) -> DeckyRow: + await assert_pending_or_409(topology_id) + + payload = { + "topology_id": topology_id, + "name": body.name, + "services": body.services, + "decky_config": body.decky_config, + "x": body.x, + "y": body.y, + } + try: + decky_uuid = await repo.add_topology_decky( + payload, expected_version=body.expected_version + ) + except (TopologyNotEditable, VersionConflict, ValueError) as exc: + raise map_repo_exception(exc) from exc + + rows = await repo.list_topology_deckies(topology_id) + row = next((r for r in rows if r["uuid"] == decky_uuid), None) + if row is None: # pragma: no cover + raise HTTPException(status_code=500, detail="Decky insert vanished") + return DeckyRow(**row) + + +@router.patch( + "/{topology_id}/deckies/{decky_uuid}", + tags=["MazeNET Topologies"], + response_model=DeckyRow, + responses={ + 400: {"description": "Malformed body"}, + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Topology or decky not found"}, + 409: {"description": "Topology not editable or version conflict"}, + }, +) +@_traced("api.topology.decky.update") +async def api_update_decky( + topology_id: str, + decky_uuid: str, + body: DeckyUpdateRequest, + _admin: dict = Depends(require_admin), +) -> DeckyRow: + await assert_pending_or_409(topology_id) + + fields = body.model_dump(exclude_unset=True, exclude={"expected_version"}) + try: + await repo.update_topology_decky( + decky_uuid, + fields, + expected_version=body.expected_version, + enforce_pending=True, + ) + except (TopologyNotEditable, VersionConflict) as exc: + raise map_repo_exception(exc) from exc + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + rows = await repo.list_topology_deckies(topology_id) + row = next((r for r in rows if r["uuid"] == decky_uuid), None) + if row is None: + raise HTTPException(status_code=404, detail="Decky not found") + return DeckyRow(**row) + + +@router.delete( + "/{topology_id}/deckies/{decky_uuid}", + tags=["MazeNET Topologies"], + status_code=status.HTTP_204_NO_CONTENT, + responses={ + 400: {"description": "Malformed path"}, + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Topology or decky not found"}, + 409: {"description": "Topology not editable or version conflict"}, + }, +) +@_traced("api.topology.decky.delete") +async def api_delete_decky( + topology_id: str, + decky_uuid: str, + _admin: dict = Depends(require_admin), +) -> Response: + await assert_pending_or_409(topology_id) + + rows = await repo.list_topology_deckies(topology_id) + if not any(r["uuid"] == decky_uuid for r in rows): + raise HTTPException(status_code=404, detail="Decky not found") + + try: + await repo.delete_topology_decky(decky_uuid) + except (TopologyNotEditable, VersionConflict, ValueError) as exc: + raise map_repo_exception(exc) from exc + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/decnet/web/router/topology/api_edge_crud.py b/decnet/web/router/topology/api_edge_crud.py new file mode 100644 index 00000000..3acd6dbc --- /dev/null +++ b/decnet/web/router/topology/api_edge_crud.py @@ -0,0 +1,110 @@ +"""Edge CRUD endpoints — pending-only child mutations. + + POST /topologies/{id}/edges + DELETE /topologies/{id}/edges/{edge_id} + +Edges are the decky↔LAN membership table (bipartite). Creating an +edge attaches a decky to an additional LAN; deleting one detaches. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Response, status + +from decnet.telemetry import traced as _traced +from decnet.topology.status import ( + TopologyNotEditable, + VersionConflict, +) +from decnet.web.db.models import EdgeCreateRequest, EdgeRow +from decnet.web.dependencies import repo, require_admin + +from ._guards import assert_pending_or_409, map_repo_exception + +router = APIRouter() + + +@router.post( + "/{topology_id}/edges", + tags=["MazeNET Topologies"], + response_model=EdgeRow, + status_code=status.HTTP_201_CREATED, + responses={ + 400: {"description": "Malformed body or unknown decky/LAN"}, + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Topology not found"}, + 409: {"description": "Topology not editable or version conflict"}, + }, +) +@_traced("api.topology.edge.create") +async def api_create_edge( + topology_id: str, + body: EdgeCreateRequest, + _admin: dict = Depends(require_admin), +) -> EdgeRow: + await assert_pending_or_409(topology_id) + + # Referential integrity: decky + LAN must belong to this topology. + deckies = await repo.list_topology_deckies(topology_id) + if not any(d["uuid"] == body.decky_uuid for d in deckies): + raise HTTPException( + status_code=400, + detail=f"decky {body.decky_uuid!r} not in topology {topology_id!r}", + ) + lans = await repo.list_lans_for_topology(topology_id) + if not any(r["id"] == body.lan_id for r in lans): + raise HTTPException( + status_code=400, + detail=f"lan {body.lan_id!r} not in topology {topology_id!r}", + ) + + payload = { + "topology_id": topology_id, + "decky_uuid": body.decky_uuid, + "lan_id": body.lan_id, + "is_bridge": body.is_bridge, + "forwards_l3": body.forwards_l3, + } + try: + edge_id = await repo.add_topology_edge( + payload, expected_version=body.expected_version + ) + except (TopologyNotEditable, VersionConflict, ValueError) as exc: + raise map_repo_exception(exc) from exc + + edges = await repo.list_topology_edges(topology_id) + row = next((e for e in edges if e["id"] == edge_id), None) + if row is None: # pragma: no cover + raise HTTPException(status_code=500, detail="Edge insert vanished") + return EdgeRow(**row) + + +@router.delete( + "/{topology_id}/edges/{edge_id}", + tags=["MazeNET Topologies"], + status_code=status.HTTP_204_NO_CONTENT, + responses={ + 400: {"description": "Malformed path"}, + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Topology or edge not found"}, + 409: {"description": "Topology not editable or version conflict"}, + }, +) +@_traced("api.topology.edge.delete") +async def api_delete_edge( + topology_id: str, + edge_id: str, + _admin: dict = Depends(require_admin), +) -> Response: + await assert_pending_or_409(topology_id) + + edges = await repo.list_topology_edges(topology_id) + if not any(e["id"] == edge_id for e in edges): + raise HTTPException(status_code=404, detail="Edge not found") + + try: + await repo.delete_topology_edge(edge_id) + except (TopologyNotEditable, VersionConflict, ValueError) as exc: + raise map_repo_exception(exc) from exc + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/decnet/web/router/topology/api_lan_crud.py b/decnet/web/router/topology/api_lan_crud.py new file mode 100644 index 00000000..6a427e95 --- /dev/null +++ b/decnet/web/router/topology/api_lan_crud.py @@ -0,0 +1,149 @@ +"""LAN CRUD endpoints — pending-only child mutations. + + POST /topologies/{id}/lans + PATCH /topologies/{id}/lans/{lan_id} + DELETE /topologies/{id}/lans/{lan_id} +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Response, status + +from decnet.telemetry import traced as _traced +from decnet.topology.allocator import reserved_subnets +from decnet.topology.status import ( + TopologyNotEditable, + VersionConflict, +) +from decnet.web.db.models import LANCreateRequest, LANRow, LANUpdateRequest +from decnet.web.dependencies import repo, require_admin + +from ._guards import assert_pending_or_409, map_repo_exception + +router = APIRouter() + + +@router.post( + "/{topology_id}/lans", + tags=["MazeNET Topologies"], + response_model=LANRow, + status_code=status.HTTP_201_CREATED, + responses={ + 400: {"description": "Malformed body or invalid LAN fields"}, + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Topology not found"}, + 409: {"description": "Topology not editable or version conflict"}, + }, +) +@_traced("api.topology.lan.create") +async def api_create_lan( + topology_id: str, + body: LANCreateRequest, + _admin: dict = Depends(require_admin), +) -> LANRow: + await assert_pending_or_409(topology_id) + + subnet = body.subnet + if subnet is None: + # Mint a free /24. The allocator scans the claimed set and hands + # back the next free subnet base — same logic as the catalog + # /next-subnet endpoint, but inlined so create is atomic. + from decnet.topology.allocator import SubnetAllocator + + allocator = SubnetAllocator( + "10.0.0.0/16", reserved=await reserved_subnets(repo) + ) + subnet = allocator.next_free() + + payload = { + "topology_id": topology_id, + "name": body.name, + "subnet": subnet, + "is_dmz": body.is_dmz, + "x": body.x, + "y": body.y, + } + try: + lan_id = await repo.add_lan( + payload, expected_version=body.expected_version + ) + except (TopologyNotEditable, VersionConflict, ValueError) as exc: + raise map_repo_exception(exc) from exc + + rows = await repo.list_lans_for_topology(topology_id) + row = next((r for r in rows if r["id"] == lan_id), None) + if row is None: # pragma: no cover — would mean insert vanished + raise HTTPException(status_code=500, detail="LAN insert vanished") + return LANRow(**row) + + +@router.patch( + "/{topology_id}/lans/{lan_id}", + tags=["MazeNET Topologies"], + response_model=LANRow, + responses={ + 400: {"description": "Malformed body or invalid LAN fields"}, + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Topology or LAN not found"}, + 409: {"description": "Topology not editable or version conflict"}, + }, +) +@_traced("api.topology.lan.update") +async def api_update_lan( + topology_id: str, + lan_id: str, + body: LANUpdateRequest, + _admin: dict = Depends(require_admin), +) -> LANRow: + await assert_pending_or_409(topology_id) + + fields = body.model_dump(exclude_unset=True, exclude={"expected_version"}) + try: + await repo.update_lan( + lan_id, + fields, + expected_version=body.expected_version, + enforce_pending=True, + ) + except (TopologyNotEditable, VersionConflict) as exc: + raise map_repo_exception(exc) from exc + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + rows = await repo.list_lans_for_topology(topology_id) + row = next((r for r in rows if r["id"] == lan_id), None) + if row is None: + raise HTTPException(status_code=404, detail="LAN not found") + return LANRow(**row) + + +@router.delete( + "/{topology_id}/lans/{lan_id}", + tags=["MazeNET Topologies"], + status_code=status.HTTP_204_NO_CONTENT, + responses={ + 400: {"description": "Cannot delete: LAN has orphan-risking deckies"}, + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Topology or LAN not found"}, + 409: {"description": "Topology not editable or version conflict"}, + }, +) +@_traced("api.topology.lan.delete") +async def api_delete_lan( + topology_id: str, + lan_id: str, + _admin: dict = Depends(require_admin), +) -> Response: + await assert_pending_or_409(topology_id) + + rows = await repo.list_lans_for_topology(topology_id) + if not any(r["id"] == lan_id for r in rows): + raise HTTPException(status_code=404, detail="LAN not found") + + try: + await repo.delete_lan(lan_id) + except (TopologyNotEditable, VersionConflict, ValueError) as exc: + raise map_repo_exception(exc) from exc + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/tests/api/topology/test_child_crud.py b/tests/api/topology/test_child_crud.py new file mode 100644 index 00000000..b8c36375 --- /dev/null +++ b/tests/api/topology/test_child_crud.py @@ -0,0 +1,224 @@ +"""Phase 3 Step 4 — child CRUD: LAN / decky / edge.""" +from __future__ import annotations + +import pytest + +from decnet.topology.config import TopologyConfig +from decnet.topology.generator import generate +from decnet.topology.persistence import persist, transition_status +from decnet.topology.status import TopologyStatus +from decnet.web.dependencies import repo as _repo + +_V1 = "/api/v1/topologies" + + +def _cfg(name: str = "draft") -> TopologyConfig: + return TopologyConfig( + name=name, + depth=1, + branching_factor=1, + deckies_per_lan_min=1, + deckies_per_lan_max=1, + services_explicit=["ssh"], + randomize_services=False, + seed=0, + ) + + +async def _seed(name: str = "draft") -> str: + return await persist(_repo, generate(_cfg(name))) + + +def _hdr(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +# ── LAN CRUD ────────────────────────────────────────────────────── + + +@pytest.mark.anyio +async def test_lan_create_ok(client, auth_token): + topology_id = await _seed("lan-create") + r = await client.post( + f"{_V1}/{topology_id}/lans", + json={"name": "extra-lan"}, + headers=_hdr(auth_token), + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["name"] == "extra-lan" + assert body["topology_id"] == topology_id + assert body["subnet"] # allocator minted one + + +@pytest.mark.anyio +async def test_lan_create_blocked_when_active(client, auth_token): + topology_id = await _seed("lan-active") + await transition_status(_repo, topology_id, TopologyStatus.DEPLOYING) + await transition_status(_repo, topology_id, TopologyStatus.ACTIVE) + + r = await client.post( + f"{_V1}/{topology_id}/lans", + json={"name": "extra-lan"}, + headers=_hdr(auth_token), + ) + assert r.status_code == 409 + + +@pytest.mark.anyio +async def test_lan_patch_ok(client, auth_token): + topology_id = await _seed("lan-patch") + lans = await _repo.list_lans_for_topology(topology_id) + lan_id = lans[0]["id"] + + r = await client.patch( + f"{_V1}/{topology_id}/lans/{lan_id}", + json={"x": 123.0, "y": 456.0}, + headers=_hdr(auth_token), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["x"] == 123.0 + assert body["y"] == 456.0 + + +@pytest.mark.anyio +async def test_lan_delete_ok(client, auth_token): + topology_id = await _seed("lan-delete") + # Add a throw-away LAN first (deleting the primary LAN would orphan its decky). + created = await client.post( + f"{_V1}/{topology_id}/lans", + json={"name": "disposable"}, + headers=_hdr(auth_token), + ) + lan_id = created.json()["id"] + + r = await client.delete( + f"{_V1}/{topology_id}/lans/{lan_id}", + headers=_hdr(auth_token), + ) + assert r.status_code == 204 + + +@pytest.mark.anyio +async def test_lan_requires_admin(client, viewer_token): + topology_id = await _seed("lan-viewer") + r = await client.post( + f"{_V1}/{topology_id}/lans", + json={"name": "nope"}, + headers=_hdr(viewer_token), + ) + assert r.status_code == 403 + + +# ── Decky CRUD ──────────────────────────────────────────────────── + + +@pytest.mark.anyio +async def test_decky_create_ok(client, auth_token): + topology_id = await _seed("decky-create") + r = await client.post( + f"{_V1}/{topology_id}/deckies", + json={"name": "test-decky", "services": ["ssh"]}, + headers=_hdr(auth_token), + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["name"] == "test-decky" + assert body["services"] == ["ssh"] + + +@pytest.mark.anyio +async def test_decky_patch_ok(client, auth_token): + topology_id = await _seed("decky-patch") + deckies = await _repo.list_topology_deckies(topology_id) + decky_uuid = deckies[0]["uuid"] + + r = await client.patch( + f"{_V1}/{topology_id}/deckies/{decky_uuid}", + json={"x": 50.0, "y": 60.0}, + headers=_hdr(auth_token), + ) + assert r.status_code == 200 + assert r.json()["x"] == 50.0 + + +@pytest.mark.anyio +async def test_decky_delete_ok(client, auth_token): + topology_id = await _seed("decky-delete") + created = await client.post( + f"{_V1}/{topology_id}/deckies", + json={"name": "transient", "services": []}, + headers=_hdr(auth_token), + ) + decky_uuid = created.json()["uuid"] + + r = await client.delete( + f"{_V1}/{topology_id}/deckies/{decky_uuid}", + headers=_hdr(auth_token), + ) + assert r.status_code == 204 + + +@pytest.mark.anyio +async def test_decky_delete_missing_404(client, auth_token): + topology_id = await _seed("decky-missing") + r = await client.delete( + f"{_V1}/{topology_id}/deckies/not-a-uuid", + headers=_hdr(auth_token), + ) + assert r.status_code == 404 + + +# ── Edge CRUD ───────────────────────────────────────────────────── + + +@pytest.mark.anyio +async def test_edge_create_and_delete(client, auth_token): + topology_id = await _seed("edge-crud") + # Add a second LAN so we can wire an extra edge (bridge) into it. + new_lan = await client.post( + f"{_V1}/{topology_id}/lans", + json={"name": "bridge-target"}, + headers=_hdr(auth_token), + ) + lan_id = new_lan.json()["id"] + + deckies = await _repo.list_topology_deckies(topology_id) + decky_uuid = deckies[0]["uuid"] + + r = await client.post( + f"{_V1}/{topology_id}/edges", + json={"decky_uuid": decky_uuid, "lan_id": lan_id, "is_bridge": True}, + headers=_hdr(auth_token), + ) + assert r.status_code == 201, r.text + edge_id = r.json()["id"] + + r2 = await client.delete( + f"{_V1}/{topology_id}/edges/{edge_id}", + headers=_hdr(auth_token), + ) + assert r2.status_code == 204 + + +@pytest.mark.anyio +async def test_edge_create_bad_refs_400(client, auth_token): + topology_id = await _seed("edge-bad") + r = await client.post( + f"{_V1}/{topology_id}/edges", + json={"decky_uuid": "ghost", "lan_id": "also-ghost"}, + headers=_hdr(auth_token), + ) + assert r.status_code == 400 + + +@pytest.mark.anyio +async def test_edge_requires_admin(client, viewer_token): + topology_id = await _seed("edge-viewer") + r = await client.post( + f"{_V1}/{topology_id}/edges", + json={"decky_uuid": "x", "lan_id": "y"}, + headers=_hdr(viewer_token), + ) + assert r.status_code == 403