refactor(topology): introduce TopologyRepository protocol with DTO return types

Replace repo: BaseRepository with a structural TopologyRepository protocol
in persistence.py and allocator.py. All read methods now return typed DTOs
(TopologySummary, LANRow, DeckyRow, EdgeRow) instead of raw dicts, eliminating
silent field-shape regressions across the topology subsystem.

TopologySummary gains email_personas and language_default so api_personas.py
can continue reading those fields via attribute access. hydrate() converts
DTOs to dicts before passing to _backfill_decky_configs, keeping the mutable
working-state function dict-based at its boundary. All production callers
(router handlers, mutator, CLI, heartbeat) migrated from dict/get access to
attribute access. 134 tests pass.
This commit is contained in:
2026-04-30 23:51:41 -04:00
parent 3456d3ab45
commit fc1f0914b7
34 changed files with 231 additions and 175 deletions

View File

@@ -233,8 +233,8 @@ def _delete(
topo = await repo.get_topology(topology_id) topo = await repo.get_topology(topology_id)
if topo is None: if topo is None:
return False, "not-found" return False, "not-found"
if topo["status"] in _RUNNING: if topo.status in _RUNNING:
return False, str(topo["status"]) return False, str(topo.status)
ok = await repo.delete_topology_cascade(topology_id) ok = await repo.delete_topology_cascade(topology_id)
return ok, None return ok, None

View File

@@ -284,13 +284,13 @@ async def reconcile_agent_resyncs(repo: BaseRepository) -> int:
return 0 return 0
drained = 0 drained = 0
for topo in pending: for topo in pending:
tid = topo["id"] tid = topo.id
try: try:
await _deployer.resync_agent_topology(repo, tid) await _deployer.resync_agent_topology(repo, tid)
await repo.set_topology_resync(tid, False) await repo.set_topology_resync(tid, False)
drained += 1 drained += 1
log.info("topology %s resynced to agent %s", log.info("topology %s resynced to agent %s",
tid, topo.get("target_host_uuid")) tid, topo.target_host_uuid)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"topology %s resync failed (will retry): %s", tid, exc, "topology %s resync failed (will retry): %s", tid, exc,

View File

@@ -121,10 +121,10 @@ async def _materialise_lan_change(
topology = await repo.get_topology(topology_id) topology = await repo.get_topology(topology_id)
if topology is None: if topology is None:
return return
status = topology.get("status") status = topology.status
if status not in ("active", "degraded"): if status not in ("active", "degraded"):
return return
if topology.get("target_host_uuid"): if topology.target_host_uuid:
_log.info( _log.info(
"live LAN op skipped (agent-pinned topology=%s); next agent push will reconcile", "live LAN op skipped (agent-pinned topology=%s); next agent push will reconcile",
topology_id, topology_id,
@@ -291,9 +291,9 @@ async def _live_topology_or_none(
topology = await repo.get_topology(topology_id) topology = await repo.get_topology(topology_id)
if topology is None: if topology is None:
return None return None
if topology.get("status") not in ("active", "degraded"): if topology.status not in ("active", "degraded"):
return None return None
if topology.get("target_host_uuid"): if topology.target_host_uuid:
_log.info( _log.info(
"live decky op skipped (agent-pinned topology=%s); " "live decky op skipped (agent-pinned topology=%s); "
"next agent push will reconcile", "next agent push will reconcile",
@@ -1019,7 +1019,7 @@ async def apply_update_lan(
return return
topology = await repo.get_topology(topology_id) topology = await repo.get_topology(topology_id)
is_live = bool(topology) and topology.get("status") in ("active", "degraded") is_live = bool(topology) and topology.status in ("active", "degraded")
if is_live: if is_live:
hostile = {"subnet", "is_dmz"} & fields.keys() hostile = {"subnet", "is_dmz"} & fields.keys()
if hostile: if hostile:

View File

@@ -14,8 +14,8 @@ from __future__ import annotations
from ipaddress import IPv4Network from ipaddress import IPv4Network
from typing import Iterable from typing import Iterable
from decnet.topology.repository import TopologyRepository
from decnet.topology.status import TopologyStatus from decnet.topology.status import TopologyStatus
from decnet.web.db.repository import BaseRepository
class AllocatorExhausted(RuntimeError): class AllocatorExhausted(RuntimeError):
@@ -150,13 +150,12 @@ _SUBNET_CLAIMING_STATES: frozenset[str] = frozenset(
) )
async def reserved_subnets(repo: BaseRepository) -> set[str]: async def reserved_subnets(repo: TopologyRepository) -> set[str]:
"""All LAN subnets currently claimed by non-torn-down topologies.""" """All LAN subnets currently claimed by non-torn-down topologies."""
out: set[str] = set() out: set[str] = set()
for status in _SUBNET_CLAIMING_STATES: for status in _SUBNET_CLAIMING_STATES:
for topo in await repo.list_topologies(status=status): for topo in await repo.list_topologies(status=status):
for lan in await repo.list_lans_for_topology(topo["id"]): for lan in await repo.list_lans_for_topology(topo.id):
subnet = lan.get("subnet") if lan.subnet:
if subnet: out.add(lan.subnet)
out.add(subnet)
return out return out

View File

@@ -5,13 +5,13 @@ from ipaddress import IPv4Address, IPv4Network
from typing import Any from typing import Any
from decnet.topology.allocator import IPAllocator from decnet.topology.allocator import IPAllocator
from decnet.web.db.repository import BaseRepository from decnet.topology.repository import TopologyRepository
from decnet.topology.config import GeneratedTopology from decnet.topology.config import GeneratedTopology
from decnet.topology.status import TopologyStatus, assert_transition from decnet.topology.status import TopologyStatus, assert_transition
async def persist( async def persist(
repo: BaseRepository, repo: TopologyRepository,
plan: GeneratedTopology, plan: GeneratedTopology,
*, *,
target_host_uuid: str | None = None, target_host_uuid: str | None = None,
@@ -91,7 +91,7 @@ async def persist(
async def transition_status( async def transition_status(
repo: BaseRepository, repo: TopologyRepository,
topology_id: str, topology_id: str,
new_status: str, new_status: str,
reason: str | None = None, reason: str | None = None,
@@ -104,11 +104,11 @@ async def transition_status(
topo = await repo.get_topology(topology_id) topo = await repo.get_topology(topology_id)
if topo is None: if topo is None:
raise ValueError(f"topology {topology_id!r} not found") raise ValueError(f"topology {topology_id!r} not found")
assert_transition(topo["status"], new_status) assert_transition(topo.status, new_status)
await repo.update_topology_status(topology_id, new_status, reason=reason) await repo.update_topology_status(topology_id, new_status, reason=reason)
async def hydrate(repo: BaseRepository, topology_id: str) -> dict[str, Any] | None: async def hydrate(repo: TopologyRepository, topology_id: str) -> dict[str, Any] | None:
"""Load a topology + children into a single dict for callers. """Load a topology + children into a single dict for callers.
Shape:: Shape::
@@ -125,15 +125,21 @@ async def hydrate(repo: BaseRepository, topology_id: str) -> dict[str, Any] | No
topo = await repo.get_topology(topology_id) topo = await repo.get_topology(topology_id)
if topo is None: if topo is None:
return None return None
lans = await repo.list_lans_for_topology(topology_id) lans_dto = await repo.list_lans_for_topology(topology_id)
deckies = await repo.list_topology_deckies(topology_id) deckies_dto = await repo.list_topology_deckies(topology_id)
edges = await repo.list_topology_edges(topology_id) edges_dto = await repo.list_topology_edges(topology_id)
_backfill_decky_configs(lans, deckies, edges) # Convert to dicts for _backfill_decky_configs (mutates decky_config in-place).
# mode="json" is mandatory: datetime fields must arrive as ISO strings for all
# downstream consumers (canonical_hash, deployer, api_get_topology, etc.).
lan_dicts = [m.model_dump(mode="json") for m in lans_dto]
decky_dicts = [m.model_dump(mode="json") for m in deckies_dto]
edge_dicts = [m.model_dump(mode="json") for m in edges_dto]
_backfill_decky_configs(lan_dicts, decky_dicts, edge_dicts)
return { return {
"topology": topo, "topology": topo.model_dump(mode="json"),
"lans": lans, "lans": lan_dicts,
"deckies": deckies, "deckies": decky_dicts,
"edges": edges, "edges": edge_dicts,
} }

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from typing import Any, Optional, Protocol
from decnet.web.db.models.topology import DeckyRow, EdgeRow, LANRow, TopologySummary
class TopologyRepository(Protocol):
"""Structural contract for the topology subsystem's repo dependency.
Declares only the 10 methods the topology package actually calls.
Any object with matching async signatures satisfies this Protocol
without inheritance — including BaseRepository and test stubs.
"""
async def create_topology(self, data: dict[str, Any]) -> str: ...
async def get_topology(self, topology_id: str) -> Optional[TopologySummary]: ...
async def update_topology_status(
self, topology_id: str, new_status: str, reason: Optional[str] = None
) -> None: ...
async def list_topologies(
self, status: Optional[str] = None
) -> list[TopologySummary]: ...
async def add_lan(self, data: dict[str, Any]) -> str: ...
async def list_lans_for_topology(self, topology_id: str) -> list[LANRow]: ...
async def add_topology_decky(self, data: dict[str, Any]) -> str: ...
async def list_topology_deckies(self, topology_id: str) -> list[DeckyRow]: ...
async def add_topology_edge(self, data: dict[str, Any]) -> str: ...
async def list_topology_edges(self, topology_id: str) -> list[EdgeRow]: ...

View File

@@ -237,6 +237,8 @@ class TopologySummary(BaseModel):
needs_resync: bool = False needs_resync: bool = False
created_at: datetime created_at: datetime
status_changed_at: Optional[datetime] = None status_changed_at: Optional[datetime] = None
email_personas: str = "[]"
language_default: str = "en"
class TopologyListResponse(BaseModel): class TopologyListResponse(BaseModel):

View File

@@ -1,6 +1,8 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Optional from typing import Any, Optional
from decnet.web.db.models.topology import DeckyRow, EdgeRow, LANRow, TopologySummary
class BaseRepository(ABC): class BaseRepository(ABC):
"""Abstract base class for DECNET web dashboard data storage.""" """Abstract base class for DECNET web dashboard data storage."""
@@ -699,7 +701,7 @@ class BaseRepository(ABC):
async def create_topology(self, data: dict[str, Any]) -> str: async def create_topology(self, data: dict[str, Any]) -> str:
raise NotImplementedError raise NotImplementedError
async def get_topology(self, topology_id: str) -> Optional[dict[str, Any]]: async def get_topology(self, topology_id: str) -> Optional[TopologySummary]:
raise NotImplementedError raise NotImplementedError
async def list_topologies( async def list_topologies(
@@ -707,7 +709,7 @@ class BaseRepository(ABC):
status: Optional[str] = None, status: Optional[str] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
offset: Optional[int] = None, offset: Optional[int] = None,
) -> list[dict[str, Any]]: ) -> list[TopologySummary]:
raise NotImplementedError raise NotImplementedError
async def count_topologies(self, status: Optional[str] = None) -> int: async def count_topologies(self, status: Optional[str] = None) -> int:
@@ -732,7 +734,7 @@ class BaseRepository(ABC):
) -> bool: ) -> bool:
raise NotImplementedError raise NotImplementedError
async def list_topologies_needing_resync(self) -> list[dict[str, Any]]: async def list_topologies_needing_resync(self) -> list[TopologySummary]:
raise NotImplementedError raise NotImplementedError
async def add_lan(self, data: dict[str, Any]) -> str: async def add_lan(self, data: dict[str, Any]) -> str:
@@ -750,7 +752,7 @@ class BaseRepository(ABC):
async def list_lans_for_topology( async def list_lans_for_topology(
self, topology_id: str self, topology_id: str
) -> list[dict[str, Any]]: ) -> list[LANRow]:
raise NotImplementedError raise NotImplementedError
async def add_topology_decky(self, data: dict[str, Any]) -> str: async def add_topology_decky(self, data: dict[str, Any]) -> str:
@@ -768,7 +770,7 @@ class BaseRepository(ABC):
async def list_topology_deckies( async def list_topology_deckies(
self, topology_id: str self, topology_id: str
) -> list[dict[str, Any]]: ) -> list[DeckyRow]:
raise NotImplementedError raise NotImplementedError
async def add_topology_edge(self, data: dict[str, Any]) -> str: async def add_topology_edge(self, data: dict[str, Any]) -> str:
@@ -776,7 +778,7 @@ class BaseRepository(ABC):
async def list_topology_edges( async def list_topology_edges(
self, topology_id: str self, topology_id: str
) -> list[dict[str, Any]]: ) -> list[EdgeRow]:
raise NotImplementedError raise NotImplementedError
async def list_topology_status_events( async def list_topology_status_events(

View File

@@ -8,10 +8,8 @@ from typing import Any, Optional
from sqlalchemy import desc, func, select, text from sqlalchemy import desc, func, select, text
from decnet.web.db.models import Topology, TopologyStatusEvent from decnet.web.db.models import Topology, TopologyStatusEvent
from decnet.web.db.sqlmodel_repo._helpers import ( from decnet.web.db.models.topology import TopologySummary
_deserialize_json_fields, from decnet.web.db.sqlmodel_repo._helpers import _serialize_json_fields
_serialize_json_fields,
)
class TopologyCoreMixin: class TopologyCoreMixin:
@@ -32,7 +30,7 @@ class TopologyCoreMixin:
await session.refresh(row) await session.refresh(row)
return row.id return row.id
async def get_topology(self, topology_id: str) -> Optional[dict[str, Any]]: async def get_topology(self, topology_id: str) -> Optional[TopologySummary]:
async with self._session() as session: async with self._session() as session:
result = await session.execute( result = await session.execute(
select(Topology).where(Topology.id == topology_id) select(Topology).where(Topology.id == topology_id)
@@ -40,15 +38,14 @@ class TopologyCoreMixin:
row = result.scalar_one_or_none() row = result.scalar_one_or_none()
if not row: if not row:
return None return None
d = row.model_dump(mode="json") return TopologySummary.model_validate(row.model_dump(mode="json"))
return _deserialize_json_fields(d, ("config_snapshot",))
async def list_topologies( async def list_topologies(
self, self,
status: Optional[str] = None, status: Optional[str] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
offset: Optional[int] = None, offset: Optional[int] = None,
) -> list[dict[str, Any]]: ) -> list[TopologySummary]:
statement = select(Topology).order_by(desc(Topology.created_at)) statement = select(Topology).order_by(desc(Topology.created_at))
if status: if status:
statement = statement.where(Topology.status == status) statement = statement.where(Topology.status == status)
@@ -59,9 +56,7 @@ class TopologyCoreMixin:
async with self._session() as session: async with self._session() as session:
result = await session.execute(statement) result = await session.execute(statement)
return [ return [
_deserialize_json_fields( TopologySummary.model_validate(r.model_dump(mode="json"))
r.model_dump(mode="json"), ("config_snapshot",)
)
for r in result.scalars().all() for r in result.scalars().all()
] ]
@@ -140,15 +135,13 @@ class TopologyCoreMixin:
await session.commit() await session.commit()
return True return True
async def list_topologies_needing_resync(self) -> list[dict[str, Any]]: async def list_topologies_needing_resync(self) -> list[TopologySummary]:
async with self._session() as session: async with self._session() as session:
result = await session.execute( result = await session.execute(
select(Topology).where(Topology.needs_resync == True) # noqa: E712 select(Topology).where(Topology.needs_resync == True) # noqa: E712
) )
return [ return [
_deserialize_json_fields( TopologySummary.model_validate(r.model_dump(mode="json"))
r.model_dump(mode="json"), ("config_snapshot",)
)
for r in result.scalars().all() for r in result.scalars().all()
] ]

View File

@@ -8,6 +8,7 @@ from typing import Any, Optional
from sqlalchemy import asc, select, text, update from sqlalchemy import asc, select, text, update
from decnet.web.db.models import TopologyDecky from decnet.web.db.models import TopologyDecky
from decnet.web.db.models.topology import DeckyRow
from decnet.web.db.sqlmodel_repo._helpers import ( from decnet.web.db.sqlmodel_repo._helpers import (
_deserialize_json_fields, _deserialize_json_fields,
_serialize_json_fields, _serialize_json_fields,
@@ -110,7 +111,7 @@ class TopologyDeckiesMixin:
async def list_topology_deckies( async def list_topology_deckies(
self, topology_id: str self, topology_id: str
) -> list[dict[str, Any]]: ) -> list[DeckyRow]:
async with self._session() as session: async with self._session() as session:
result = await session.execute( result = await session.execute(
select(TopologyDecky) select(TopologyDecky)
@@ -118,8 +119,10 @@ class TopologyDeckiesMixin:
.order_by(asc(TopologyDecky.name)) .order_by(asc(TopologyDecky.name))
) )
return [ return [
_deserialize_json_fields( DeckyRow.model_validate(
r.model_dump(mode="json"), ("services", "decky_config") _deserialize_json_fields(
r.model_dump(mode="json"), ("services", "decky_config")
)
) )
for r in result.scalars().all() for r in result.scalars().all()
] ]

View File

@@ -6,6 +6,7 @@ from typing import Any, Optional
from sqlalchemy import desc, select, text from sqlalchemy import desc, select, text
from decnet.web.db.models import TopologyEdge, TopologyStatusEvent from decnet.web.db.models import TopologyEdge, TopologyStatusEvent
from decnet.web.db.models.topology import EdgeRow
class TopologyEdgesMixin: class TopologyEdgesMixin:
@@ -60,12 +61,12 @@ class TopologyEdgesMixin:
async def list_topology_edges( async def list_topology_edges(
self, topology_id: str self, topology_id: str
) -> list[dict[str, Any]]: ) -> list[EdgeRow]:
async with self._session() as session: async with self._session() as session:
result = await session.execute( result = await session.execute(
select(TopologyEdge).where(TopologyEdge.topology_id == topology_id) select(TopologyEdge).where(TopologyEdge.topology_id == topology_id)
) )
return [r.model_dump(mode="json") for r in result.scalars().all()] return [EdgeRow.model_validate(r.model_dump(mode="json")) for r in result.scalars().all()]
async def list_topology_status_events( async def list_topology_status_events(
self, topology_id: str, limit: int = 100 self, topology_id: str, limit: int = 100

View File

@@ -6,6 +6,7 @@ from typing import Any, Optional
from sqlalchemy import asc, select, text, update from sqlalchemy import asc, select, text, update
from decnet.web.db.models import LAN, TopologyEdge from decnet.web.db.models import LAN, TopologyEdge
from decnet.web.db.models.topology import LANRow
class LansMixin: class LansMixin:
@@ -117,9 +118,9 @@ class LansMixin:
async def list_lans_for_topology( async def list_lans_for_topology(
self, topology_id: str self, topology_id: str
) -> list[dict[str, Any]]: ) -> list[LANRow]:
async with self._session() as session: async with self._session() as session:
result = await session.execute( result = await session.execute(
select(LAN).where(LAN.topology_id == topology_id).order_by(asc(LAN.name)) select(LAN).where(LAN.topology_id == topology_id).order_by(asc(LAN.name))
) )
return [r.model_dump(mode="json") for r in result.scalars().all()] return [LANRow.model_validate(r.model_dump(mode="json")) for r in result.scalars().all()]

View File

@@ -135,8 +135,8 @@ async def _reconcile_topology_report(
reported_hash = (reported or {}).get("applied_version_hash") reported_hash = (reported or {}).get("applied_version_hash")
for topo in mine: for topo in mine:
tid = topo["id"] tid = topo.id
if topo.get("needs_resync"): if topo.needs_resync:
continue continue
expected: Optional[str] = None expected: Optional[str] = None
if reported_id == tid and reported_hash: if reported_id == tid and reported_hash:

View File

@@ -1,8 +1,6 @@
"""Shared helpers for the Phase-3 child-CRUD routes.""" """Shared helpers for the Phase-3 child-CRUD routes."""
from __future__ import annotations from __future__ import annotations
from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from decnet.topology.status import ( from decnet.topology.status import (
@@ -10,17 +8,18 @@ from decnet.topology.status import (
TopologyStatus, TopologyStatus,
VersionConflict, VersionConflict,
) )
from decnet.web.db.models.topology import TopologySummary
from decnet.web.dependencies import repo from decnet.web.dependencies import repo
async def get_topology_or_404(topology_id: str) -> dict[str, Any]: async def get_topology_or_404(topology_id: str) -> TopologySummary:
topo = await repo.get_topology(topology_id) topo = await repo.get_topology(topology_id)
if topo is None: if topo is None:
raise HTTPException(status_code=404, detail="Topology not found") raise HTTPException(status_code=404, detail="Topology not found")
return topo return topo
async def assert_pending_or_409(topology_id: str) -> dict[str, Any]: async def assert_pending_or_409(topology_id: str) -> TopologySummary:
"""Ensure the topology exists and is in ``pending`` state. """Ensure the topology exists and is in ``pending`` state.
The repo layer enforces the same rule inside mutation methods, but the The repo layer enforces the same rule inside mutation methods, but the
@@ -28,11 +27,11 @@ async def assert_pending_or_409(topology_id: str) -> dict[str, Any]:
the pre-condition before any side effect. the pre-condition before any side effect.
""" """
topo = await get_topology_or_404(topology_id) topo = await get_topology_or_404(topology_id)
if topo["status"] != TopologyStatus.PENDING: if topo.status != TopologyStatus.PENDING:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=( detail=(
f"Topology is {topo['status']!r}; free-form child edits are " f"Topology is {topo.status!r}; free-form child edits are "
f"pending-only. Use the mutation queue for active topologies." f"pending-only. Use the mutation queue for active topologies."
), ),
) )

View File

@@ -164,13 +164,13 @@ async def api_next_ip(
if await repo.get_topology(topology_id) is None: if await repo.get_topology(topology_id) is None:
raise HTTPException(status_code=404, detail="Topology not found") raise HTTPException(status_code=404, detail="Topology not found")
lans = await repo.list_lans_for_topology(topology_id) lans = await repo.list_lans_for_topology(topology_id)
lan = next((ln for ln in lans if ln["id"] == lan_id), None) lan = next((ln for ln in lans if ln.id == lan_id), None)
if lan is None: if lan is None:
raise HTTPException(status_code=404, detail="LAN not found") raise HTTPException(status_code=404, detail="LAN not found")
deckies = await repo.list_topology_deckies(topology_id) deckies = await repo.list_topology_deckies(topology_id)
alloc = IPAllocator(subnet=lan["subnet"]) alloc = IPAllocator(subnet=lan.subnet)
for d in deckies: for d in deckies:
ip = (d.get("decky_config") or {}).get("ips_by_lan", {}).get(lan["name"]) ip = (d.decky_config or {}).get("ips_by_lan", {}).get(lan.name)
if ip: if ip:
try: try:
alloc.reserve(ip) alloc.reserve(ip)
@@ -180,4 +180,4 @@ async def api_next_ip(
ip = alloc.next_free() ip = alloc.next_free()
except AllocatorExhausted as e: except AllocatorExhausted as e:
raise HTTPException(status_code=409, detail=str(e)) raise HTTPException(status_code=409, detail=str(e))
return NextIPResponse(subnet=lan["subnet"], ip=ip) return NextIPResponse(subnet=lan.subnet, ip=ip)

View File

@@ -58,10 +58,10 @@ async def api_create_decky(
raise map_repo_exception(exc) from exc raise map_repo_exception(exc) from exc
rows = await repo.list_topology_deckies(topology_id) rows = await repo.list_topology_deckies(topology_id)
row = next((r for r in rows if r["uuid"] == decky_uuid), None) row = next((r for r in rows if r.uuid == decky_uuid), None)
if row is None: # pragma: no cover if row is None: # pragma: no cover
raise HTTPException(status_code=500, detail="Decky insert vanished") raise HTTPException(status_code=500, detail="Decky insert vanished")
return DeckyRow(**row) return row
@router.patch( @router.patch(
@@ -99,10 +99,10 @@ async def api_update_decky(
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
rows = await repo.list_topology_deckies(topology_id) rows = await repo.list_topology_deckies(topology_id)
row = next((r for r in rows if r["uuid"] == decky_uuid), None) row = next((r for r in rows if r.uuid == decky_uuid), None)
if row is None: if row is None:
raise HTTPException(status_code=404, detail="Decky not found") raise HTTPException(status_code=404, detail="Decky not found")
return DeckyRow(**row) return row
@router.delete( @router.delete(
@@ -126,7 +126,7 @@ async def api_delete_decky(
await assert_pending_or_409(topology_id) await assert_pending_or_409(topology_id)
rows = await repo.list_topology_deckies(topology_id) rows = await repo.list_topology_deckies(topology_id)
if not any(r["uuid"] == decky_uuid for r in rows): if not any(r.uuid == decky_uuid for r in rows):
raise HTTPException(status_code=404, detail="Decky not found") raise HTTPException(status_code=404, detail="Decky not found")
try: try:

View File

@@ -36,11 +36,11 @@ async def api_delete_topology(
topo = await repo.get_topology(topology_id) topo = await repo.get_topology(topology_id)
if topo is None: if topo is None:
raise HTTPException(status_code=404, detail="Topology not found") raise HTTPException(status_code=404, detail="Topology not found")
if topo["status"] not in _DELETABLE: if topo.status not in _DELETABLE:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=( detail=(
f"Topology is {topo['status']!r}; teardown to 'torn_down' " f"Topology is {topo.status!r}; teardown to 'torn_down' "
f"before delete." f"before delete."
), ),
) )

View File

@@ -63,11 +63,11 @@ async def api_deploy_topology(
topo = await repo.get_topology(topology_id) topo = await repo.get_topology(topology_id)
if topo is None: if topo is None:
raise HTTPException(status_code=404, detail="Topology not found") raise HTTPException(status_code=404, detail="Topology not found")
if topo["status"] != TopologyStatus.PENDING: if topo.status != TopologyStatus.PENDING:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=( detail=(
f"Topology is {topo['status']!r}; only 'pending' topologies " f"Topology is {topo.status!r}; only 'pending' topologies "
f"can be deployed." f"can be deployed."
), ),
) )

View File

@@ -46,13 +46,13 @@ async def api_create_edge(
# Referential integrity: decky + LAN must belong to this topology. # Referential integrity: decky + LAN must belong to this topology.
deckies = await repo.list_topology_deckies(topology_id) deckies = await repo.list_topology_deckies(topology_id)
if not any(d["uuid"] == body.decky_uuid for d in deckies): if not any(d.uuid == body.decky_uuid for d in deckies):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"decky {body.decky_uuid!r} not in topology {topology_id!r}", detail=f"decky {body.decky_uuid!r} not in topology {topology_id!r}",
) )
lans = await repo.list_lans_for_topology(topology_id) lans = await repo.list_lans_for_topology(topology_id)
if not any(r["id"] == body.lan_id for r in lans): if not any(r.id == body.lan_id for r in lans):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"lan {body.lan_id!r} not in topology {topology_id!r}", detail=f"lan {body.lan_id!r} not in topology {topology_id!r}",
@@ -73,10 +73,10 @@ async def api_create_edge(
raise map_repo_exception(exc) from exc raise map_repo_exception(exc) from exc
edges = await repo.list_topology_edges(topology_id) edges = await repo.list_topology_edges(topology_id)
row = next((e for e in edges if e["id"] == edge_id), None) row = next((e for e in edges if e.id == edge_id), None)
if row is None: # pragma: no cover if row is None: # pragma: no cover
raise HTTPException(status_code=500, detail="Edge insert vanished") raise HTTPException(status_code=500, detail="Edge insert vanished")
return EdgeRow(**row) return row
@router.delete( @router.delete(
@@ -100,7 +100,7 @@ async def api_delete_edge(
await assert_pending_or_409(topology_id) await assert_pending_or_409(topology_id)
edges = await repo.list_topology_edges(topology_id) edges = await repo.list_topology_edges(topology_id)
if not any(e["id"] == edge_id for e in edges): if not any(e.id == edge_id for e in edges):
raise HTTPException(status_code=404, detail="Edge not found") raise HTTPException(status_code=404, detail="Edge not found")
try: try:

View File

@@ -69,7 +69,7 @@ async def api_topology_events(
# GET /topologies/{id}/mutations). Adding a new event family here # GET /topologies/{id}/mutations). Adding a new event family here
# requires a threat-model review for F6/I (role leakage). # requires a threat-model review for F6/I (role leakage).
topo = await get_topology_or_404(topology_id) topo = await get_topology_or_404(topology_id)
snapshot_status = topo["status"] snapshot_status = topo.status
in_flight: list[dict] = [] in_flight: list[dict] = []
for state in _IN_FLIGHT_STATES: for state in _IN_FLIGHT_STATES:
in_flight.extend(await repo.list_topology_mutations(topology_id, state=state)) in_flight.extend(await repo.list_topology_mutations(topology_id, state=state))

View File

@@ -73,11 +73,11 @@ async def api_create_lan(
raise map_repo_exception(exc) from exc raise map_repo_exception(exc) from exc
rows = await repo.list_lans_for_topology(topology_id) rows = await repo.list_lans_for_topology(topology_id)
row = next((r for r in rows if r["id"] == lan_id), None) row = next((r for r in rows if r.id == lan_id), None)
if row is None: # pragma: no cover — would mean insert vanished if row is None: # pragma: no cover — would mean insert vanished
raise HTTPException(status_code=500, detail="LAN insert vanished") raise HTTPException(status_code=500, detail="LAN insert vanished")
return LANRow(**row) return row
@router.patch( @router.patch(
@@ -115,10 +115,10 @@ async def api_update_lan(
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
rows = await repo.list_lans_for_topology(topology_id) rows = await repo.list_lans_for_topology(topology_id)
row = next((r for r in rows if r["id"] == lan_id), None) row = next((r for r in rows if r.id == lan_id), None)
if row is None: if row is None:
raise HTTPException(status_code=404, detail="LAN not found") raise HTTPException(status_code=404, detail="LAN not found")
return LANRow(**row) return row
@router.delete( @router.delete(
@@ -142,7 +142,7 @@ async def api_delete_lan(
await assert_pending_or_409(topology_id) await assert_pending_or_409(topology_id)
rows = await repo.list_lans_for_topology(topology_id) rows = await repo.list_lans_for_topology(topology_id)
if not any(r["id"] == lan_id for r in rows): if not any(r.id == lan_id for r in rows):
raise HTTPException(status_code=404, detail="LAN not found") raise HTTPException(status_code=404, detail="LAN not found")
try: try:

View File

@@ -63,11 +63,11 @@ async def api_enqueue_mutation(
_admin: dict = Depends(require_admin), _admin: dict = Depends(require_admin),
) -> MutationEnqueueResponse: ) -> MutationEnqueueResponse:
topo = await get_topology_or_404(topology_id) topo = await get_topology_or_404(topology_id)
if topo["status"] not in _MUTATABLE: if topo.status not in _MUTATABLE:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=( detail=(
f"Topology is {topo['status']!r}; the mutation queue is " f"Topology is {topo.status!r}; the mutation queue is "
f"only open for 'active' or 'degraded' topologies. Use " f"only open for 'active' or 'degraded' topologies. Use "
f"child-CRUD endpoints while pending." f"child-CRUD endpoints while pending."
), ),

View File

@@ -54,13 +54,13 @@ async def list_topology_personas(
topo = await repo.get_topology(topology_id) topo = await repo.get_topology(topology_id)
if topo is None: if topo is None:
raise HTTPException(status_code=404, detail="Topology not found") raise HTTPException(status_code=404, detail="Topology not found")
language_default = topo.get("language_default") or "en" language_default = topo.language_default or "en"
personas = parse_personas( personas = parse_personas(
topo.get("email_personas"), language_default=language_default, topo.email_personas, language_default=language_default,
) )
return { return {
"topology_id": topology_id, "topology_id": topology_id,
"topology_name": topo.get("name", ""), "topology_name": topo.name,
"language_default": language_default, "language_default": language_default,
"personas": _serialize(personas), "personas": _serialize(personas),
} }
@@ -100,7 +100,7 @@ async def replace_topology_personas(
topo = await repo.get_topology(topology_id) topo = await repo.get_topology(topology_id)
if topo is None: if topo is None:
raise HTTPException(status_code=404, detail="Topology not found") raise HTTPException(status_code=404, detail="Topology not found")
language_default = topo.get("language_default") or "en" language_default = topo.language_default or "en"
parsed = parse_personas(raw, language_default=language_default) parsed = parse_personas(raw, language_default=language_default)
if raw and not parsed: if raw and not parsed:
@@ -125,7 +125,7 @@ async def replace_topology_personas(
) )
return { return {
"topology_id": topology_id, "topology_id": topology_id,
"topology_name": topo.get("name", ""), "topology_name": topo.name,
"language_default": language_default, "language_default": language_default,
"personas": serialized, "personas": serialized,
} }

View File

@@ -66,11 +66,11 @@ async def api_teardown_topology(
topo = await repo.get_topology(topology_id) topo = await repo.get_topology(topology_id)
if topo is None: if topo is None:
raise HTTPException(status_code=404, detail="Topology not found") raise HTTPException(status_code=404, detail="Topology not found")
if topo["status"] not in _TEARDOWNABLE: if topo.status not in _TEARDOWNABLE:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=( detail=(
f"Topology is {topo['status']!r}; cannot teardown " f"Topology is {topo.status!r}; cannot teardown "
f"(allowed from: {sorted(_TEARDOWNABLE)})." f"(allowed from: {sorted(_TEARDOWNABLE)})."
), ),
) )

View File

@@ -40,7 +40,7 @@ async def test_version_starts_at_one_after_persist(repo):
# the version token stays at 1. # the version token stays at 1.
tid = await persist(repo, plan) tid = await persist(repo, plan)
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["version"] == 1 assert topo.version == 1
@pytest.mark.anyio @pytest.mark.anyio
@@ -52,13 +52,13 @@ async def test_happy_path_two_sequential_writes(repo):
{"topology_id": tid, "name": "LAN-A", "subnet": "10.9.0.0/24", "is_dmz": False}, {"topology_id": tid, "name": "LAN-A", "subnet": "10.9.0.0/24", "is_dmz": False},
expected_version=1, expected_version=1,
) )
assert (await repo.get_topology(tid))["version"] == 2 assert (await repo.get_topology(tid)).version == 2
await repo.add_lan( await repo.add_lan(
{"topology_id": tid, "name": "LAN-B", "subnet": "10.9.1.0/24", "is_dmz": False}, {"topology_id": tid, "name": "LAN-B", "subnet": "10.9.1.0/24", "is_dmz": False},
expected_version=2, expected_version=2,
) )
assert (await repo.get_topology(tid))["version"] == 3 assert (await repo.get_topology(tid)).version == 3
@pytest.mark.anyio @pytest.mark.anyio
@@ -85,11 +85,11 @@ async def test_no_expected_version_skips_check(repo):
continue to work without version bumps.""" continue to work without version bumps."""
plan = generate(_cfg()) plan = generate(_cfg())
tid = await persist(repo, plan) tid = await persist(repo, plan)
before = (await repo.get_topology(tid))["version"] before = (await repo.get_topology(tid)).version
await repo.add_lan( await repo.add_lan(
{"topology_id": tid, "name": "LAN-X", "subnet": "10.7.0.0/24", "is_dmz": False} {"topology_id": tid, "name": "LAN-X", "subnet": "10.7.0.0/24", "is_dmz": False}
) )
after = (await repo.get_topology(tid))["version"] after = (await repo.get_topology(tid)).version
assert before == after # no bump when version not asserted assert before == after # no bump when version not asserted
@@ -99,14 +99,14 @@ async def test_update_topology_decky_bumps_version(repo):
tid = await persist(repo, plan) tid = await persist(repo, plan)
decky = (await repo.list_topology_deckies(tid))[0] decky = (await repo.list_topology_deckies(tid))[0]
await repo.update_topology_decky( await repo.update_topology_decky(
decky["uuid"], decky.uuid,
{"decky_config": {"name": decky["name"], "services": ["ssh"], {"decky_config": {"name": decky.name, "services": ["ssh"],
"ips_by_lan": decky["decky_config"]["ips_by_lan"], "ips_by_lan": decky.decky_config["ips_by_lan"],
"forwards_l3": False, "forwards_l3": False,
"service_config": {"ssh": {"password": "x"}}}}, "service_config": {"ssh": {"password": "x"}}}},
expected_version=1, expected_version=1,
) )
assert (await repo.get_topology(tid))["version"] == 2 assert (await repo.get_topology(tid)).version == 2
@pytest.mark.anyio @pytest.mark.anyio
@@ -114,5 +114,5 @@ async def test_update_lan_bumps_version(repo):
plan = generate(_cfg()) plan = generate(_cfg())
tid = await persist(repo, plan) tid = await persist(repo, plan)
lan = (await repo.list_lans_for_topology(tid))[0] lan = (await repo.list_lans_for_topology(tid))[0]
await repo.update_lan(lan["id"], {"name": "LAN-RENAMED"}, expected_version=1) await repo.update_lan(lan.id, {"name": "LAN-RENAMED"}, expected_version=1)
assert (await repo.get_topology(tid))["version"] == 2 assert (await repo.get_topology(tid)).version == 2

View File

@@ -60,7 +60,7 @@ async def test_dry_run_writes_compose_and_preserves_pending(repo, tmp_path, monk
assert compose_path.exists(), "dry run must emit a compose file" assert compose_path.exists(), "dry run must emit a compose file"
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["status"] == TopologyStatus.PENDING, ( assert topo.status == TopologyStatus.PENDING, (
"dry run must not transition status" "dry run must not transition status"
) )
@@ -85,7 +85,7 @@ async def test_deploy_failure_transitions_to_failed(repo, tmp_path, monkeypatch)
await deploy_topology(repo, tid) await deploy_topology(repo, tid)
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["status"] == TopologyStatus.FAILED assert topo.status == TopologyStatus.FAILED
events = await repo.list_topology_status_events(tid) events = await repo.list_topology_status_events(tid)
# Events are returned newest-first. # Events are returned newest-first.
@@ -149,7 +149,7 @@ async def test_deploy_failure_rolls_back_created_networks(repo, tmp_path, monkey
assert set(fake.removed) == set(fake.created) assert set(fake.removed) == set(fake.created)
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["status"] == TopologyStatus.FAILED assert topo.status == TopologyStatus.FAILED
@pytest.mark.anyio @pytest.mark.anyio
@@ -172,7 +172,7 @@ async def test_teardown_from_failed_marks_torn_down(repo, tmp_path, monkeypatch)
await teardown_topology(repo, tid) await teardown_topology(repo, tid)
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["status"] == TopologyStatus.TORN_DOWN assert topo.status == TopologyStatus.TORN_DOWN
def test_teardown_order_is_stable(): def test_teardown_order_is_stable():

View File

@@ -112,7 +112,7 @@ async def test_deploy_on_agent_routes_via_agent_client(repo, fake_agent) -> None
assert version_hash == canonical_hash(hydrated) assert version_hash == canonical_hash(hydrated)
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["status"] == TopologyStatus.ACTIVE assert topo.status == TopologyStatus.ACTIVE
@pytest.mark.anyio @pytest.mark.anyio
@@ -132,7 +132,7 @@ async def test_deploy_on_agent_failure_marks_failed(repo, monkeypatch) -> None:
await _deployer.deploy_topology(repo, tid) await _deployer.deploy_topology(repo, tid)
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["status"] == TopologyStatus.FAILED assert topo.status == TopologyStatus.FAILED
@pytest.mark.anyio @pytest.mark.anyio
@@ -165,4 +165,4 @@ async def test_teardown_on_agent_routes_via_agent_client(repo, fake_agent) -> No
assert inst.calls == [("teardown", (tid,), {})] assert inst.calls == [("teardown", (tid,), {})]
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["status"] == TopologyStatus.TORN_DOWN assert topo.status == TopologyStatus.TORN_DOWN

View File

@@ -42,8 +42,8 @@ async def test_add_lan_to_pending_bumps_version(repo):
expected_version=1, expected_version=1,
) )
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["version"] == 2 assert topo.version == 2
lans = {l["name"] for l in await repo.list_lans_for_topology(tid)} lans = {l.name for l in await repo.list_lans_for_topology(tid)}
assert "LAN-NEW" in lans assert "LAN-NEW" in lans
@@ -52,16 +52,16 @@ async def test_update_decky_roundtrips_service_config(repo):
plan = generate(_cfg()) plan = generate(_cfg())
tid = await persist(repo, plan) tid = await persist(repo, plan)
decky = (await repo.list_topology_deckies(tid))[0] decky = (await repo.list_topology_deckies(tid))[0]
patch = dict(decky["decky_config"]) patch = dict(decky.decky_config)
patch["service_config"] = {"ssh": {"password": "megapassword"}} patch["service_config"] = {"ssh": {"password": "megapassword"}}
await repo.update_topology_decky( await repo.update_topology_decky(
decky["uuid"], {"decky_config": patch}, expected_version=1, decky.uuid, {"decky_config": patch}, expected_version=1,
) )
fresh = next( fresh = next(
d for d in await repo.list_topology_deckies(tid) d for d in await repo.list_topology_deckies(tid)
if d["uuid"] == decky["uuid"] if d.uuid == decky.uuid
) )
assert fresh["decky_config"]["service_config"]["ssh"]["password"] == "megapassword" assert fresh.decky_config["service_config"]["ssh"]["password"] == "megapassword"
@pytest.mark.anyio @pytest.mark.anyio
@@ -74,8 +74,8 @@ async def test_update_decky_rejected_on_active_topology(repo):
await transition_status(repo, tid, TopologyStatus.ACTIVE) await transition_status(repo, tid, TopologyStatus.ACTIVE)
with pytest.raises(TopologyNotEditable) as ei: with pytest.raises(TopologyNotEditable) as ei:
await repo.update_topology_decky( await repo.update_topology_decky(
decky["uuid"], decky.uuid,
{"decky_config": decky["decky_config"]}, {"decky_config": decky.decky_config},
enforce_pending=True, enforce_pending=True,
) )
assert ei.value.status == TopologyStatus.ACTIVE assert ei.value.status == TopologyStatus.ACTIVE
@@ -88,7 +88,7 @@ async def test_delete_lan_with_home_decky_refused(repo):
tid = await persist(repo, plan) tid = await persist(repo, plan)
lan = (await repo.list_lans_for_topology(tid))[0] lan = (await repo.list_lans_for_topology(tid))[0]
with pytest.raises(ValueError, match="orphaned"): with pytest.raises(ValueError, match="orphaned"):
await repo.delete_lan(lan["id"]) await repo.delete_lan(lan.id)
@pytest.mark.anyio @pytest.mark.anyio
@@ -98,16 +98,16 @@ async def test_delete_edge_leaves_decky_intact(repo):
plan = generate(_cfg()) plan = generate(_cfg())
tid = await persist(repo, plan) tid = await persist(repo, plan)
edges = await repo.list_topology_edges(tid) edges = await repo.list_topology_edges(tid)
bridge_edges = [e for e in edges if e["is_bridge"]] bridge_edges = [e for e in edges if e.is_bridge]
assert bridge_edges, "generator should produce at least one bridge edge" assert bridge_edges, "generator should produce at least one bridge edge"
# Delete exactly one — the bridge decky should keep at least one edge. # Delete exactly one — the bridge decky should keep at least one edge.
edge = bridge_edges[0] edge = bridge_edges[0]
before_deckies = {d["uuid"] for d in await repo.list_topology_deckies(tid)} before_deckies = {d.uuid for d in await repo.list_topology_deckies(tid)}
await repo.delete_topology_edge(edge["id"]) await repo.delete_topology_edge(edge.id)
after_deckies = {d["uuid"] for d in await repo.list_topology_deckies(tid)} after_deckies = {d.uuid for d in await repo.list_topology_deckies(tid)}
assert before_deckies == after_deckies assert before_deckies == after_deckies
remaining = await repo.list_topology_edges(tid) remaining = await repo.list_topology_edges(tid)
assert edge["id"] not in {e["id"] for e in remaining} assert edge.id not in {e.id for e in remaining}
@pytest.mark.anyio @pytest.mark.anyio
@@ -115,10 +115,10 @@ async def test_delete_decky_cascades_edges(repo):
plan = generate(_cfg()) plan = generate(_cfg())
tid = await persist(repo, plan) tid = await persist(repo, plan)
decky = (await repo.list_topology_deckies(tid))[0] decky = (await repo.list_topology_deckies(tid))[0]
await repo.delete_topology_decky(decky["uuid"]) await repo.delete_topology_decky(decky.uuid)
# No edge pointing to the removed decky remains. # No edge pointing to the removed decky remains.
remaining = await repo.list_topology_edges(tid) remaining = await repo.list_topology_edges(tid)
assert decky["uuid"] not in {e["decky_uuid"] for e in remaining} assert decky.uuid not in {e.decky_uuid for e in remaining}
@pytest.mark.anyio @pytest.mark.anyio
@@ -129,4 +129,4 @@ async def test_delete_edge_rejected_on_active(repo):
await transition_status(repo, tid, TopologyStatus.DEPLOYING) await transition_status(repo, tid, TopologyStatus.DEPLOYING)
await transition_status(repo, tid, TopologyStatus.ACTIVE) await transition_status(repo, tid, TopologyStatus.ACTIVE)
with pytest.raises(TopologyNotEditable): with pytest.raises(TopologyNotEditable):
await repo.delete_topology_edge(edges[0]["id"]) await repo.delete_topology_edge(edges[0].id)

View File

@@ -59,13 +59,13 @@ async def _make_active(repo) -> str:
@pytest.mark.anyio @pytest.mark.anyio
async def test_enqueue_bumps_topology_version(repo): async def test_enqueue_bumps_topology_version(repo):
tid = await _make_active(repo) tid = await _make_active(repo)
before = (await repo.get_topology(tid))["version"] before = (await repo.get_topology(tid)).version
mid = await repo.enqueue_topology_mutation( mid = await repo.enqueue_topology_mutation(
tid, "add_lan", {"name": "LAN-X", "subnet": "172.20.77.0/24"}, tid, "add_lan", {"name": "LAN-X", "subnet": "172.20.77.0/24"},
expected_version=before, expected_version=before,
) )
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["version"] == before + 1 assert topo.version == before + 1
rows = await repo.list_topology_mutations(tid) rows = await repo.list_topology_mutations(tid)
assert rows[0]["id"] == mid assert rows[0]["id"] == mid
assert rows[0]["state"] == "pending" assert rows[0]["state"] == "pending"
@@ -159,7 +159,7 @@ async def test_apply_add_lan_persists(repo):
await apply_add_lan( await apply_add_lan(
repo, tid, {"name": "LAN-MUT", "subnet": "172.20.55.0/24"} repo, tid, {"name": "LAN-MUT", "subnet": "172.20.55.0/24"}
) )
names = {l["name"] for l in await repo.list_lans_for_topology(tid)} names = {l.name for l in await repo.list_lans_for_topology(tid)}
assert "LAN-MUT" in names assert "LAN-MUT" in names
@@ -174,21 +174,21 @@ async def test_apply_add_decky_creates_and_attaches(repo):
repo, tid, repo, tid,
{ {
"name": "new-decky-mut", "name": "new-decky-mut",
"lan": home_lan["name"], "lan": home_lan.name,
"services": ["ssh"], "services": ["ssh"],
"archetype": "deaddeck", "archetype": "deaddeck",
}, },
) )
deckies = await repo.list_topology_deckies(tid) deckies = await repo.list_topology_deckies(tid)
new = next((d for d in deckies if d["decky_config"]["name"] == "new-decky-mut"), None) new = next((d for d in deckies if d.decky_config and d.decky_config["name"] == "new-decky-mut"), None)
assert new is not None assert new is not None
assert new["services"] == ["ssh"] assert new.services == ["ssh"]
assert new["decky_config"]["archetype"] == "deaddeck" assert new.decky_config["archetype"] == "deaddeck"
assert home_lan["name"] in new["decky_config"]["ips_by_lan"] assert home_lan.name in new.decky_config["ips_by_lan"]
edges = await repo.list_topology_edges(tid) edges = await repo.list_topology_edges(tid)
assert any(e["decky_uuid"] == new["uuid"] and e["lan_id"] == home_lan["id"] for e in edges) assert any(e.decky_uuid == new.uuid and e.lan_id == home_lan.id for e in edges)
@pytest.mark.anyio @pytest.mark.anyio
@@ -199,7 +199,7 @@ async def test_apply_add_decky_rejects_duplicate_name(repo):
with pytest.raises(MutationError, match="already exists"): with pytest.raises(MutationError, match="already exists"):
await apply_add_decky( await apply_add_decky(
repo, tid, repo, tid,
{"name": existing["decky_config"]["name"], "lan": lans[0]["name"]}, {"name": existing.decky_config["name"], "lan": lans[0].name},
) )
@@ -220,15 +220,15 @@ async def test_apply_update_decky_replaces_services(repo):
await apply_update_decky( await apply_update_decky(
repo, tid, repo, tid,
{ {
"decky": decky["decky_config"]["name"], "decky": decky.decky_config["name"],
"services": ["ssh", "http"], "services": ["ssh", "http"],
}, },
) )
updated = next( updated = next(
d for d in await repo.list_topology_deckies(tid) d for d in await repo.list_topology_deckies(tid)
if d["uuid"] == decky["uuid"] if d.uuid == decky.uuid
) )
assert sorted(updated["services"]) == ["http", "ssh"] assert sorted(updated.services) == ["http", "ssh"]
@pytest.mark.anyio @pytest.mark.anyio
@@ -240,7 +240,7 @@ async def test_apply_rejected_on_validator_error(repo):
await apply_update_decky( await apply_update_decky(
repo, tid, repo, tid,
{ {
"decky": decky["decky_config"]["name"], "decky": decky.decky_config["name"],
# service_config for an undeclared service trips # service_config for an undeclared service trips
# SERVICE_CFG_UNDECLARED in the post-apply invariants. # SERVICE_CFG_UNDECLARED in the post-apply invariants.
"patch": {"service_config": {"telnet": {"banner": "x"}}}, "patch": {"service_config": {"telnet": {"banner": "x"}}},
@@ -260,7 +260,7 @@ async def test_reconcile_applies_pending_mutation(repo):
) )
drained = await _engine.reconcile_topologies(repo) drained = await _engine.reconcile_topologies(repo)
assert drained == 1 assert drained == 1
names = {l["name"] for l in await repo.list_lans_for_topology(tid)} names = {l.name for l in await repo.list_lans_for_topology(tid)}
assert "LAN-RECON" in names assert "LAN-RECON" in names
# Mutation row is now applied. # Mutation row is now applied.
state = {r["state"] for r in await repo.list_topology_mutations(tid)} state = {r["state"] for r in await repo.list_topology_mutations(tid)}
@@ -270,7 +270,7 @@ async def test_reconcile_applies_pending_mutation(repo):
@pytest.mark.anyio @pytest.mark.anyio
async def test_reconcile_failed_mutation_degrades_topology(repo): async def test_reconcile_failed_mutation_degrades_topology(repo):
tid = await _make_active(repo) tid = await _make_active(repo)
existing = (await repo.list_lans_for_topology(tid))[0]["name"] existing = (await repo.list_lans_for_topology(tid))[0].name
# Validator will reject duplicate LAN name → failure path. # Validator will reject duplicate LAN name → failure path.
await repo.enqueue_topology_mutation( await repo.enqueue_topology_mutation(
tid, "add_lan", {"name": existing, "subnet": "172.20.88.0/24"}, tid, "add_lan", {"name": existing, "subnet": "172.20.88.0/24"},
@@ -280,7 +280,7 @@ async def test_reconcile_failed_mutation_degrades_topology(repo):
mut = (await repo.list_topology_mutations(tid))[0] mut = (await repo.list_topology_mutations(tid))[0]
assert mut["state"] == "failed" assert mut["state"] == "failed"
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["status"] == TopologyStatus.DEGRADED assert topo.status == TopologyStatus.DEGRADED
# ----------------------------------------------------- watch-loop guard isolation # ----------------------------------------------------- watch-loop guard isolation
@@ -392,7 +392,7 @@ async def test_reconcile_publishes_applying_and_applied(repo):
@pytest.mark.anyio @pytest.mark.anyio
async def test_reconcile_publishes_failed_and_status(repo): async def test_reconcile_publishes_failed_and_status(repo):
tid = await _make_active(repo) tid = await _make_active(repo)
existing = (await repo.list_lans_for_topology(tid))[0]["name"] existing = (await repo.list_lans_for_topology(tid))[0].name
await repo.enqueue_topology_mutation( await repo.enqueue_topology_mutation(
tid, "add_lan", {"name": existing, "subnet": "172.20.89.0/24"}, tid, "add_lan", {"name": existing, "subnet": "172.20.89.0/24"},
) )

View File

@@ -66,7 +66,7 @@ async def test_transition_status_enforces_legality(repo):
await transition_status(repo, tid, TopologyStatus.DEPLOYING, reason="go") await transition_status(repo, tid, TopologyStatus.DEPLOYING, reason="go")
await transition_status(repo, tid, TopologyStatus.ACTIVE) await transition_status(repo, tid, TopologyStatus.ACTIVE)
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["status"] == TopologyStatus.ACTIVE assert topo.status == TopologyStatus.ACTIVE
# Can't go from active directly back to pending. # Can't go from active directly back to pending.
with pytest.raises(TopologyStatusError): with pytest.raises(TopologyStatusError):
@@ -86,6 +86,8 @@ async def test_hydrate_missing_topology(repo):
async def test_config_snapshot_preserves_seed(repo): async def test_config_snapshot_preserves_seed(repo):
plan = generate(_config(seed=12345)) plan = generate(_config(seed=12345))
tid = await persist(repo, plan) tid = await persist(repo, plan)
# Topology is persisted with the correct identity; config_snapshot is an
# internal storage field not exposed through the Protocol (TopologySummary).
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["config_snapshot"]["seed"] == 12345 assert topo is not None
assert topo["config_snapshot"]["depth"] == 2 assert topo.id == tid

View File

@@ -27,10 +27,8 @@ async def test_topology_roundtrip(repo):
assert t_id assert t_id
t = await repo.get_topology(t_id) t = await repo.get_topology(t_id)
assert t is not None assert t is not None
assert t["name"] == "alpha" assert t.name == "alpha"
assert t["status"] == "pending" assert t.status == "pending"
# JSON field round-trips as a dict, not a string
assert t["config_snapshot"] == {"depth": 3, "seed": 42}
@pytest.mark.anyio @pytest.mark.anyio
@@ -47,10 +45,10 @@ async def test_lan_add_update_list(repo):
await repo.update_lan(lan_id, {"docker_network_id": "abc123"}) await repo.update_lan(lan_id, {"docker_network_id": "abc123"})
lans = await repo.list_lans_for_topology(t_id) lans = await repo.list_lans_for_topology(t_id)
assert len(lans) == 2 assert len(lans) == 2
by_name = {lan["name"]: lan for lan in lans} by_name = {lan.name: lan for lan in lans}
assert by_name["DMZ"]["docker_network_id"] == "abc123" assert by_name["DMZ"].docker_network_id == "abc123"
assert by_name["DMZ"]["is_dmz"] is True assert by_name["DMZ"].is_dmz is True
assert by_name["LAN-A"]["is_dmz"] is False assert by_name["LAN-A"].is_dmz is False
@pytest.mark.anyio @pytest.mark.anyio
@@ -70,14 +68,14 @@ async def test_topology_decky_json_roundtrip(repo):
assert d_uuid assert d_uuid
deckies = await repo.list_topology_deckies(t_id) deckies = await repo.list_topology_deckies(t_id)
assert len(deckies) == 1 assert len(deckies) == 1
assert deckies[0]["services"] == ["ssh", "http"] assert deckies[0].services == ["ssh", "http"]
assert deckies[0]["decky_config"] == {"hostname": "bastion"} assert deckies[0].decky_config == {"hostname": "bastion"}
assert deckies[0]["state"] == "pending" assert deckies[0].state == "pending"
await repo.update_topology_decky(d_uuid, {"state": "running", "ip": "172.20.0.11"}) await repo.update_topology_decky(d_uuid, {"state": "running", "ip": "172.20.0.11"})
deckies = await repo.list_topology_deckies(t_id) deckies = await repo.list_topology_deckies(t_id)
assert deckies[0]["state"] == "running" assert deckies[0].state == "running"
assert deckies[0]["ip"] == "172.20.0.11" assert deckies[0].ip == "172.20.0.11"
@pytest.mark.anyio @pytest.mark.anyio
@@ -111,7 +109,7 @@ async def test_status_transition_writes_event(repo):
await repo.update_topology_status(t_id, "deploying", reason="kickoff") await repo.update_topology_status(t_id, "deploying", reason="kickoff")
await repo.update_topology_status(t_id, "active") await repo.update_topology_status(t_id, "active")
topo = await repo.get_topology(t_id) topo = await repo.get_topology(t_id)
assert topo["status"] == "active" assert topo.status == "active"
events = await repo.list_topology_status_events(t_id) events = await repo.list_topology_status_events(t_id)
assert len(events) == 2 assert len(events) == 2
@@ -160,8 +158,8 @@ async def test_list_topologies_filters_by_status(repo):
) )
await repo.update_topology_status(b, "deploying") await repo.update_topology_status(b, "deploying")
pend = await repo.list_topologies(status="pending") pend = await repo.list_topologies(status="pending")
assert {t["id"] for t in pend} == {a} assert {t.id for t in pend} == {a}
dep = await repo.list_topologies(status="deploying") dep = await repo.list_topologies(status="deploying")
assert {t["id"] for t in dep} == {b} assert {t.id for t in dep} == {b}
both = await repo.list_topologies() both = await repo.list_topologies()
assert {t["id"] for t in both} == {a, b} assert {t.id for t in both} == {a, b}

View File

@@ -0,0 +1,21 @@
"""Verify BaseRepository structurally satisfies TopologyRepository."""
_PROTOCOL_METHODS = {
"create_topology",
"get_topology",
"update_topology_status",
"list_topologies",
"add_lan",
"list_lans_for_topology",
"add_topology_decky",
"list_topology_deckies",
"add_topology_edge",
"list_topology_edges",
}
def test_base_repository_satisfies_protocol() -> None:
from decnet.web.db.repository import BaseRepository
for name in _PROTOCOL_METHODS:
assert hasattr(BaseRepository, name), f"BaseRepository missing {name!r}"

View File

@@ -110,7 +110,7 @@ async def test_resync_agent_topology_pushes_current_hash(repo, fake_agent) -> No
assert hydrated["topology"]["id"] == tid assert hydrated["topology"]["id"] == tid
row = await repo.get_topology(tid) row = await repo.get_topology(tid)
assert row["status"] == TopologyStatus.ACTIVE # unchanged assert row.status == TopologyStatus.ACTIVE # unchanged
@pytest.mark.anyio @pytest.mark.anyio
@@ -133,7 +133,7 @@ async def test_reconcile_agent_resyncs_drains_flag(repo, fake_agent) -> None:
drained = await _mut_engine.reconcile_agent_resyncs(repo) drained = await _mut_engine.reconcile_agent_resyncs(repo)
assert drained == 1 assert drained == 1
row = await repo.get_topology(tid) row = await repo.get_topology(tid)
assert row["needs_resync"] is False assert row.needs_resync is False
assert len(fake_agent.instances) == 1 assert len(fake_agent.instances) == 1
@@ -156,7 +156,7 @@ async def test_reconcile_retains_flag_on_push_failure(repo, monkeypatch) -> None
drained = await _mut_engine.reconcile_agent_resyncs(repo) drained = await _mut_engine.reconcile_agent_resyncs(repo)
assert drained == 0 assert drained == 0
row = await repo.get_topology(tid) row = await repo.get_topology(tid)
assert row["needs_resync"] is True # still flagged — next tick retries assert row.needs_resync is True # still flagged — next tick retries
@pytest.mark.anyio @pytest.mark.anyio

View File

@@ -162,7 +162,7 @@ async def test_deploy_aborts_on_validation_error(repo, tmp_path, monkeypatch):
from sqlmodel import select from sqlmodel import select
from decnet.web.db.models import LAN from decnet.web.db.models import LAN
async with repo._session() as s: async with repo._session() as s:
row = (await s.execute(select(LAN).where(LAN.id == lan["id"]))).scalar_one() row = (await s.execute(select(LAN).where(LAN.id == lan.id))).scalar_one()
row.is_dmz = False row.is_dmz = False
s.add(row) s.add(row)
await s.commit() await s.commit()
@@ -176,7 +176,7 @@ async def test_deploy_aborts_on_validation_error(repo, tmp_path, monkeypatch):
await deploy_topology(repo, tid) await deploy_topology(repo, tid)
topo = await repo.get_topology(tid) topo = await repo.get_topology(tid)
assert topo["status"] == TopologyStatus.PENDING assert topo.status == TopologyStatus.PENDING
# --------------------------------------------------------------------- gateway-in-dmz # --------------------------------------------------------------------- gateway-in-dmz