From f182c98ffa1d2336a535a168d17de247c1a086da Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 20 Apr 2026 18:25:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(api):=20phase=203=20step=202=20=E2=80=94?= =?UTF-8?q?=20topology=20read=20endpoints=20(list/get/status/catalog)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/topologies — paginated list with status filter. Extends repo.list_topologies() to accept limit/offset and adds count_topologies() for the total envelope field. GET /api/v1/topologies/{id} — hydrated TopologyDetail; 404 if missing. GET /api/v1/topologies/{id}/status-events — audit trail, limit-capped. Catalog helpers for the phase-4 canvas UI: * GET /topologies/services — full service catalog. * GET /topologies/next-subnet?base=172.20 — wraps SubnetAllocator against reserved_subnets across non-torn-down topologies. * GET /topologies/{id}/lans/{lan_id}/next-ip — IPAllocator pre-seeded with existing decky IPs in that LAN. All read routes are viewer-or-admin. Sub-routers are included in an order that keeps literal catalog paths (/services, /next-subnet) from being shadowed by the /{topology_id} trie branch. --- decnet/web/db/repository.py | 8 +- decnet/web/db/sqlmodel_repo.py | 18 +- decnet/web/router/topology/__init__.py | 14 +- decnet/web/router/topology/api_catalog.py | 104 +++++++++++ .../web/router/topology/api_get_topology.py | 66 +++++++ .../router/topology/api_list_topologies.py | 38 ++++ tests/api/topology/test_reads.py | 169 ++++++++++++++++++ 7 files changed, 413 insertions(+), 4 deletions(-) create mode 100644 decnet/web/router/topology/api_catalog.py create mode 100644 decnet/web/router/topology/api_get_topology.py create mode 100644 decnet/web/router/topology/api_list_topologies.py create mode 100644 tests/api/topology/test_reads.py diff --git a/decnet/web/db/repository.py b/decnet/web/db/repository.py index 0ead310c..acdcc638 100644 --- a/decnet/web/db/repository.py +++ b/decnet/web/db/repository.py @@ -247,10 +247,16 @@ class BaseRepository(ABC): raise NotImplementedError async def list_topologies( - self, status: Optional[str] = None + self, + status: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, ) -> list[dict[str, Any]]: raise NotImplementedError + async def count_topologies(self, status: Optional[str] = None) -> int: + raise NotImplementedError + async def update_topology_status( self, topology_id: str, diff --git a/decnet/web/db/sqlmodel_repo.py b/decnet/web/db/sqlmodel_repo.py index 3ed3a8ac..08b8e19e 100644 --- a/decnet/web/db/sqlmodel_repo.py +++ b/decnet/web/db/sqlmodel_repo.py @@ -949,11 +949,18 @@ class SQLModelRepository(BaseRepository): return self._deserialize_json_fields(d, ("config_snapshot",)) async def list_topologies( - self, status: Optional[str] = None + self, + status: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, ) -> list[dict[str, Any]]: statement = select(Topology).order_by(desc(Topology.created_at)) if status: statement = statement.where(Topology.status == status) + if offset is not None: + statement = statement.offset(offset) + if limit is not None: + statement = statement.limit(limit) async with self._session() as session: result = await session.execute(statement) return [ @@ -963,6 +970,15 @@ class SQLModelRepository(BaseRepository): for r in result.scalars().all() ] + async def count_topologies(self, status: Optional[str] = None) -> int: + from sqlalchemy import func + statement = select(func.count(Topology.id)) + if status: + statement = statement.where(Topology.status == status) + async with self._session() as session: + result = await session.execute(statement) + return int(result.scalar_one() or 0) + async def update_topology_status( self, topology_id: str, diff --git a/decnet/web/router/topology/__init__.py b/decnet/web/router/topology/__init__.py index b0b5605f..a0dce806 100644 --- a/decnet/web/router/topology/__init__.py +++ b/decnet/web/router/topology/__init__.py @@ -9,10 +9,20 @@ live one-per-file and are aggregated here. """ from fastapi import APIRouter +from .api_catalog import router as _catalog_router +from .api_get_topology import router as _get_router +from .api_list_topologies import router as _list_router + 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. +# Order matters: catalog routes use literal path segments (e.g. +# /services, /next-subnet) that would otherwise be shadowed by the +# `/{topology_id}` path in api_get_topology. Keep the catalog router +# included first so FastAPI's trie resolves literals before the +# parameterized fallback. +topology_router.include_router(_catalog_router) +topology_router.include_router(_list_router) +topology_router.include_router(_get_router) __all__ = ["topology_router"] diff --git a/decnet/web/router/topology/api_catalog.py b/decnet/web/router/topology/api_catalog.py new file mode 100644 index 00000000..44c44114 --- /dev/null +++ b/decnet/web/router/topology/api_catalog.py @@ -0,0 +1,104 @@ +"""Read-only catalog endpoints — services, next-subnet, next-ip. + +These wrap fleet/allocator helpers so the phase-4 canvas UI can lean +on the server for allocation instead of shipping the logic client-side. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query + +from decnet.fleet import all_service_names +from decnet.telemetry import traced as _traced +from decnet.topology.allocator import ( + AllocatorExhausted, + IPAllocator, + SubnetAllocator, + reserved_subnets, +) +from decnet.web.db.models import ( + NextIPResponse, + NextSubnetResponse, + ServiceCatalogResponse, +) +from decnet.web.dependencies import repo, require_viewer + +router = APIRouter() + + +@router.get( + "/services", + tags=["MazeNET Topologies"], + response_model=ServiceCatalogResponse, + responses={ + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + }, +) +@_traced("api.topology.catalog.services") +async def api_list_services( + _viewer: dict = Depends(require_viewer), +) -> ServiceCatalogResponse: + return ServiceCatalogResponse(services=all_service_names()) + + +@router.get( + "/next-subnet", + tags=["MazeNET Topologies"], + response_model=NextSubnetResponse, + responses={ + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + 409: {"description": "Allocator exhausted"}, + }, +) +@_traced("api.topology.catalog.next_subnet") +async def api_next_subnet( + base: str = Query(default="172.20", pattern=r"^\d{1,3}\.\d{1,3}$"), + _viewer: dict = Depends(require_viewer), +) -> NextSubnetResponse: + reserved = await reserved_subnets(repo) + alloc = SubnetAllocator(base_prefix=base, reserved=reserved) + try: + subnet = alloc.next_free() + except AllocatorExhausted as e: + raise HTTPException(status_code=409, detail=str(e)) + return NextSubnetResponse(subnet=subnet) + + +@router.get( + "/{topology_id}/lans/{lan_id}/next-ip", + tags=["MazeNET Topologies"], + response_model=NextIPResponse, + responses={ + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Topology or LAN not found"}, + 409: {"description": "Allocator exhausted"}, + }, +) +@_traced("api.topology.catalog.next_ip") +async def api_next_ip( + topology_id: str, + lan_id: str, + _viewer: dict = Depends(require_viewer), +) -> NextIPResponse: + if await repo.get_topology(topology_id) is None: + raise HTTPException(status_code=404, detail="Topology not found") + lans = await repo.list_lans_for_topology(topology_id) + lan = next((ln for ln in lans if ln["id"] == lan_id), None) + if lan is None: + raise HTTPException(status_code=404, detail="LAN not found") + deckies = await repo.list_topology_deckies(topology_id) + alloc = IPAllocator(subnet=lan["subnet"]) + for d in deckies: + ip = (d.get("decky_config") or {}).get("ips_by_lan", {}).get(lan["name"]) + if ip: + try: + alloc.reserve(ip) + except ValueError: + continue + try: + ip = alloc.next_free() + except AllocatorExhausted as e: + raise HTTPException(status_code=409, detail=str(e)) + return NextIPResponse(subnet=lan["subnet"], ip=ip) diff --git a/decnet/web/router/topology/api_get_topology.py b/decnet/web/router/topology/api_get_topology.py new file mode 100644 index 00000000..dd9ebaa9 --- /dev/null +++ b/decnet/web/router/topology/api_get_topology.py @@ -0,0 +1,66 @@ +"""GET /topologies/{id} and /topologies/{id}/status-events.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query + +from decnet.telemetry import traced as _traced +from decnet.topology.persistence import hydrate +from decnet.web.db.models import ( + DeckyRow, + EdgeRow, + LANRow, + TopologyDetail, + TopologyStatusEventRow, + TopologySummary, +) +from decnet.web.dependencies import repo, require_viewer + +router = APIRouter() + + +@router.get( + "/{topology_id}", + tags=["MazeNET Topologies"], + response_model=TopologyDetail, + responses={ + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Topology not found"}, + }, +) +@_traced("api.topology.get") +async def api_get_topology( + topology_id: str, + _viewer: dict = Depends(require_viewer), +) -> TopologyDetail: + hydrated = await hydrate(repo, topology_id) + if hydrated is None: + raise HTTPException(status_code=404, detail="Topology not found") + return TopologyDetail( + topology=TopologySummary(**hydrated["topology"]), + lans=[LANRow(**r) for r in hydrated["lans"]], + deckies=[DeckyRow(**r) for r in hydrated["deckies"]], + edges=[EdgeRow(**r) for r in hydrated["edges"]], + ) + + +@router.get( + "/{topology_id}/status-events", + tags=["MazeNET Topologies"], + response_model=list[TopologyStatusEventRow], + responses={ + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Topology not found"}, + }, +) +@_traced("api.topology.status_events") +async def api_get_status_events( + topology_id: str, + limit: int = Query(default=100, ge=1, le=1000), + _viewer: dict = Depends(require_viewer), +) -> list[TopologyStatusEventRow]: + if await repo.get_topology(topology_id) is None: + raise HTTPException(status_code=404, detail="Topology not found") + rows = await repo.list_topology_status_events(topology_id, limit=limit) + return [TopologyStatusEventRow(**r) for r in rows] diff --git a/decnet/web/router/topology/api_list_topologies.py b/decnet/web/router/topology/api_list_topologies.py new file mode 100644 index 00000000..f1df8ab3 --- /dev/null +++ b/decnet/web/router/topology/api_list_topologies.py @@ -0,0 +1,38 @@ +"""GET /topologies — paginated list of MazeNET topologies.""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, Query + +from decnet.telemetry import traced as _traced +from decnet.web.db.models import TopologyListResponse, TopologySummary +from decnet.web.dependencies import repo, require_viewer + +router = APIRouter() + + +@router.get( + "/", + tags=["MazeNET Topologies"], + response_model=TopologyListResponse, + responses={ + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + }, +) +@_traced("api.topology.list") +async def api_list_topologies( + status: Optional[str] = Query(default=None, description="Filter by topology status"), + limit: int = Query(default=50, ge=1, le=500), + offset: int = Query(default=0, ge=0), + _viewer: dict = Depends(require_viewer), +) -> TopologyListResponse: + total = await repo.count_topologies(status=status) + rows = await repo.list_topologies(status=status, limit=limit, offset=offset) + return TopologyListResponse( + total=total, + limit=limit, + offset=offset, + data=[TopologySummary(**r) for r in rows], + ) diff --git a/tests/api/topology/test_reads.py b/tests/api/topology/test_reads.py new file mode 100644 index 00000000..1951e4a3 --- /dev/null +++ b/tests/api/topology/test_reads.py @@ -0,0 +1,169 @@ +"""Phase 3 Step 2 — read endpoints: list / get / status-events / catalog.""" +from __future__ import annotations + +import pytest +from sqlmodel import select as _ss_select + +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.db.models import Topology as _TopologyTable +from decnet.web.dependencies import repo as _repo + +_V1 = "/api/v1/topologies" +_LIST = f"{_V1}/" + + +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))) + + +@pytest.mark.anyio +async def test_list_empty_ok(client, auth_token): + r = await client.get(_LIST, headers={"Authorization": f"Bearer {auth_token}"}) + assert r.status_code == 200 + body = r.json() + assert body["total"] == 0 + assert body["data"] == [] + + +@pytest.mark.anyio +async def test_list_requires_auth(client): + r = await client.get(_LIST) + assert r.status_code == 401 + + +@pytest.mark.anyio +async def test_list_viewer_allowed(client, viewer_token): + r = await client.get(_LIST, headers={"Authorization": f"Bearer {viewer_token}"}) + assert r.status_code == 200 + + +@pytest.mark.anyio +async def test_list_with_topology_and_pagination(client, auth_token): + tid1 = await _seed("alpha") + await _seed("beta") + r = await client.get( + f"{_LIST}?limit=1&offset=0", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["total"] == 2 + assert len(body["data"]) == 1 + assert body["data"][0]["id"] in {tid1, body["data"][0]["id"]} + + +@pytest.mark.anyio +async def test_get_topology_hydrated(client, auth_token): + tid = await _seed("detail") + r = await client.get( + f"{_V1}/{tid}", headers={"Authorization": f"Bearer {auth_token}"} + ) + assert r.status_code == 200 + body = r.json() + assert body["topology"]["id"] == tid + assert body["topology"]["version"] == 1 + assert body["lans"], "seeded topology has at least one LAN" + assert body["deckies"] + + +@pytest.mark.anyio +async def test_get_topology_404(client, auth_token): + r = await client.get( + f"{_V1}/does-not-exist", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert r.status_code == 404 + + +@pytest.mark.anyio +async def test_status_events_after_transition(client, auth_token): + tid = await _seed("events") + await transition_status(_repo, tid, TopologyStatus.DEPLOYING) + r = await client.get( + f"{_V1}/{tid}/status-events", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert r.status_code == 200 + rows = r.json() + assert rows and rows[0]["to_status"] == "deploying" + + +@pytest.mark.anyio +async def test_status_events_404_on_missing(client, auth_token): + r = await client.get( + f"{_V1}/nope/status-events", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert r.status_code == 404 + + +@pytest.mark.anyio +async def test_services_catalog(client, viewer_token): + r = await client.get( + f"{_V1}/services", + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert r.status_code == 200 + body = r.json() + assert isinstance(body["services"], list) + assert "ssh" in body["services"] + + +@pytest.mark.anyio +async def test_next_subnet_starts_at_base(client, auth_token): + r = await client.get( + f"{_V1}/next-subnet?base=172.20", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert r.status_code == 200 + assert r.json()["subnet"].startswith("172.20.") + + +@pytest.mark.anyio +async def test_next_ip_skips_gateway_and_existing(client, auth_token): + tid = await _seed("ipalloc") + # Find a LAN and existing decky IPs from the seeded topology. + r = await client.get( + f"{_V1}/{tid}", headers={"Authorization": f"Bearer {auth_token}"} + ) + body = r.json() + lan = body["lans"][0] + taken = { + (d.get("decky_config") or {}).get("ips_by_lan", {}).get(lan["name"]) + for d in body["deckies"] + } + taken.discard(None) + r2 = await client.get( + f"{_V1}/{tid}/lans/{lan['id']}/next-ip", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert r2.status_code == 200 + ip = r2.json()["ip"] + assert ip not in taken + assert not ip.endswith(".1") # gateway skipped + + +@pytest.mark.anyio +async def test_next_ip_404_lan(client, auth_token): + tid = await _seed("nopelan") + r = await client.get( + f"{_V1}/{tid}/lans/bogus/next-ip", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert r.status_code == 404