merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
55
decnet/web/router/topology/__init__.py
Normal file
55
decnet/web/router/topology/__init__.py
Normal 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"]
|
||||
53
decnet/web/router/topology/_guards.py
Normal file
53
decnet/web/router/topology/_guards.py
Normal 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")
|
||||
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}")
|
||||
140
decnet/web/router/topology/api_catalog.py
Normal file
140
decnet/web/router/topology/api_catalog.py
Normal 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)
|
||||
123
decnet/web/router/topology/api_create_blank_topology.py
Normal file
123
decnet/web/router/topology/api_create_blank_topology.py
Normal 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)
|
||||
77
decnet/web/router/topology/api_create_topology.py
Normal file
77
decnet/web/router/topology/api_create_topology.py
Normal 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)
|
||||
136
decnet/web/router/topology/api_decky_crud.py
Normal file
136
decnet/web/router/topology/api_decky_crud.py
Normal 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)
|
||||
51
decnet/web/router/topology/api_delete_topology.py
Normal file
51
decnet/web/router/topology/api_delete_topology.py
Normal 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)
|
||||
76
decnet/web/router/topology/api_deploy_topology.py
Normal file
76
decnet/web/router/topology/api_deploy_topology.py
Normal 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)
|
||||
110
decnet/web/router/topology/api_edge_crud.py
Normal file
110
decnet/web/router/topology/api_edge_crud.py
Normal 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)
|
||||
157
decnet/web/router/topology/api_events.py
Normal file
157
decnet/web/router/topology/api_events.py
Normal 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
|
||||
68
decnet/web/router/topology/api_get_topology.py
Normal file
68
decnet/web/router/topology/api_get_topology.py
Normal 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]
|
||||
152
decnet/web/router/topology/api_lan_crud.py
Normal file
152
decnet/web/router/topology/api_lan_crud.py
Normal 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)
|
||||
39
decnet/web/router/topology/api_list_topologies.py
Normal file
39
decnet/web/router/topology/api_list_topologies.py
Normal 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],
|
||||
)
|
||||
127
decnet/web/router/topology/api_mutations.py
Normal file
127
decnet/web/router/topology/api_mutations.py
Normal 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]
|
||||
131
decnet/web/router/topology/api_personas.py
Normal file
131
decnet/web/router/topology/api_personas.py
Normal 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,
|
||||
}
|
||||
48
decnet/web/router/topology/api_reap_orphans.py
Normal file
48
decnet/web/router/topology/api_reap_orphans.py
Normal 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()
|
||||
79
decnet/web/router/topology/api_teardown_topology.py
Normal file
79
decnet/web/router/topology/api_teardown_topology.py
Normal 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)
|
||||
Reference in New Issue
Block a user