feat(mutator,web): add_decky op — create-and-attach in one mutation
apply_attach_decky requires an existing decky, so the MazeNET editor had no way to grow a live topology: creating a new decky on active topologies 409'd on the direct-CRUD createDecky call. - Backend: new apply_add_decky that creates the decky row + its home-LAN edge atomically, auto-allocating an IP if none pinned. Post-apply validation still runs. Added to DISPATCH + _MUTATION_OPS Literal + CLI help text. - Tests: 3 new ops tests (happy path, duplicate-name rejection, missing-LAN rejection) plus dispatch coverage update. - Frontend: useTopologyEditor gains addDeckyToLan() composite. Pending routes through createDecky + attachEdge as before; active routes through a single add_decky enqueue. MazeNET.tsx drag-archetype, duplicate, DMZ-gateway, and ctx-menu add-decky paths all use the composite so active topologies stop 409'ing on new-decky drops.
This commit is contained in:
@@ -267,8 +267,8 @@ def _mutate(
|
||||
op: str = typer.Argument(
|
||||
...,
|
||||
help=(
|
||||
"One of: add_lan, remove_lan, attach_decky, detach_decky, "
|
||||
"remove_decky, update_decky, update_lan"
|
||||
"One of: add_lan, remove_lan, add_decky, attach_decky, "
|
||||
"detach_decky, remove_decky, update_decky, update_lan"
|
||||
),
|
||||
),
|
||||
payload_json: str = typer.Option(
|
||||
|
||||
@@ -154,6 +154,78 @@ async def apply_remove_lan(
|
||||
await _assert_valid_after(repo, topology_id)
|
||||
|
||||
|
||||
async def apply_add_decky(
|
||||
repo: Any, topology_id: str, payload: dict[str, Any]
|
||||
) -> None:
|
||||
"""Create a brand-new decky and attach it to its home LAN.
|
||||
|
||||
Used when the editor drags an archetype onto an active topology.
|
||||
``apply_attach_decky`` requires an existing decky, so without this
|
||||
op there is no way to grow a live topology from the UI.
|
||||
|
||||
``payload`` keys:
|
||||
``name`` — decky name (required, unique in topology).
|
||||
``lan`` — home LAN name (required).
|
||||
``services`` — list of service slugs (optional).
|
||||
``archetype`` — slug string; stored in ``decky_config`` (optional).
|
||||
``forwards_l3`` — bool; stored in ``decky_config`` (optional).
|
||||
``ip`` — pinned IP inside the LAN; else auto-allocated.
|
||||
``x``,``y`` — layout coords (optional).
|
||||
"""
|
||||
name = payload["name"]
|
||||
hydrated = await _hydrated(repo, topology_id)
|
||||
if _decky_by_name(hydrated, name) is not None:
|
||||
raise MutationError(f"decky {name!r} already exists")
|
||||
lan = _lan_by_name(hydrated, payload["lan"])
|
||||
if lan is None:
|
||||
raise MutationError(f"LAN {payload['lan']!r} not found")
|
||||
|
||||
ip = payload.get("ip")
|
||||
if ip is None:
|
||||
taken = {
|
||||
d["decky_config"]["ips_by_lan"].get(lan["name"])
|
||||
for d in hydrated["deckies"]
|
||||
if lan["name"] in d["decky_config"].get("ips_by_lan", {})
|
||||
}
|
||||
taken.discard(None)
|
||||
alloc = IPAllocator(subnet=lan["subnet"])
|
||||
for t in taken:
|
||||
if t:
|
||||
alloc.reserve(t)
|
||||
ip = alloc.next_free()
|
||||
|
||||
decky_config: dict[str, Any] = {
|
||||
"name": name,
|
||||
"ips_by_lan": {lan["name"]: ip},
|
||||
}
|
||||
if "archetype" in payload:
|
||||
decky_config["archetype"] = payload["archetype"]
|
||||
forwards_l3 = bool(payload.get("forwards_l3", False))
|
||||
if forwards_l3:
|
||||
decky_config["forwards_l3"] = True
|
||||
|
||||
decky_uuid = await repo.add_topology_decky(
|
||||
{
|
||||
"topology_id": topology_id,
|
||||
"name": name,
|
||||
"services": list(payload.get("services", [])),
|
||||
"decky_config": decky_config,
|
||||
"x": payload.get("x"),
|
||||
"y": payload.get("y"),
|
||||
}
|
||||
)
|
||||
await repo.add_topology_edge(
|
||||
{
|
||||
"topology_id": topology_id,
|
||||
"decky_uuid": decky_uuid,
|
||||
"lan_id": lan["id"],
|
||||
"is_bridge": False,
|
||||
"forwards_l3": forwards_l3,
|
||||
}
|
||||
)
|
||||
await _assert_valid_after(repo, topology_id)
|
||||
|
||||
|
||||
async def apply_attach_decky(
|
||||
repo: Any, topology_id: str, payload: dict[str, Any]
|
||||
) -> None:
|
||||
@@ -326,6 +398,7 @@ async def apply_update_lan(
|
||||
DISPATCH: dict[str, OpFunc] = {
|
||||
"add_lan": apply_add_lan,
|
||||
"remove_lan": apply_remove_lan,
|
||||
"add_decky": apply_add_decky,
|
||||
"attach_decky": apply_attach_decky,
|
||||
"detach_decky": apply_detach_decky,
|
||||
"remove_decky": apply_remove_decky,
|
||||
@@ -358,6 +431,7 @@ __all__ = [
|
||||
"dispatch",
|
||||
"apply_add_lan",
|
||||
"apply_remove_lan",
|
||||
"apply_add_decky",
|
||||
"apply_attach_decky",
|
||||
"apply_detach_decky",
|
||||
"apply_remove_decky",
|
||||
|
||||
@@ -339,8 +339,8 @@ class TopologyMutation(SQLModel, table=True):
|
||||
)
|
||||
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
||||
topology_id: str = Field(foreign_key="topologies.id", index=True)
|
||||
# add_lan|remove_lan|attach_decky|detach_decky|remove_decky|
|
||||
# update_decky|update_lan
|
||||
# add_lan|remove_lan|add_decky|attach_decky|detach_decky|
|
||||
# remove_decky|update_decky|update_lan
|
||||
op: str = Field(index=True)
|
||||
# JSON-serialised op payload (keys depend on ``op``).
|
||||
payload: str = Field(
|
||||
@@ -805,6 +805,7 @@ class EdgeCreateRequest(BaseModel):
|
||||
_MUTATION_OPS = Literal[
|
||||
"add_lan",
|
||||
"remove_lan",
|
||||
"add_decky",
|
||||
"attach_decky",
|
||||
"detach_decky",
|
||||
"remove_decky",
|
||||
|
||||
Reference in New Issue
Block a user