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

@@ -9,18 +9,28 @@ from decnet.topology.config import GeneratedTopology
from decnet.topology.status import TopologyStatus, assert_transition 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. """Write a generated plan to the repo as a ``pending`` topology.
Returns the newly created topology id. All child rows are written Returns the newly created topology id. All child rows are written
atomically relative to each other (SQLite transactions are per-call atomically relative to each other (SQLite transactions are per-call
here; the repo methods each commit — good enough for initial create here; the repo methods each commit — good enough for initial create
since the whole chain is invoked before any external side effects). 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( topology_id = await repo.create_topology(
{ {
"name": plan.config.name, "name": plan.config.name,
"mode": plan.config.mode, "mode": plan.config.mode,
"target_host_uuid": target_host_uuid,
"config_snapshot": plan.config.model_dump(), "config_snapshot": plan.config.model_dump(),
} }
) )

View File

@@ -203,6 +203,11 @@ class Topology(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
name: str = Field(index=True, unique=True) name: str = Field(index=True, unique=True)
mode: str = Field(default="unihost") # unihost|agent 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. # Full TopologyConfig snapshot (including seed) used at generation time.
config_snapshot: str = Field( config_snapshot: str = Field(
sa_column=Column("config_snapshot", _BIG_TEXT, nullable=False, default="{}") sa_column=Column("config_snapshot", _BIG_TEXT, nullable=False, default="{}")
@@ -655,6 +660,8 @@ class RollbackResponse(BaseModel):
class TopologyGenerateRequest(BaseModel): class TopologyGenerateRequest(BaseModel):
"""Body for POST /topologies — mirrors the `topology generate` CLI.""" """Body for POST /topologies — mirrors the `topology generate` CLI."""
name: str = PydanticField(..., min_length=1, max_length=64) 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) depth: int = PydanticField(..., ge=1, le=16)
branching_factor: int = PydanticField(..., ge=1, le=8) branching_factor: int = PydanticField(..., ge=1, le=8)
deckies_per_lan_min: int = PydanticField(default=1, ge=0, le=32) deckies_per_lan_min: int = PydanticField(default=1, ge=0, le=32)
@@ -672,6 +679,7 @@ class TopologySummary(BaseModel):
id: str id: str
name: str name: str
mode: str mode: str
target_host_uuid: Optional[str] = None
status: str status: str
version: int version: int
created_at: datetime created_at: datetime
@@ -722,7 +730,12 @@ class EdgeRow(BaseModel):
class TopologyDetail(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 topology: TopologySummary
lans: list[LANRow] lans: list[LANRow]
deckies: list[DeckyRow] deckies: list[DeckyRow]
@@ -856,6 +869,19 @@ class ServiceCatalogResponse(BaseModel):
services: list[str] 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): class NextIPResponse(BaseModel):
subnet: str subnet: str
ip: str ip: str

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.topology.allocator import SubnetAllocator, reserved_subnets
from decnet.web.db.models import TopologySummary from decnet.web.db.models import TopologySummary
from decnet.web.dependencies import repo, require_admin from decnet.web.dependencies import repo, require_admin
from decnet.web.router.topology._target_host import validate_target_host
router = APIRouter() router = APIRouter()
class BlankTopologyRequest(BaseModel): 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) 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( @router.post(
@@ -44,12 +47,16 @@ async def api_create_blank_topology(
body: BlankTopologyRequest, body: BlankTopologyRequest,
_admin: dict = Depends(require_admin), _admin: dict = Depends(require_admin),
) -> TopologySummary: ) -> TopologySummary:
# 0. Validate mode/host pairing before any writes.
await validate_target_host(repo, body.mode, body.target_host_uuid)
# 1. Topology row # 1. Topology row
try: try:
topology_id = await repo.create_topology( topology_id = await repo.create_topology(
{ {
"name": body.name, "name": body.name,
"mode": "unihost", "mode": body.mode,
"target_host_uuid": body.target_host_uuid,
"status": "pending", "status": "pending",
"config_snapshot": json.dumps({"blank": True}), "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.topology.persistence import persist
from decnet.web.db.models import TopologyGenerateRequest, TopologySummary from decnet.web.db.models import TopologyGenerateRequest, TopologySummary
from decnet.web.dependencies import repo, require_admin from decnet.web.dependencies import repo, require_admin
from decnet.web.router.topology._target_host import validate_target_host
router = APIRouter() router = APIRouter()
@@ -31,9 +32,11 @@ async def api_create_topology(
body: TopologyGenerateRequest, body: TopologyGenerateRequest,
_admin: dict = Depends(require_admin), _admin: dict = Depends(require_admin),
) -> TopologySummary: ) -> TopologySummary:
await validate_target_host(repo, body.mode, body.target_host_uuid)
try: try:
config = TopologyConfig( config = TopologyConfig(
name=body.name, name=body.name,
mode=body.mode,
depth=body.depth, depth=body.depth,
branching_factor=body.branching_factor, branching_factor=body.branching_factor,
deckies_per_lan_min=body.deckies_per_lan_min, deckies_per_lan_min=body.deckies_per_lan_min,
@@ -55,6 +58,6 @@ async def api_create_topology(
except (ValueError, TypeError) as exc: except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from 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) row = await repo.get_topology(topology_id)
return TopologySummary(**row) return TopologySummary(**row)

View File

@@ -189,3 +189,110 @@ async def test_deploy_requires_admin(client, viewer_token):
headers={"Authorization": f"Bearer {viewer_token}"}, headers={"Authorization": f"Bearer {viewer_token}"},
) )
assert r.status_code == 403 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