feat(topology): add target_host_uuid to pin topologies to swarm agents

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.
This commit is contained in:
2026-04-21 01:19:45 -04:00
parent 167582b887
commit 5a0cf5d7c8
6 changed files with 224 additions and 5 deletions

View File

@@ -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}")

View File

@@ -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}),
}

View File

@@ -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)