diff --git a/decnet/web/db/models/topology.py b/decnet/web/db/models/topology.py index 20e8423d..913d610e 100644 --- a/decnet/web/db/models/topology.py +++ b/decnet/web/db/models/topology.py @@ -59,6 +59,13 @@ class LAN(SQLModel, table=True): docker_network_id: Optional[str] = Field(default=None) subnet: str is_dmz: bool = Field(default=False) + # Per-LAN swarm host pin. ``None`` means "fall back to + # ``Topology.target_host_uuid``; if that is also None, deploy on the + # master." A LAN is one Docker bridge — bridges don't span hosts — + # so a non-null value forces every decky in this LAN onto that host. + host_uuid: Optional[str] = Field( + default=None, foreign_key="swarm_hosts.uuid", index=True + ) # Canvas layout coordinates (set by the web editor). Nullable so # generator-emitted LANs don't need auto-layout at generation time. x: Optional[float] = Field(default=None) @@ -225,6 +232,7 @@ class LANRow(BaseModel): subnet: str is_dmz: bool = False docker_network_id: Optional[str] = None + host_uuid: Optional[str] = None x: Optional[float] = None y: Optional[float] = None @@ -280,6 +288,7 @@ class LANCreateRequest(BaseModel): name: str = PydanticField(..., min_length=1, max_length=64) subnet: Optional[str] = None is_dmz: bool = False + host_uuid: Optional[str] = None x: Optional[float] = None y: Optional[float] = None expected_version: Optional[int] = None @@ -289,6 +298,7 @@ class LANUpdateRequest(BaseModel): name: Optional[str] = None subnet: Optional[str] = None is_dmz: Optional[bool] = None + host_uuid: Optional[str] = None x: Optional[float] = None y: Optional[float] = None expected_version: Optional[int] = None diff --git a/decnet/web/router/topology/api_lan_crud.py b/decnet/web/router/topology/api_lan_crud.py index ae15d394..60eb8ef8 100644 --- a/decnet/web/router/topology/api_lan_crud.py +++ b/decnet/web/router/topology/api_lan_crud.py @@ -57,11 +57,20 @@ async def api_create_lan( ) subnet = allocator.next_free() + if body.host_uuid is not None: + host = await repo.get_swarm_host_by_uuid(body.host_uuid) + if host is None: + raise HTTPException( + status_code=400, + detail=f"swarm host {body.host_uuid!r} not found", + ) + payload = { "topology_id": topology_id, "name": body.name, "subnet": subnet, "is_dmz": body.is_dmz, + "host_uuid": body.host_uuid, "x": body.x, "y": body.y, } @@ -102,6 +111,13 @@ async def api_update_lan( await assert_pending_or_409(topology_id) fields = body.model_dump(exclude_unset=True, exclude={"expected_version"}) + if "host_uuid" in fields and fields["host_uuid"] is not None: + host = await repo.get_swarm_host_by_uuid(fields["host_uuid"]) + if host is None: + raise HTTPException( + status_code=400, + detail=f"swarm host {fields['host_uuid']!r} not found", + ) try: await repo.update_lan( lan_id, diff --git a/tests/api/topology/test_child_crud.py b/tests/api/topology/test_child_crud.py index b8c36375..1cee11ae 100644 --- a/tests/api/topology/test_child_crud.py +++ b/tests/api/topology/test_child_crud.py @@ -111,6 +111,78 @@ async def test_lan_requires_admin(client, viewer_token): assert r.status_code == 403 +# ── LAN host_uuid (per-Net SWARM assignment) ────────────────────── + + +async def _enroll_host(uuid: str = "h-test", name: str = "test-host") -> str: + await _repo.add_swarm_host( + { + "uuid": uuid, + "name": name, + "address": "10.99.0.2", + "agent_port": 8765, + "status": "active", + "client_cert_fingerprint": "a" * 64, + "cert_bundle_path": "/tmp/test", + } + ) + return uuid + + +@pytest.mark.anyio +async def test_lan_create_with_host_uuid(client, auth_token): + topology_id = await _seed("lan-host-create") + host_uuid = await _enroll_host("h-create", "host-create") + r = await client.post( + f"{_V1}/{topology_id}/lans", + json={"name": "remote-lan", "host_uuid": host_uuid}, + headers=_hdr(auth_token), + ) + assert r.status_code == 201, r.text + assert r.json()["host_uuid"] == host_uuid + + +@pytest.mark.anyio +async def test_lan_create_rejects_unknown_host(client, auth_token): + topology_id = await _seed("lan-host-bad") + r = await client.post( + f"{_V1}/{topology_id}/lans", + json={"name": "ghost-lan", "host_uuid": "ghost-uuid"}, + headers=_hdr(auth_token), + ) + assert r.status_code == 400 + + +@pytest.mark.anyio +async def test_lan_patch_host_uuid(client, auth_token): + topology_id = await _seed("lan-host-patch") + host_uuid = await _enroll_host("h-patch", "host-patch") + lans = await _repo.list_lans_for_topology(topology_id) + lan_id = lans[0]["id"] + + r = await client.patch( + f"{_V1}/{topology_id}/lans/{lan_id}", + json={"host_uuid": host_uuid}, + headers=_hdr(auth_token), + ) + assert r.status_code == 200, r.text + assert r.json()["host_uuid"] == host_uuid + + +@pytest.mark.anyio +async def test_lan_patch_rejects_unknown_host(client, auth_token): + topology_id = await _seed("lan-host-patch-bad") + lans = await _repo.list_lans_for_topology(topology_id) + lan_id = lans[0]["id"] + + r = await client.patch( + f"{_V1}/{topology_id}/lans/{lan_id}", + json={"host_uuid": "ghost-uuid"}, + headers=_hdr(auth_token), + ) + assert r.status_code == 400 + + # ── Decky CRUD ────────────────────────────────────────────────────