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:
@@ -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(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
66
decnet/web/router/topology/_target_host.py
Normal file
66
decnet/web/router/topology/_target_host.py
Normal 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}")
|
||||
@@ -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}),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user