merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

View File

@@ -0,0 +1,55 @@
"""MazeNET topology REST endpoints (phase 3).
Thin FastAPI layer over the phase-2 topology machinery:
generate/validate/deploy/teardown, pending-only child CRUD, and the
live-mutation queue for active|degraded topologies.
Mounted at ``/api/v1/topologies`` by the main api router. Sub-routers
live one-per-file and are aggregated here.
"""
from fastapi import APIRouter
from .api_catalog import router as _catalog_router
from .api_create_topology import router as _create_router
from .api_create_blank_topology import router as _create_blank_router
from .api_decky_crud import router as _decky_router
from .api_delete_topology import router as _delete_router
from .api_deploy_topology import router as _deploy_router
from .api_edge_crud import router as _edge_router
from .api_events import router as _events_router
from .api_get_topology import router as _get_router
from .api_lan_crud import router as _lan_router
from .api_list_topologies import router as _list_router
from .api_mutations import router as _mutations_router
from .api_personas import router as _personas_router
from .api_reap_orphans import router as _reap_router
from .api_teardown_topology import router as _teardown_router
topology_router = APIRouter(prefix="/topologies", tags=["topologies"])
# Order matters: catalog routes use literal path segments (e.g.
# /services, /next-subnet) that would otherwise be shadowed by the
# `/{topology_id}` path in api_get_topology. Keep the catalog router
# included first so FastAPI's trie resolves literals before the
# parameterized fallback.
topology_router.include_router(_catalog_router)
topology_router.include_router(_list_router)
topology_router.include_router(_create_blank_router)
topology_router.include_router(_create_router)
topology_router.include_router(_reap_router)
topology_router.include_router(_deploy_router)
topology_router.include_router(_teardown_router)
topology_router.include_router(_delete_router)
topology_router.include_router(_lan_router)
topology_router.include_router(_decky_router)
topology_router.include_router(_edge_router)
topology_router.include_router(_mutations_router)
topology_router.include_router(_events_router)
# Personas use a literal-suffix path (`/{id}/personas`) — register
# before the bare `/{id}` getter so FastAPI's trie sees the literal
# segment first.
topology_router.include_router(_personas_router)
topology_router.include_router(_get_router)
__all__ = ["topology_router"]

View File

@@ -0,0 +1,53 @@
"""Shared helpers for the Phase-3 child-CRUD routes."""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from decnet.topology.status import (
TopologyNotEditable,
TopologyStatus,
VersionConflict,
)
from decnet.web.dependencies import repo
async def get_topology_or_404(topology_id: str) -> dict[str, Any]:
topo = await repo.get_topology(topology_id)
if topo is None:
raise HTTPException(status_code=404, detail="Topology not found")
return topo
async def assert_pending_or_409(topology_id: str) -> dict[str, Any]:
"""Ensure the topology exists and is in ``pending`` state.
The repo layer enforces the same rule inside mutation methods, but the
``add_*`` helpers don't — re-check here so every write route agrees on
the pre-condition before any side effect.
"""
topo = await get_topology_or_404(topology_id)
if topo["status"] != TopologyStatus.PENDING:
raise HTTPException(
status_code=409,
detail=(
f"Topology is {topo['status']!r}; free-form child edits are "
f"pending-only. Use the mutation queue for active topologies."
),
)
return topo
def map_repo_exception(exc: Exception) -> HTTPException:
"""Translate repo-layer exceptions to HTTP status codes."""
if isinstance(exc, TopologyNotEditable):
return HTTPException(status_code=409, detail=str(exc))
if isinstance(exc, VersionConflict):
return HTTPException(
status_code=409,
detail=f"Version conflict: expected {exc.expected}, current {exc.current}",
)
if isinstance(exc, ValueError):
return HTTPException(status_code=400, detail=str(exc))
return HTTPException(status_code=500, detail="Internal error")

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

@@ -0,0 +1,140 @@
"""Read-only catalog endpoints — services, next-subnet, next-ip.
These wrap fleet/allocator helpers so the phase-4 canvas UI can lean
on the server for allocation instead of shipping the logic client-side.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from decnet.archetypes import all_archetypes
from decnet.fleet import all_service_names
from decnet.telemetry import traced as _traced
from decnet.topology.allocator import (
AllocatorExhausted,
IPAllocator,
SubnetAllocator,
reserved_subnets,
)
from decnet.web.db.models import (
ArchetypeCatalogResponse,
ArchetypeEntry,
NextIPResponse,
NextSubnetResponse,
ServiceCatalogResponse,
)
from decnet.web.dependencies import repo, require_viewer
router = APIRouter()
@router.get(
"/services",
tags=["MazeNET Topologies"],
response_model=ServiceCatalogResponse,
responses={
400: {"description": "Malformed query parameters"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
},
)
@_traced("api.topology.catalog.services")
async def api_list_services(
_viewer: dict = Depends(require_viewer),
) -> ServiceCatalogResponse:
return ServiceCatalogResponse(services=all_service_names())
@router.get(
"/archetypes",
tags=["MazeNET Topologies"],
response_model=ArchetypeCatalogResponse,
responses={
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
},
)
@_traced("api.topology.catalog.archetypes")
async def api_list_archetypes(
_viewer: dict = Depends(require_viewer),
) -> ArchetypeCatalogResponse:
return ArchetypeCatalogResponse(
archetypes=[
ArchetypeEntry(
slug=a.slug,
display_name=a.display_name,
description=a.description,
services=list(a.services),
preferred_distros=list(a.preferred_distros),
nmap_os=a.nmap_os,
)
for a in all_archetypes().values()
],
)
@router.get(
"/next-subnet",
tags=["MazeNET Topologies"],
response_model=NextSubnetResponse,
responses={
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
409: {"description": "Allocator exhausted"},
},
)
@_traced("api.topology.catalog.next_subnet")
async def api_next_subnet(
base: str = Query(
default="172.16.0.0/12",
pattern=r"^\d{1,3}\.\d{1,3}(\.\d{1,3}\.\d{1,3}/\d{1,2})?$",
),
_viewer: dict = Depends(require_viewer),
) -> NextSubnetResponse:
reserved = await reserved_subnets(repo)
alloc = SubnetAllocator(base_prefix=base, reserved=reserved)
try:
subnet = alloc.next_free()
except AllocatorExhausted as e:
raise HTTPException(status_code=409, detail=str(e))
return NextSubnetResponse(subnet=subnet)
@router.get(
"/{topology_id}/lans/{lan_id}/next-ip",
tags=["MazeNET Topologies"],
response_model=NextIPResponse,
responses={
400: {"description": "Malformed path parameters"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology or LAN not found"},
409: {"description": "Allocator exhausted"},
},
)
@_traced("api.topology.catalog.next_ip")
async def api_next_ip(
topology_id: str,
lan_id: str,
_viewer: dict = Depends(require_viewer),
) -> NextIPResponse:
if await repo.get_topology(topology_id) is None:
raise HTTPException(status_code=404, detail="Topology not found")
lans = await repo.list_lans_for_topology(topology_id)
lan = next((ln for ln in lans if ln["id"] == lan_id), None)
if lan is None:
raise HTTPException(status_code=404, detail="LAN not found")
deckies = await repo.list_topology_deckies(topology_id)
alloc = IPAllocator(subnet=lan["subnet"])
for d in deckies:
ip = (d.get("decky_config") or {}).get("ips_by_lan", {}).get(lan["name"])
if ip:
try:
alloc.reserve(ip)
except ValueError:
continue
try:
ip = alloc.next_free()
except AllocatorExhausted as e:
raise HTTPException(status_code=409, detail=str(e))
return NextIPResponse(subnet=lan["subnet"], ip=ip)

View File

@@ -0,0 +1,123 @@
"""POST /topologies/blank — create an empty editable topology.
Produces a minimal ``pending`` topology seeded with exactly one DMZ LAN
and its mandatory host-gateway decky. Intended for the MazeNET editor
landing flow: unlike ``POST /topologies`` (which runs the generator),
this endpoint takes no generator parameters and skips the planner
entirely. The DMZ+gateway invariant is enforced server-side so the
editor never has to special-case a "no DMZ yet" state.
"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field as PydanticField
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 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(
"/blank",
tags=["MazeNET Topologies"],
response_model=TopologySummary,
status_code=status.HTTP_201_CREATED,
responses={
400: {"description": "Malformed body or invalid topology name"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
409: {"description": "Name collision or subnet pool exhausted"},
},
)
@_traced("api.topology.create_blank")
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": body.mode,
"target_host_uuid": body.target_host_uuid,
"status": "pending",
"config_snapshot": json.dumps({"blank": True}),
}
)
except Exception as exc: # noqa: BLE001 — surface duplicate-name as 409
raise HTTPException(status_code=409, detail=str(exc)) from exc
# 2. DMZ LAN with auto-allocated subnet
try:
allocator = SubnetAllocator(
"10.0", reserved=await reserved_subnets(repo)
)
subnet = allocator.next_free()
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
lan_id = await repo.add_lan(
{
"topology_id": topology_id,
"name": "dmz",
"subnet": subnet,
"is_dmz": True,
"x": 40,
"y": 40,
}
)
# 3. DMZ-gateway decky — a normal multi-homed bridge decky.
# `forwards_l3=True` turns on net.ipv4.ip_forward + NET_ADMIN at
# compose time (see decnet/topology/compose.py). No host-mode,
# no MACVLAN — the gateway reaches the outside world via Docker
# port publishing (see composer port emission).
decky_uuid = await repo.add_topology_decky(
{
"topology_id": topology_id,
"name": "dmz-gateway",
"services": ["ssh"],
"decky_config": {
"archetype": "deaddeck",
"forwards_l3": True,
},
"state": "pending",
"x": 20,
"y": 60,
}
)
# 4. Membership edge on the DMZ — is_bridge=True marks this decky
# as the topology's bridge gateway; forwards_l3 mirrors the decky
# config so the generator/compose paths stay consistent.
await repo.add_topology_edge(
{
"topology_id": topology_id,
"decky_uuid": decky_uuid,
"lan_id": lan_id,
"is_bridge": True,
"forwards_l3": True,
}
)
row = await repo.get_topology(topology_id)
if row is None: # pragma: no cover — create then vanish
raise HTTPException(status_code=500, detail="topology insert vanished")
return TopologySummary(**row)

View File

@@ -0,0 +1,77 @@
"""POST /topologies — generate and persist a new MazeNET topology."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from decnet.telemetry import traced as _traced
from decnet.topology.allocator import reserved_subnets
from decnet.topology.config import TopologyConfig
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()
@router.post(
"/",
tags=["MazeNET Topologies"],
response_model=TopologySummary,
status_code=status.HTTP_201_CREATED,
responses={
400: {"description": "Malformed or invalid generation parameters"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
409: {"description": "Duplicate topology name, or generator could not allocate subnets (exhausted pool)"},
},
)
@_traced("api.topology.create")
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,
deckies_per_lan_max=body.deckies_per_lan_max,
bridge_forward_probability=body.bridge_forward_probability,
cross_edge_probability=body.cross_edge_probability,
services_explicit=body.services_explicit,
randomize_services=body.randomize_services,
seed=body.seed,
)
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
try:
plan = generate(config, reserved_subnets=await reserved_subnets(repo))
except RuntimeError as exc:
# Subnet allocator exhaustion or similar planner-level failure.
raise HTTPException(status_code=409, detail=str(exc)) from exc
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
try:
topology_id = await persist(repo, plan, target_host_uuid=body.target_host_uuid)
except IntegrityError as exc:
# Unique constraint on topologies.name is the only integrity
# error the create path can realistically hit — inspecting the
# constraint name keeps us from silently mapping unrelated
# integrity failures to 409.
msg = str(exc.orig) if exc.orig is not None else str(exc)
if "ix_topologies_name" in msg or "topologies.name" in msg:
raise HTTPException(
status_code=409,
detail=f"A topology named {body.name!r} already exists.",
) from exc
raise
row = await repo.get_topology(topology_id)
return TopologySummary(**row)

View File

@@ -0,0 +1,136 @@
"""Decky CRUD endpoints — pending-only child mutations.
POST /topologies/{id}/deckies
PATCH /topologies/{id}/deckies/{uuid}
DELETE /topologies/{id}/deckies/{uuid}
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Response, status
from decnet.telemetry import traced as _traced
from decnet.topology.status import (
TopologyNotEditable,
VersionConflict,
)
from decnet.web.db.models import DeckyCreateRequest, DeckyRow, DeckyUpdateRequest
from decnet.web.dependencies import repo, require_admin
from ._guards import assert_pending_or_409, map_repo_exception
router = APIRouter()
@router.post(
"/{topology_id}/deckies",
tags=["MazeNET Topologies"],
response_model=DeckyRow,
status_code=status.HTTP_201_CREATED,
responses={
400: {"description": "Malformed body or invalid decky fields"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
409: {"description": "Topology not editable or version conflict"},
},
)
@_traced("api.topology.decky.create")
async def api_create_decky(
topology_id: str,
body: DeckyCreateRequest,
_admin: dict = Depends(require_admin),
) -> DeckyRow:
await assert_pending_or_409(topology_id)
payload = {
"topology_id": topology_id,
"name": body.name,
"services": body.services,
"decky_config": body.decky_config,
"x": body.x,
"y": body.y,
}
try:
decky_uuid = await repo.add_topology_decky(
payload, expected_version=body.expected_version
)
except (TopologyNotEditable, VersionConflict, ValueError) as exc:
raise map_repo_exception(exc) from exc
rows = await repo.list_topology_deckies(topology_id)
row = next((r for r in rows if r["uuid"] == decky_uuid), None)
if row is None: # pragma: no cover
raise HTTPException(status_code=500, detail="Decky insert vanished")
return DeckyRow(**row)
@router.patch(
"/{topology_id}/deckies/{decky_uuid}",
tags=["MazeNET Topologies"],
response_model=DeckyRow,
responses={
400: {"description": "Malformed body"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology or decky not found"},
409: {"description": "Topology not editable or version conflict"},
},
)
@_traced("api.topology.decky.update")
async def api_update_decky(
topology_id: str,
decky_uuid: str,
body: DeckyUpdateRequest,
_admin: dict = Depends(require_admin),
) -> DeckyRow:
await assert_pending_or_409(topology_id)
fields = body.model_dump(exclude_unset=True, exclude={"expected_version"})
try:
await repo.update_topology_decky(
decky_uuid,
fields,
expected_version=body.expected_version,
enforce_pending=True,
)
except (TopologyNotEditable, VersionConflict) as exc:
raise map_repo_exception(exc) from exc
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
rows = await repo.list_topology_deckies(topology_id)
row = next((r for r in rows if r["uuid"] == decky_uuid), None)
if row is None:
raise HTTPException(status_code=404, detail="Decky not found")
return DeckyRow(**row)
@router.delete(
"/{topology_id}/deckies/{decky_uuid}",
tags=["MazeNET Topologies"],
status_code=status.HTTP_204_NO_CONTENT,
responses={
400: {"description": "Malformed path"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology or decky not found"},
409: {"description": "Topology not editable or version conflict"},
},
)
@_traced("api.topology.decky.delete")
async def api_delete_decky(
topology_id: str,
decky_uuid: str,
_admin: dict = Depends(require_admin),
) -> Response:
await assert_pending_or_409(topology_id)
rows = await repo.list_topology_deckies(topology_id)
if not any(r["uuid"] == decky_uuid for r in rows):
raise HTTPException(status_code=404, detail="Decky not found")
try:
await repo.delete_topology_decky(decky_uuid)
except (TopologyNotEditable, VersionConflict, ValueError) as exc:
raise map_repo_exception(exc) from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,51 @@
"""DELETE /topologies/{id} — cascade-delete a pending or torn-down topology."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Response, status
from decnet.telemetry import traced as _traced
from decnet.topology.status import TopologyStatus
from decnet.web.dependencies import repo, require_admin
router = APIRouter()
# Only allow delete when containers are guaranteed not to be running.
# ACTIVE / DEPLOYING / DEGRADED / TEARING_DOWN must teardown first.
_DELETABLE: frozenset[str] = frozenset(
{TopologyStatus.PENDING, TopologyStatus.TORN_DOWN, TopologyStatus.FAILED}
)
@router.delete(
"/{topology_id}",
tags=["MazeNET Topologies"],
status_code=status.HTTP_204_NO_CONTENT,
responses={
400: {"description": "Malformed path parameters"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
409: {"description": "Topology has running resources; teardown first"},
},
)
@_traced("api.topology.delete")
async def api_delete_topology(
topology_id: str,
_admin: dict = Depends(require_admin),
) -> Response:
topo = await repo.get_topology(topology_id)
if topo is None:
raise HTTPException(status_code=404, detail="Topology not found")
if topo["status"] not in _DELETABLE:
raise HTTPException(
status_code=409,
detail=(
f"Topology is {topo['status']!r}; teardown to 'torn_down' "
f"before delete."
),
)
deleted = await repo.delete_topology_cascade(topology_id)
if not deleted:
# Race: row vanished between the status check and the cascade.
raise HTTPException(status_code=404, detail="Topology not found")
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,76 @@
"""POST /topologies/{id}/deploy — transition pending → deploying and fire
the background deploy.
The actual Docker work happens in a BackgroundTask so the HTTP caller
returns quickly with ``202 Accepted``. Status transitions
(``deploying`` → ``active`` | ``failed``) are written by
:func:`decnet.engine.deployer.deploy_topology` itself.
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from decnet.engine.deployer import deploy_topology
from decnet.telemetry import traced as _traced
from decnet.topology.status import TopologyStatus
from decnet.web.db.models import TopologySummary
from decnet.web.dependencies import repo, require_admin
log = logging.getLogger(__name__)
router = APIRouter()
async def _run_deploy(topology_id: str) -> None:
"""BackgroundTask body: deploy, swallow + log any exception so the
task runner doesn't crash. Status on failure is marked by
:func:`deploy_topology` via its own exception handler.
"""
try:
await deploy_topology(repo, topology_id)
except asyncio.CancelledError: # pragma: no cover — shutdown
raise
except Exception as exc: # noqa: BLE001
from decnet.engine.deployer import _format_subprocess_error
log.error(
"background deploy of %s failed: %s",
topology_id, _format_subprocess_error(exc),
)
@router.post(
"/{topology_id}/deploy",
tags=["MazeNET Topologies"],
response_model=TopologySummary,
status_code=status.HTTP_202_ACCEPTED,
responses={
400: {"description": "Malformed path parameters"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
409: {"description": "Topology is not in 'pending' status"},
},
)
@_traced("api.topology.deploy")
async def api_deploy_topology(
topology_id: str,
background: BackgroundTasks,
_admin: dict = Depends(require_admin),
) -> TopologySummary:
topo = await repo.get_topology(topology_id)
if topo is None:
raise HTTPException(status_code=404, detail="Topology not found")
if topo["status"] != TopologyStatus.PENDING:
raise HTTPException(
status_code=409,
detail=(
f"Topology is {topo['status']!r}; only 'pending' topologies "
f"can be deployed."
),
)
background.add_task(_run_deploy, topology_id)
return TopologySummary(**topo)

View File

@@ -0,0 +1,110 @@
"""Edge CRUD endpoints — pending-only child mutations.
POST /topologies/{id}/edges
DELETE /topologies/{id}/edges/{edge_id}
Edges are the decky↔LAN membership table (bipartite). Creating an
edge attaches a decky to an additional LAN; deleting one detaches.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Response, status
from decnet.telemetry import traced as _traced
from decnet.topology.status import (
TopologyNotEditable,
VersionConflict,
)
from decnet.web.db.models import EdgeCreateRequest, EdgeRow
from decnet.web.dependencies import repo, require_admin
from ._guards import assert_pending_or_409, map_repo_exception
router = APIRouter()
@router.post(
"/{topology_id}/edges",
tags=["MazeNET Topologies"],
response_model=EdgeRow,
status_code=status.HTTP_201_CREATED,
responses={
400: {"description": "Malformed body or unknown decky/LAN"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
409: {"description": "Topology not editable or version conflict"},
},
)
@_traced("api.topology.edge.create")
async def api_create_edge(
topology_id: str,
body: EdgeCreateRequest,
_admin: dict = Depends(require_admin),
) -> EdgeRow:
await assert_pending_or_409(topology_id)
# Referential integrity: decky + LAN must belong to this topology.
deckies = await repo.list_topology_deckies(topology_id)
if not any(d["uuid"] == body.decky_uuid for d in deckies):
raise HTTPException(
status_code=400,
detail=f"decky {body.decky_uuid!r} not in topology {topology_id!r}",
)
lans = await repo.list_lans_for_topology(topology_id)
if not any(r["id"] == body.lan_id for r in lans):
raise HTTPException(
status_code=400,
detail=f"lan {body.lan_id!r} not in topology {topology_id!r}",
)
payload = {
"topology_id": topology_id,
"decky_uuid": body.decky_uuid,
"lan_id": body.lan_id,
"is_bridge": body.is_bridge,
"forwards_l3": body.forwards_l3,
}
try:
edge_id = await repo.add_topology_edge(
payload, expected_version=body.expected_version
)
except (TopologyNotEditable, VersionConflict, ValueError) as exc:
raise map_repo_exception(exc) from exc
edges = await repo.list_topology_edges(topology_id)
row = next((e for e in edges if e["id"] == edge_id), None)
if row is None: # pragma: no cover
raise HTTPException(status_code=500, detail="Edge insert vanished")
return EdgeRow(**row)
@router.delete(
"/{topology_id}/edges/{edge_id}",
tags=["MazeNET Topologies"],
status_code=status.HTTP_204_NO_CONTENT,
responses={
400: {"description": "Malformed path"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology or edge not found"},
409: {"description": "Topology not editable or version conflict"},
},
)
@_traced("api.topology.edge.delete")
async def api_delete_edge(
topology_id: str,
edge_id: str,
_admin: dict = Depends(require_admin),
) -> Response:
await assert_pending_or_409(topology_id)
edges = await repo.list_topology_edges(topology_id)
if not any(e["id"] == edge_id for e in edges):
raise HTTPException(status_code=404, detail="Edge not found")
try:
await repo.delete_topology_edge(edge_id)
except (TopologyNotEditable, VersionConflict, ValueError) as exc:
raise map_repo_exception(exc) from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,157 @@
"""SSE stream of topology lifecycle events — one connection per editor.
Subscribes to ``topology.<id>.>`` on the :class:`~decnet.bus.base.BaseBus`
for the duration of the request and forwards each matching bus event as
a Server-Sent Event to the browser. Emits a one-shot snapshot on connect
(current status + any in-flight mutations) so the client doesn't need a
separate fetch to initialise the "pending" buffer.
Authorization matches :mod:`decnet.web.router.stream.api_stream_events`
— a JWT passed via the ``?token=`` query parameter (EventSource can't
set arbitrary headers) + ``require_stream_viewer`` role gate. The
per-topology 404 is enforced after auth so existence probes can't leak
a topology id to an unauthenticated caller.
"""
from __future__ import annotations
import asyncio
from typing import AsyncGenerator
import orjson
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from decnet.bus import topics as _topics
from decnet.bus.app import get_app_bus
from decnet.logging import get_logger
from decnet.telemetry import traced as _traced
from decnet.web.dependencies import repo, require_stream_viewer
from decnet.web.sse_limits import sse_connection_slot
from ._guards import get_topology_or_404
log = get_logger("api.topology.events")
router = APIRouter()
_KEEPALIVE_SECS = 15.0
_IN_FLIGHT_STATES = ("pending", "applying")
def _format_sse(event_name: str, data: dict) -> str:
"""Build one SSE frame: ``event: <name>\\ndata: <json>\\n\\n``."""
return f"event: {event_name}\ndata: {orjson.dumps(data).decode()}\n\n"
@router.get(
"/{topology_id}/events",
tags=["MazeNET Topologies"],
responses={
200: {
"content": {"text/event-stream": {}},
"description": "SSE stream of mutation and status events for one topology",
},
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
429: {"description": "Per-user SSE connection cap reached"},
},
)
@_traced("api.topology.events")
async def api_topology_events(
topology_id: str,
request: Request,
user: dict = Depends(require_stream_viewer),
) -> StreamingResponse:
# Event types emitted: snapshot, status, mutation.{enqueued,
# applying,applied,failed}. All wrap bus events whose payload is
# also reachable via viewer-gated REST (GET /topologies/{id},
# GET /topologies/{id}/mutations). Adding a new event family here
# requires a threat-model review for F6/I (role leakage).
topo = await get_topology_or_404(topology_id)
snapshot_status = topo["status"]
in_flight: list[dict] = []
for state in _IN_FLIGHT_STATES:
in_flight.extend(await repo.list_topology_mutations(topology_id, state=state))
async def generator() -> AsyncGenerator[str, None]:
async with sse_connection_slot(user["uuid"]):
# Flush headers immediately so the browser's EventSource sees a
# live connection before the first real event arrives.
yield ": keepalive\n\n"
# One-shot snapshot — pair the current topology status with any
# mutations the mutator is still holding, so the client buffer
# can render an accurate "already in flight" state.
yield _format_sse("snapshot", {
"topology_id": topology_id,
"status": snapshot_status,
"in_flight": in_flight,
})
bus = await get_app_bus()
if bus is None:
# Bus disabled (NullBus) or unreachable. The snapshot is
# still useful; we idle on keepalives so the client stays
# connected and will re-poll on its own timers.
while not await request.is_disconnected():
try:
await asyncio.sleep(_KEEPALIVE_SECS)
except asyncio.CancelledError:
break
yield ": keepalive\n\n"
return
sub = bus.subscribe(f"{_topics.TOPOLOGY}.{topology_id}.>")
try:
async with sub:
sub_iter = sub.__aiter__()
while True:
if await request.is_disconnected():
break
next_task = asyncio.ensure_future(sub_iter.__anext__())
try:
event = await asyncio.wait_for(next_task, timeout=_KEEPALIVE_SECS)
except asyncio.TimeoutError:
next_task.cancel()
yield ": keepalive\n\n"
continue
except StopAsyncIteration:
break
# Map the bus event onto an SSE ``event:`` name that
# the frontend can switch on without parsing topics.
yield _format_sse(
_sse_name_for(event.topic),
{
"topic": event.topic,
"type": event.type,
"ts": event.ts,
"payload": event.payload,
},
)
except asyncio.CancelledError:
pass
except Exception:
log.exception("topology events stream crashed topology_id=%s", topology_id)
yield _format_sse("error", {"message": "Stream interrupted"})
return StreamingResponse(
generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
def _sse_name_for(topic: str) -> str:
"""Derive an SSE ``event:`` name from a bus topic.
``topology.<id>.mutation.applied`` → ``mutation.applied``
``topology.<id>.status`` → ``status``
Anything else is passed through unchanged so future topic families
don't silently collapse onto a generic bucket.
"""
parts = topic.split(".", 2)
return parts[2] if len(parts) >= 3 else topic

View File

@@ -0,0 +1,68 @@
"""GET /topologies/{id} and /topologies/{id}/status-events."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from decnet.telemetry import traced as _traced
from decnet.topology.persistence import hydrate
from decnet.web.db.models import (
DeckyRow,
EdgeRow,
LANRow,
TopologyDetail,
TopologyStatusEventRow,
TopologySummary,
)
from decnet.web.dependencies import repo, require_viewer
router = APIRouter()
@router.get(
"/{topology_id}",
tags=["MazeNET Topologies"],
response_model=TopologyDetail,
responses={
400: {"description": "Malformed path parameters"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
},
)
@_traced("api.topology.get")
async def api_get_topology(
topology_id: str,
_viewer: dict = Depends(require_viewer),
) -> TopologyDetail:
hydrated = await hydrate(repo, topology_id)
if hydrated is None:
raise HTTPException(status_code=404, detail="Topology not found")
return TopologyDetail(
topology=TopologySummary(**hydrated["topology"]),
lans=[LANRow(**r) for r in hydrated["lans"]],
deckies=[DeckyRow(**r) for r in hydrated["deckies"]],
edges=[EdgeRow(**r) for r in hydrated["edges"]],
)
@router.get(
"/{topology_id}/status-events",
tags=["MazeNET Topologies"],
response_model=list[TopologyStatusEventRow],
responses={
400: {"description": "Malformed query parameters"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
},
)
@_traced("api.topology.status_events")
async def api_get_status_events(
topology_id: str,
limit: int = Query(default=100, ge=1, le=1000),
_viewer: dict = Depends(require_viewer),
) -> list[TopologyStatusEventRow]:
if await repo.get_topology(topology_id) is None:
raise HTTPException(status_code=404, detail="Topology not found")
rows = await repo.list_topology_status_events(topology_id, limit=limit)
return [TopologyStatusEventRow(**r) for r in rows]

View File

@@ -0,0 +1,152 @@
"""LAN CRUD endpoints — pending-only child mutations.
POST /topologies/{id}/lans
PATCH /topologies/{id}/lans/{lan_id}
DELETE /topologies/{id}/lans/{lan_id}
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Response, status
from decnet.logging import get_logger
from decnet.telemetry import traced as _traced
from decnet.topology.allocator import reserved_subnets
from decnet.topology.status import (
TopologyNotEditable,
VersionConflict,
)
from decnet.web.db.models import LANCreateRequest, LANRow, LANUpdateRequest
from decnet.web.dependencies import repo, require_admin
from ._guards import assert_pending_or_409, map_repo_exception
log = get_logger("api.topology.lan")
router = APIRouter()
@router.post(
"/{topology_id}/lans",
tags=["MazeNET Topologies"],
response_model=LANRow,
status_code=status.HTTP_201_CREATED,
responses={
400: {"description": "Malformed body or invalid LAN fields"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
409: {"description": "Topology not editable or version conflict"},
},
)
@_traced("api.topology.lan.create")
async def api_create_lan(
topology_id: str,
body: LANCreateRequest,
_admin: dict = Depends(require_admin),
) -> LANRow:
await assert_pending_or_409(topology_id)
subnet = body.subnet
if subnet is None:
# Mint a free /24. The allocator scans the claimed set and hands
# back the next free subnet base — same logic as the catalog
# /next-subnet endpoint, but inlined so create is atomic.
from decnet.topology.allocator import SubnetAllocator
allocator = SubnetAllocator(
"10.0", reserved=await reserved_subnets(repo)
)
subnet = allocator.next_free()
payload = {
"topology_id": topology_id,
"name": body.name,
"subnet": subnet,
"is_dmz": body.is_dmz,
"x": body.x,
"y": body.y,
}
try:
lan_id = await repo.add_lan(
payload, expected_version=body.expected_version
)
except (TopologyNotEditable, VersionConflict, ValueError) as exc:
raise map_repo_exception(exc) from exc
rows = await repo.list_lans_for_topology(topology_id)
row = next((r for r in rows if r["id"] == lan_id), None)
if row is None: # pragma: no cover — would mean insert vanished
raise HTTPException(status_code=500, detail="LAN insert vanished")
return LANRow(**row)
@router.patch(
"/{topology_id}/lans/{lan_id}",
tags=["MazeNET Topologies"],
response_model=LANRow,
responses={
400: {"description": "Malformed body or invalid LAN fields"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology or LAN not found"},
409: {"description": "Topology not editable or version conflict"},
},
)
@_traced("api.topology.lan.update")
async def api_update_lan(
topology_id: str,
lan_id: str,
body: LANUpdateRequest,
_admin: dict = Depends(require_admin),
) -> LANRow:
await assert_pending_or_409(topology_id)
fields = body.model_dump(exclude_unset=True, exclude={"expected_version"})
try:
await repo.update_lan(
lan_id,
fields,
expected_version=body.expected_version,
enforce_pending=True,
)
except (TopologyNotEditable, VersionConflict) as exc:
raise map_repo_exception(exc) from exc
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
rows = await repo.list_lans_for_topology(topology_id)
row = next((r for r in rows if r["id"] == lan_id), None)
if row is None:
raise HTTPException(status_code=404, detail="LAN not found")
return LANRow(**row)
@router.delete(
"/{topology_id}/lans/{lan_id}",
tags=["MazeNET Topologies"],
status_code=status.HTTP_204_NO_CONTENT,
responses={
400: {"description": "Cannot delete: LAN has orphan-risking deckies"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology or LAN not found"},
409: {"description": "Topology not editable or version conflict"},
},
)
@_traced("api.topology.lan.delete")
async def api_delete_lan(
topology_id: str,
lan_id: str,
_admin: dict = Depends(require_admin),
) -> Response:
await assert_pending_or_409(topology_id)
rows = await repo.list_lans_for_topology(topology_id)
if not any(r["id"] == lan_id for r in rows):
raise HTTPException(status_code=404, detail="LAN not found")
try:
await repo.delete_lan(lan_id)
except (TopologyNotEditable, VersionConflict, ValueError) as exc:
raise map_repo_exception(exc) from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,39 @@
"""GET /topologies — paginated list of MazeNET topologies."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, Query
from decnet.telemetry import traced as _traced
from decnet.web.db.models import TopologyListResponse, TopologySummary
from decnet.web.dependencies import repo, require_viewer
router = APIRouter()
@router.get(
"/",
tags=["MazeNET Topologies"],
response_model=TopologyListResponse,
responses={
400: {"description": "Malformed query parameters"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
},
)
@_traced("api.topology.list")
async def api_list_topologies(
status: Optional[str] = Query(default=None, description="Filter by topology status"),
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0, le=2147483647),
_viewer: dict = Depends(require_viewer),
) -> TopologyListResponse:
total = await repo.count_topologies(status=status)
rows = await repo.list_topologies(status=status, limit=limit, offset=offset)
return TopologyListResponse(
total=total,
limit=limit,
offset=offset,
data=[TopologySummary(**r) for r in rows],
)

View File

@@ -0,0 +1,127 @@
"""Live-mutation queue endpoints — for active | degraded topologies.
POST /topologies/{id}/mutations enqueue one mutation op
GET /topologies/{id}/mutations list queued / applied / failed rows
The mutator worker claims pending rows via ``claim_next_mutation`` and
transitions them to ``applying`` → ``applied`` | ``failed``. The API
layer only stages rows and reports them back.
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from decnet.bus import topics as _topics
from decnet.bus.app import get_app_bus
from decnet.logging import get_logger
from decnet.telemetry import traced as _traced
from decnet.topology.status import (
TopologyStatus,
VersionConflict,
)
from decnet.web.db.models import (
MutationEnqueueRequest,
MutationEnqueueResponse,
MutationRow,
)
from decnet.web.dependencies import repo, require_admin, require_viewer
from ._guards import get_topology_or_404, map_repo_exception
_log = get_logger("api.topology.mutations")
router = APIRouter()
_MUTATABLE: frozenset[str] = frozenset(
{TopologyStatus.ACTIVE, TopologyStatus.DEGRADED}
)
@router.post(
"/{topology_id}/mutations",
tags=["MazeNET Topologies"],
response_model=MutationEnqueueResponse,
status_code=status.HTTP_202_ACCEPTED,
responses={
400: {"description": "Malformed body or unknown mutation op"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
409: {
"description": (
"Topology is not active|degraded, or version conflict"
)
},
},
)
@_traced("api.topology.mutation.enqueue")
async def api_enqueue_mutation(
topology_id: str,
body: MutationEnqueueRequest,
_admin: dict = Depends(require_admin),
) -> MutationEnqueueResponse:
topo = await get_topology_or_404(topology_id)
if topo["status"] not in _MUTATABLE:
raise HTTPException(
status_code=409,
detail=(
f"Topology is {topo['status']!r}; the mutation queue is "
f"only open for 'active' or 'degraded' topologies. Use "
f"child-CRUD endpoints while pending."
),
)
try:
mutation_id = await repo.enqueue_topology_mutation(
topology_id,
body.op,
body.payload,
expected_version=body.expected_version,
)
except VersionConflict as exc:
raise map_repo_exception(exc) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# Fire-and-forget bus publish so the mutator can wake immediately and
# the SSE route can notify connected editors. Bus failure here must
# never mask a successful enqueue — the DB row is authoritative.
bus = await get_app_bus()
if bus is not None:
try:
await bus.publish(
_topics.topology_mutation(topology_id, _topics.MUTATION_ENQUEUED),
{"mutation_id": mutation_id, "op": body.op, "payload": body.payload},
event_type=_topics.MUTATION_ENQUEUED,
)
except Exception as exc: # noqa: BLE001
_log.warning("bus publish (enqueued) failed: %s", exc)
return MutationEnqueueResponse(mutation_id=mutation_id, state="pending")
@router.get(
"/{topology_id}/mutations",
tags=["MazeNET Topologies"],
response_model=list[MutationRow],
responses={
400: {"description": "Malformed query parameters"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
},
)
@_traced("api.topology.mutation.list")
async def api_list_mutations(
topology_id: str,
state: Optional[str] = Query(
default=None,
description="Filter by state: pending | applying | applied | failed",
),
_viewer: dict = Depends(require_viewer),
) -> list[MutationRow]:
await get_topology_or_404(topology_id)
rows = await repo.list_topology_mutations(topology_id, state=state)
return [MutationRow(**r) for r in rows]

View File

@@ -0,0 +1,131 @@
"""GET/PUT ``/topologies/{id}/personas`` — per-topology email persona pool.
The global pool (``decnet/web/router/emailgen/api_personas.py``) drives
non-MazeNET fleet/SWARM-shard mail deckies. MazeNET topology mail
deckies use ``Topology.email_personas`` instead — one JSON-serialized
list per topology, parsed by the emailgen scheduler each tick.
This endpoint is the API surface behind the dashboard's per-topology
"Personas" editor. Reads accept admin or viewer; writes are admin-only.
Concurrency: last-write-wins. The list is operator-curated and small
(typically <20 entries); no need for optimistic versioning here.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from decnet.logging import get_logger
from decnet.realism.personas import EmailPersona, parse_personas
from decnet.telemetry import traced as _traced
from decnet.web.dependencies import repo, require_admin, require_viewer
router = APIRouter()
log = get_logger("api.topology.personas")
def _serialize(personas: list[EmailPersona]) -> list[dict[str, Any]]:
return [p.model_dump(exclude_none=False) for p in personas]
@router.get(
"/{topology_id}/personas",
tags=["MazeNET Topologies"],
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
},
)
@_traced("api.topology.list_personas")
async def list_topology_personas(
topology_id: str,
_viewer: dict = Depends(require_viewer),
) -> dict[str, Any]:
"""Return the topology's persona list and its language default.
``language_default`` is included so the editor can show which
language unset entries fall back to — same fallback the scheduler
applies when building prompts.
"""
topo = await repo.get_topology(topology_id)
if topo is None:
raise HTTPException(status_code=404, detail="Topology not found")
language_default = topo.get("language_default") or "en"
personas = parse_personas(
topo.get("email_personas"), language_default=language_default,
)
return {
"topology_id": topology_id,
"topology_name": topo.get("name", ""),
"language_default": language_default,
"personas": _serialize(personas),
}
@router.put(
"/{topology_id}/personas",
tags=["MazeNET Topologies"],
responses={
400: {"description": "Invalid persona payload"},
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
},
)
@_traced("api.topology.replace_personas")
async def replace_topology_personas(
topology_id: str,
body: dict[str, Any],
user: dict = Depends(require_admin),
) -> dict[str, Any]:
"""Replace the topology's persona list.
Body shape: ``{"personas": [<EmailPersona>, ...]}``.
Drop-invalid semantics mirror the global-pool endpoint: bad entries
are skipped with a warning rather than failing the whole request, but
a wholly invalid payload returns 400 so a schema mistake doesn't
silently wipe the list.
"""
raw = body.get("personas")
if not isinstance(raw, list):
raise HTTPException(
status_code=400, detail="body.personas must be a list",
)
topo = await repo.get_topology(topology_id)
if topo is None:
raise HTTPException(status_code=404, detail="Topology not found")
language_default = topo.get("language_default") or "en"
parsed = parse_personas(raw, language_default=language_default)
if raw and not parsed:
raise HTTPException(
status_code=400,
detail=(
"All persona entries failed validation. Required fields: "
"name, email (user@host.tld), role, tone, mannerisms."
),
)
serialized = _serialize(parsed)
payload = json.dumps(serialized, ensure_ascii=False)
updated = await repo.set_topology_email_personas(topology_id, payload)
if not updated:
# Race: row vanished between the get and the update.
raise HTTPException(status_code=404, detail="Topology not found")
log.info(
"api.topology.replace_personas user=%s topology=%s wrote=%d",
user.get("username", user.get("uuid")), topology_id, len(parsed),
)
return {
"topology_id": topology_id,
"topology_name": topo.get("name", ""),
"language_default": language_default,
"personas": serialized,
}

View File

@@ -0,0 +1,48 @@
"""POST /topologies/reap-orphans — remove Docker resources for topology
ids the DB no longer knows about.
A topology row deleted outside the teardown flow (operator error,
crashed master, direct DB edit) leaves its containers and bridge
networks behind. The orphan networks keep their IPAM pools, so the
next deploy at the same subnet hits a 403 ``Pool overlaps`` from the
Docker daemon.
This endpoint walks the local Docker daemon, computes the set of
topology prefixes still known to the repo, and force-removes every
container + network whose prefix is orphaned. Resources belonging to
live topologies are never touched.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from decnet.engine.reaper import reap_orphan_topology_resources
from decnet.telemetry import traced as _traced
from decnet.web.db.models import ReapReportResponse
from decnet.web.dependencies import repo, require_admin
router = APIRouter()
@router.post(
"/reap-orphans",
tags=["MazeNET Topologies"],
response_model=ReapReportResponse,
responses={
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
},
)
@_traced("api.topology.reap_orphans")
async def api_reap_orphans(
_admin: dict = Depends(require_admin),
) -> dict:
"""Reap Docker resources whose topology id is absent from the DB.
Returns a report with the live prefixes, the orphan prefixes that
were identified, every container + network actually removed, and
any per-resource errors encountered. Errors are non-fatal — a
single stuck resource does not abort the sweep.
"""
report = await reap_orphan_topology_resources(repo)
return report.to_dict()

View File

@@ -0,0 +1,79 @@
"""POST /topologies/{id}/teardown — transition an active/degraded/failed
topology to ``tearing_down`` and fire the background teardown.
Mirrors :mod:`api_deploy_topology`: the real Docker work runs in a
BackgroundTask, the caller returns ``202 Accepted``, and
:func:`decnet.engine.deployer.teardown_topology` writes the terminal
``torn_down`` status when it finishes.
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from decnet.engine.deployer import teardown_topology
from decnet.telemetry import traced as _traced
from decnet.topology.status import TopologyStatus
from decnet.web.db.models import TopologySummary
from decnet.web.dependencies import repo, require_admin
log = logging.getLogger(__name__)
router = APIRouter()
# Statuses that can legally transition to TEARING_DOWN (see
# decnet.topology.status._LEGAL).
_TEARDOWNABLE: frozenset[str] = frozenset(
{
TopologyStatus.ACTIVE,
TopologyStatus.DEGRADED,
TopologyStatus.FAILED,
TopologyStatus.DEPLOYING,
}
)
async def _run_teardown(topology_id: str) -> None:
try:
await teardown_topology(repo, topology_id)
except asyncio.CancelledError: # pragma: no cover — shutdown
raise
except Exception as exc: # noqa: BLE001
log.error("background teardown of %s failed: %s", topology_id, exc)
@router.post(
"/{topology_id}/teardown",
tags=["MazeNET Topologies"],
response_model=TopologySummary,
status_code=status.HTTP_202_ACCEPTED,
responses={
400: {"description": "Malformed path parameters"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
409: {"description": "Topology cannot be torn down from its current status"},
},
)
@_traced("api.topology.teardown")
async def api_teardown_topology(
topology_id: str,
background: BackgroundTasks,
_admin: dict = Depends(require_admin),
) -> TopologySummary:
topo = await repo.get_topology(topology_id)
if topo is None:
raise HTTPException(status_code=404, detail="Topology not found")
if topo["status"] not in _TEARDOWNABLE:
raise HTTPException(
status_code=409,
detail=(
f"Topology is {topo['status']!r}; cannot teardown "
f"(allowed from: {sorted(_TEARDOWNABLE)})."
),
)
background.add_task(_run_teardown, topology_id)
return TopologySummary(**topo)