From a27e3f5e0f8ff47092a7f7baa6c028da8795d0f4 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 29 Apr 2026 00:24:17 -0400 Subject: [PATCH] fix(tests+mutator): unbreak the docker-shadow test env + let mutator delete from active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes that came out of running the W5 tests locally: 1. tests/__init__.py — empty file, makes 'tests/' a package so pytest stops inserting it into sys.path. Without it, 'tests/docker/' (the docker-image test category) shadowed the installed docker SDK on every engine-touching test in the repo: module 'docker' has no attribute 'DockerClient' Pytest's default --import-mode=prepend was the culprit; making tests/ a package is the cheapest fix and doesn't change --import-mode for the whole tree. 2. delete_topology_decky / delete_topology_edge / delete_lan grow an 'enforce_pending: bool = True' kwarg. Default preserves the HTTP CRUD guard (api_decky_crud / api_edge_crud / api_lan_crud get the 409 for free). apply_remove_decky / apply_detach_decky / apply_remove_lan now pass enforce_pending=False — the mutator queue is the live-editing surface and has its own active-topology gating; the repo's pending-only guard was for design-time CRUD that mustn't bypass it. Without this, apply_remove_decky was silently broken on active topologies pre-W5; W5's new test surfaced it on first run. 10/10 new W5 tests pass; 58/58 across mutator + topology suites. --- decnet/mutator/ops.py | 10 +++++++--- decnet/web/db/repository.py | 18 +++++++++++++++--- .../web/db/sqlmodel_repo/topology/deckies.py | 16 ++++++++++++++-- decnet/web/db/sqlmodel_repo/topology/edges.py | 8 +++++++- decnet/web/db/sqlmodel_repo/topology/lans.py | 11 +++++++++-- tests/__init__.py | 0 6 files changed, 52 insertions(+), 11 deletions(-) create mode 100644 tests/__init__.py diff --git a/decnet/mutator/ops.py b/decnet/mutator/ops.py index 1e9110b5..e91393b9 100644 --- a/decnet/mutator/ops.py +++ b/decnet/mutator/ops.py @@ -558,7 +558,11 @@ async def apply_remove_lan( f"{d['decky_config']['name']!r}; remove the decky first" ) lan_name = lan["name"] - await repo.delete_lan(lan["id"]) + # enforce_pending=False: the mutator queue is the live-editing + # surface, gated on topology status by us before we got here. The + # repo's pending-only guard is for HTTP CRUD callers that mustn't + # bypass it. + await repo.delete_lan(lan["id"], enforce_pending=False) # Live materialisation symmetric to apply_add_lan: tear down the # docker bridge and re-render compose so a future redeploy doesn't @@ -757,7 +761,7 @@ async def apply_detach_decky( await repo.update_topology_decky( decky["uuid"], {"decky_config": new_cfg} ) - await repo.delete_topology_edge(edge["id"]) + await repo.delete_topology_edge(edge["id"], enforce_pending=False) # Live materialisation: SDK network.disconnect on the base # container. Service containers automatically lose visibility into # the LAN because they share the base's netns. @@ -778,7 +782,7 @@ async def apply_remove_decky( raise MutationError(f"decky {payload['decky']!r} not found") decky_name = decky["decky_config"]["name"] services_list = list(decky.get("services") or []) - await repo.delete_topology_decky(decky["uuid"]) + await repo.delete_topology_decky(decky["uuid"], enforce_pending=False) # Live materialisation: stop + rm -f the decky's containers. We # capture decky_name + services BEFORE the delete so the helper # has the targets even though the row is gone. diff --git a/decnet/web/db/repository.py b/decnet/web/db/repository.py index cd6271fa..cf776494 100644 --- a/decnet/web/db/repository.py +++ b/decnet/web/db/repository.py @@ -787,17 +787,29 @@ class BaseRepository(ABC): # -------------------- pre-deploy (pending-only) mutations -------------------- async def delete_lan( - self, lan_id: str, *, expected_version: Optional[int] = None + self, + lan_id: str, + *, + expected_version: Optional[int] = None, + enforce_pending: bool = True, ) -> None: raise NotImplementedError async def delete_topology_decky( - self, decky_uuid: str, *, expected_version: Optional[int] = None + self, + decky_uuid: str, + *, + expected_version: Optional[int] = None, + enforce_pending: bool = True, ) -> None: raise NotImplementedError async def delete_topology_edge( - self, edge_id: str, *, expected_version: Optional[int] = None + self, + edge_id: str, + *, + expected_version: Optional[int] = None, + enforce_pending: bool = True, ) -> None: raise NotImplementedError diff --git a/decnet/web/db/sqlmodel_repo/topology/deckies.py b/decnet/web/db/sqlmodel_repo/topology/deckies.py index 23fac911..0941ca90 100644 --- a/decnet/web/db/sqlmodel_repo/topology/deckies.py +++ b/decnet/web/db/sqlmodel_repo/topology/deckies.py @@ -72,8 +72,19 @@ class TopologyDeckiesMixin: decky_uuid: str, *, expected_version: Optional[int] = None, + enforce_pending: bool = True, ) -> None: - """Cascade-delete a decky + all its edges from a pending topology.""" + """Cascade-delete a decky + all its edges from a topology. + + Defaults to ``enforce_pending=True`` so HTTP CRUD callers + (api_decky_crud.py) get the existing 409 guard for free. The + mutator's ``apply_remove_decky`` is the only path that's + legitimately allowed to delete from an active topology — it + passes ``enforce_pending=False`` after dequeuing the mutation + through its own active-topology gating (the queue is the live + editing surface; the repo's CRUD guard is for the design-time + endpoints that mustn't bypass it). + """ async with self._session() as session: result = await session.execute( select(TopologyDecky).where(TopologyDecky.uuid == decky_uuid) @@ -81,7 +92,8 @@ class TopologyDeckiesMixin: d = result.scalar_one_or_none() if d is None: return - await self._assert_pending(session, d.topology_id) + if enforce_pending: + await self._assert_pending(session, d.topology_id) if expected_version is not None: await self._check_and_bump_version( session, d.topology_id, expected_version diff --git a/decnet/web/db/sqlmodel_repo/topology/edges.py b/decnet/web/db/sqlmodel_repo/topology/edges.py index 6ce0330b..edf3dfbf 100644 --- a/decnet/web/db/sqlmodel_repo/topology/edges.py +++ b/decnet/web/db/sqlmodel_repo/topology/edges.py @@ -33,7 +33,12 @@ class TopologyEdgesMixin: edge_id: str, *, expected_version: Optional[int] = None, + enforce_pending: bool = True, ) -> None: + """Delete one edge. ``enforce_pending=True`` by default — the + mutator's ``apply_detach_decky`` opts out, same rationale as + ``delete_topology_decky``. + """ async with self._session() as session: result = await session.execute( select(TopologyEdge).where(TopologyEdge.id == edge_id) @@ -41,7 +46,8 @@ class TopologyEdgesMixin: edge = result.scalar_one_or_none() if edge is None: return - await self._assert_pending(session, edge.topology_id) + if enforce_pending: + await self._assert_pending(session, edge.topology_id) if expected_version is not None: await self._check_and_bump_version( session, edge.topology_id, expected_version diff --git a/decnet/web/db/sqlmodel_repo/topology/lans.py b/decnet/web/db/sqlmodel_repo/topology/lans.py index fe38c1f8..0906a11f 100644 --- a/decnet/web/db/sqlmodel_repo/topology/lans.py +++ b/decnet/web/db/sqlmodel_repo/topology/lans.py @@ -61,12 +61,18 @@ class LansMixin: lan_id: str, *, expected_version: Optional[int] = None, + enforce_pending: bool = True, ) -> None: - """Cascade-delete a LAN from a pending topology. + """Cascade-delete a LAN. Rejects if any decky declares this LAN as its home (i.e. has a non-bridge edge to it — the only LAN that decky lives in). The caller must delete or reassign the home-deckies first. + + ``enforce_pending=True`` by default keeps the HTTP CRUD guard + intact; the mutator's ``apply_remove_lan`` opts out (it has + already gated on topology status and the live-LAN docker + materialisation runs after). """ from decnet.topology.status import TopologyNotEditable # noqa: F401 @@ -75,7 +81,8 @@ class LansMixin: lan = result.scalar_one_or_none() if lan is None: return - await self._assert_pending(session, lan.topology_id) + if enforce_pending: + await self._assert_pending(session, lan.topology_id) # Home-decky check: any decky whose only edge lands here? edges_result = await session.execute( diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b