From 5a0cf5d7c86d89fe0ab85980a543c5010f6e6745 Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 21 Apr 2026 01:19:45 -0400 Subject: [PATCH] feat(topology): add target_host_uuid to pin topologies to swarm agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `target_host_uuid` FK on `Topology` plus wiring through the two create endpoints (`POST /topologies`, `POST /topologies/blank`). Validates the mode/host pair: `mode='agent'` now requires a known, routable host; `mode='unihost'` must leave the field unset. Surfaced on `TopologySummary` so list/detail responses expose it. Purely additive at the schema level — existing unihost flows unchanged (field defaults to `NULL`). Step 1 of the agent <-> topology integration. --- decnet/topology/persistence.py | 12 +- decnet/web/db/models.py | 28 ++++- decnet/web/router/topology/_target_host.py | 66 +++++++++++ .../topology/api_create_blank_topology.py | 11 +- .../router/topology/api_create_topology.py | 5 +- tests/api/topology/test_writes.py | 107 ++++++++++++++++++ 6 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 decnet/web/router/topology/_target_host.py diff --git a/decnet/topology/persistence.py b/decnet/topology/persistence.py index 94cfd734..d70e8f4f 100644 --- a/decnet/topology/persistence.py +++ b/decnet/topology/persistence.py @@ -9,18 +9,28 @@ from decnet.topology.config import GeneratedTopology from decnet.topology.status import TopologyStatus, assert_transition -async def persist(repo: Any, plan: GeneratedTopology) -> str: +async def persist( + repo: Any, + plan: GeneratedTopology, + *, + target_host_uuid: str | None = None, +) -> str: """Write a generated plan to the repo as a ``pending`` topology. Returns the newly created topology id. All child rows are written atomically relative to each other (SQLite transactions are per-call here; the repo methods each commit — good enough for initial create since the whole chain is invoked before any external side effects). + + ``target_host_uuid`` — pin the topology to a specific swarm agent. + Only meaningful when ``plan.config.mode == "agent"`` (caller + validates; this function just stores what it's told). """ topology_id = await repo.create_topology( { "name": plan.config.name, "mode": plan.config.mode, + "target_host_uuid": target_host_uuid, "config_snapshot": plan.config.model_dump(), } ) diff --git a/decnet/web/db/models.py b/decnet/web/db/models.py index 00087875..0840f019 100644 --- a/decnet/web/db/models.py +++ b/decnet/web/db/models.py @@ -203,6 +203,11 @@ class Topology(SQLModel, table=True): id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) name: str = Field(index=True, unique=True) mode: str = Field(default="unihost") # unihost|agent + # When ``mode == "agent"``, pins this topology to a specific enrolled + # worker. ``None`` for unihost topologies (master-local deploy). + target_host_uuid: Optional[str] = Field( + default=None, foreign_key="swarm_hosts.uuid", index=True + ) # Full TopologyConfig snapshot (including seed) used at generation time. config_snapshot: str = Field( sa_column=Column("config_snapshot", _BIG_TEXT, nullable=False, default="{}") @@ -655,6 +660,8 @@ class RollbackResponse(BaseModel): class TopologyGenerateRequest(BaseModel): """Body for POST /topologies — mirrors the `topology generate` CLI.""" name: str = PydanticField(..., min_length=1, max_length=64) + mode: str = PydanticField(default="unihost", pattern=r"^(unihost|agent)$") + target_host_uuid: Optional[str] = None depth: int = PydanticField(..., ge=1, le=16) branching_factor: int = PydanticField(..., ge=1, le=8) deckies_per_lan_min: int = PydanticField(default=1, ge=0, le=32) @@ -672,6 +679,7 @@ class TopologySummary(BaseModel): id: str name: str mode: str + target_host_uuid: Optional[str] = None status: str version: int created_at: datetime @@ -722,7 +730,12 @@ class EdgeRow(BaseModel): class TopologyDetail(BaseModel): - """Hydrated topology — mirrors persistence.hydrate() output.""" + """Hydrated topology — mirrors persistence.hydrate() output. + + ``topology`` uses :class:`TopologySummary` which already exposes + ``target_host_uuid`` — agent-targeted topologies surface their + pinned host through that field. + """ topology: TopologySummary lans: list[LANRow] deckies: list[DeckyRow] @@ -856,6 +869,19 @@ class ServiceCatalogResponse(BaseModel): services: list[str] +class ArchetypeEntry(BaseModel): + slug: str + display_name: str + description: str + services: list[str] + preferred_distros: list[str] + nmap_os: str + + +class ArchetypeCatalogResponse(BaseModel): + archetypes: list[ArchetypeEntry] + + class NextIPResponse(BaseModel): subnet: str ip: str diff --git a/decnet/web/router/topology/_target_host.py b/decnet/web/router/topology/_target_host.py new file mode 100644 index 00000000..7d5ea1c5 --- /dev/null +++ b/decnet/web/router/topology/_target_host.py @@ -0,0 +1,66 @@ +"""Shared validation for the ``mode`` / ``target_host_uuid`` pair. + +Called by the two topology-create endpoints +(``api_create_topology``, ``api_create_blank_topology``). Kept as a +tiny module so the rules stay in one place when Step 6 grows the list +(e.g. when we start rejecting hosts that already own a topology). +""" +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import HTTPException + +# Hosts we're willing to route a new topology to. ``enrolled`` is fine +# because the agent process has certs and will answer mTLS calls as +# soon as it's up; ``active`` means we've seen a heartbeat recently. +_ROUTABLE_HOST_STATUSES = {"enrolled", "active"} + + +async def validate_target_host( + repo: Any, + mode: str, + target_host_uuid: Optional[str], +) -> None: + """Raise HTTPException(400) if the mode/host combination is invalid. + + Rules: + - ``mode=="unihost"`` with a ``target_host_uuid`` → 400 (nonsense). + - ``mode=="agent"`` without ``target_host_uuid`` → 400. + - ``mode=="agent"`` with an unknown uuid → 400. + - ``mode=="agent"`` pointing at a host in ``unreachable`` / + ``decommissioned`` → 400 (operator asked for a broken path). + """ + if mode == "unihost": + if target_host_uuid is not None: + raise HTTPException( + status_code=400, + detail="target_host_uuid is only valid when mode='agent'", + ) + return + + if mode == "agent": + if not target_host_uuid: + raise HTTPException( + status_code=400, + detail="mode='agent' requires target_host_uuid", + ) + host = await repo.get_swarm_host_by_uuid(target_host_uuid) + if host is None: + raise HTTPException( + status_code=400, + detail=f"unknown swarm host {target_host_uuid!r}", + ) + if host.get("status") not in _ROUTABLE_HOST_STATUSES: + raise HTTPException( + status_code=400, + detail=( + f"swarm host {target_host_uuid!r} is " + f"{host.get('status')!r}; expected one of " + f"{sorted(_ROUTABLE_HOST_STATUSES)}" + ), + ) + return + + # Shouldn't happen — the pydantic pattern should have rejected it. + raise HTTPException(status_code=400, detail=f"unknown mode {mode!r}") diff --git a/decnet/web/router/topology/api_create_blank_topology.py b/decnet/web/router/topology/api_create_blank_topology.py index 2b7ef462..1c9f1c6e 100644 --- a/decnet/web/router/topology/api_create_blank_topology.py +++ b/decnet/web/router/topology/api_create_blank_topology.py @@ -18,13 +18,16 @@ from decnet.telemetry import traced as _traced from decnet.topology.allocator import SubnetAllocator, reserved_subnets from decnet.web.db.models import TopologySummary from decnet.web.dependencies import repo, require_admin +from decnet.web.router.topology._target_host import validate_target_host router = APIRouter() class BlankTopologyRequest(BaseModel): - """Body for POST /topologies/blank — name only.""" + """Body for POST /topologies/blank — name plus optional agent pinning.""" name: str = PydanticField(..., min_length=1, max_length=64) + mode: str = PydanticField(default="unihost", pattern=r"^(unihost|agent)$") + target_host_uuid: str | None = PydanticField(default=None) @router.post( @@ -44,12 +47,16 @@ async def api_create_blank_topology( body: BlankTopologyRequest, _admin: dict = Depends(require_admin), ) -> TopologySummary: + # 0. Validate mode/host pairing before any writes. + await validate_target_host(repo, body.mode, body.target_host_uuid) + # 1. Topology row try: topology_id = await repo.create_topology( { "name": body.name, - "mode": "unihost", + "mode": body.mode, + "target_host_uuid": body.target_host_uuid, "status": "pending", "config_snapshot": json.dumps({"blank": True}), } diff --git a/decnet/web/router/topology/api_create_topology.py b/decnet/web/router/topology/api_create_topology.py index f42705b2..a60bda57 100644 --- a/decnet/web/router/topology/api_create_topology.py +++ b/decnet/web/router/topology/api_create_topology.py @@ -10,6 +10,7 @@ from decnet.topology.generator import generate from decnet.topology.persistence import persist from decnet.web.db.models import TopologyGenerateRequest, TopologySummary from decnet.web.dependencies import repo, require_admin +from decnet.web.router.topology._target_host import validate_target_host router = APIRouter() @@ -31,9 +32,11 @@ async def api_create_topology( body: TopologyGenerateRequest, _admin: dict = Depends(require_admin), ) -> TopologySummary: + await validate_target_host(repo, body.mode, body.target_host_uuid) try: config = TopologyConfig( name=body.name, + mode=body.mode, depth=body.depth, branching_factor=body.branching_factor, deckies_per_lan_min=body.deckies_per_lan_min, @@ -55,6 +58,6 @@ async def api_create_topology( except (ValueError, TypeError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - topology_id = await persist(repo, plan) + topology_id = await persist(repo, plan, target_host_uuid=body.target_host_uuid) row = await repo.get_topology(topology_id) return TopologySummary(**row) diff --git a/tests/api/topology/test_writes.py b/tests/api/topology/test_writes.py index e8a13f0f..26c7ba57 100644 --- a/tests/api/topology/test_writes.py +++ b/tests/api/topology/test_writes.py @@ -189,3 +189,110 @@ async def test_deploy_requires_admin(client, viewer_token): headers={"Authorization": f"Bearer {viewer_token}"}, ) assert r.status_code == 403 + + +# ── mode / target_host_uuid pairing (Step 1) ────────────────────── + + +async def _seed_swarm_host(uuid_: str = "host-uuid-1", status: str = "enrolled") -> None: + await _repo.add_swarm_host( + { + "uuid": uuid_, + "name": f"host-{uuid_}", + "address": "10.9.9.9", + "agent_port": 8765, + "status": status, + "client_cert_fingerprint": "a" * 64, + "cert_bundle_path": "/tmp/ignored", + } + ) + + +@pytest.mark.anyio +async def test_create_blank_agent_mode_ok(client, auth_token): + await _seed_swarm_host("host-ok", status="active") + r = await client.post( + f"{_V1}/blank", + json={"name": "blank-agent", "mode": "agent", "target_host_uuid": "host-ok"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["mode"] == "agent" + assert body["target_host_uuid"] == "host-ok" + + +@pytest.mark.anyio +async def test_create_blank_agent_without_host_is_400(client, auth_token): + r = await client.post( + f"{_V1}/blank", + json={"name": "blank-agent-no-host", "mode": "agent"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert r.status_code == 400 + assert "target_host_uuid" in r.json()["detail"] + + +@pytest.mark.anyio +async def test_create_blank_agent_unknown_host_is_400(client, auth_token): + r = await client.post( + f"{_V1}/blank", + json={ + "name": "blank-agent-unknown", + "mode": "agent", + "target_host_uuid": "does-not-exist", + }, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert r.status_code == 400 + assert "unknown" in r.json()["detail"].lower() + + +@pytest.mark.anyio +async def test_create_blank_unihost_with_host_is_400(client, auth_token): + await _seed_swarm_host("host-unused") + r = await client.post( + f"{_V1}/blank", + json={ + "name": "blank-unihost-with-host", + "mode": "unihost", + "target_host_uuid": "host-unused", + }, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert r.status_code == 400 + + +@pytest.mark.anyio +async def test_create_agent_mode_ok(client, auth_token): + await _seed_swarm_host("host-gen") + payload = { + **_generate_payload("gen-agent"), + "mode": "agent", + "target_host_uuid": "host-gen", + } + r = await client.post( + f"{_V1}/", + json=payload, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["mode"] == "agent" + assert body["target_host_uuid"] == "host-gen" + + +@pytest.mark.anyio +async def test_create_agent_unreachable_host_is_400(client, auth_token): + await _seed_swarm_host("host-dead", status="unreachable") + payload = { + **_generate_payload("gen-agent-dead"), + "mode": "agent", + "target_host_uuid": "host-dead", + } + r = await client.post( + f"{_V1}/", + json=payload, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert r.status_code == 400