fix(tests+mutator): unbreak the docker-shadow test env + let mutator delete from active
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.
This commit is contained in:
@@ -558,7 +558,11 @@ async def apply_remove_lan(
|
|||||||
f"{d['decky_config']['name']!r}; remove the decky first"
|
f"{d['decky_config']['name']!r}; remove the decky first"
|
||||||
)
|
)
|
||||||
lan_name = lan["name"]
|
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
|
# Live materialisation symmetric to apply_add_lan: tear down the
|
||||||
# docker bridge and re-render compose so a future redeploy doesn't
|
# 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(
|
await repo.update_topology_decky(
|
||||||
decky["uuid"], {"decky_config": new_cfg}
|
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
|
# Live materialisation: SDK network.disconnect on the base
|
||||||
# container. Service containers automatically lose visibility into
|
# container. Service containers automatically lose visibility into
|
||||||
# the LAN because they share the base's netns.
|
# 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")
|
raise MutationError(f"decky {payload['decky']!r} not found")
|
||||||
decky_name = decky["decky_config"]["name"]
|
decky_name = decky["decky_config"]["name"]
|
||||||
services_list = list(decky.get("services") or [])
|
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
|
# Live materialisation: stop + rm -f the decky's containers. We
|
||||||
# capture decky_name + services BEFORE the delete so the helper
|
# capture decky_name + services BEFORE the delete so the helper
|
||||||
# has the targets even though the row is gone.
|
# has the targets even though the row is gone.
|
||||||
|
|||||||
@@ -787,17 +787,29 @@ class BaseRepository(ABC):
|
|||||||
# -------------------- pre-deploy (pending-only) mutations --------------------
|
# -------------------- pre-deploy (pending-only) mutations --------------------
|
||||||
|
|
||||||
async def delete_lan(
|
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:
|
) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def delete_topology_decky(
|
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:
|
) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def delete_topology_edge(
|
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:
|
) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|||||||
@@ -72,8 +72,19 @@ class TopologyDeckiesMixin:
|
|||||||
decky_uuid: str,
|
decky_uuid: str,
|
||||||
*,
|
*,
|
||||||
expected_version: Optional[int] = None,
|
expected_version: Optional[int] = None,
|
||||||
|
enforce_pending: bool = True,
|
||||||
) -> None:
|
) -> 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:
|
async with self._session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(TopologyDecky).where(TopologyDecky.uuid == decky_uuid)
|
select(TopologyDecky).where(TopologyDecky.uuid == decky_uuid)
|
||||||
@@ -81,7 +92,8 @@ class TopologyDeckiesMixin:
|
|||||||
d = result.scalar_one_or_none()
|
d = result.scalar_one_or_none()
|
||||||
if d is None:
|
if d is None:
|
||||||
return
|
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:
|
if expected_version is not None:
|
||||||
await self._check_and_bump_version(
|
await self._check_and_bump_version(
|
||||||
session, d.topology_id, expected_version
|
session, d.topology_id, expected_version
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ class TopologyEdgesMixin:
|
|||||||
edge_id: str,
|
edge_id: str,
|
||||||
*,
|
*,
|
||||||
expected_version: Optional[int] = None,
|
expected_version: Optional[int] = None,
|
||||||
|
enforce_pending: bool = True,
|
||||||
) -> None:
|
) -> 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:
|
async with self._session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(TopologyEdge).where(TopologyEdge.id == edge_id)
|
select(TopologyEdge).where(TopologyEdge.id == edge_id)
|
||||||
@@ -41,7 +46,8 @@ class TopologyEdgesMixin:
|
|||||||
edge = result.scalar_one_or_none()
|
edge = result.scalar_one_or_none()
|
||||||
if edge is None:
|
if edge is None:
|
||||||
return
|
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:
|
if expected_version is not None:
|
||||||
await self._check_and_bump_version(
|
await self._check_and_bump_version(
|
||||||
session, edge.topology_id, expected_version
|
session, edge.topology_id, expected_version
|
||||||
|
|||||||
@@ -61,12 +61,18 @@ class LansMixin:
|
|||||||
lan_id: str,
|
lan_id: str,
|
||||||
*,
|
*,
|
||||||
expected_version: Optional[int] = None,
|
expected_version: Optional[int] = None,
|
||||||
|
enforce_pending: bool = True,
|
||||||
) -> None:
|
) -> 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
|
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
|
non-bridge edge to it — the only LAN that decky lives in). The
|
||||||
caller must delete or reassign the home-deckies first.
|
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
|
from decnet.topology.status import TopologyNotEditable # noqa: F401
|
||||||
|
|
||||||
@@ -75,7 +81,8 @@ class LansMixin:
|
|||||||
lan = result.scalar_one_or_none()
|
lan = result.scalar_one_or_none()
|
||||||
if lan is None:
|
if lan is None:
|
||||||
return
|
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?
|
# Home-decky check: any decky whose only edge lands here?
|
||||||
edges_result = await session.execute(
|
edges_result = await session.execute(
|
||||||
|
|||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Reference in New Issue
Block a user