feat(swarm): add decnet swarm deckies to list deployed shards by host

`swarm list` only shows enrolled workers — there was no way to see which
deckies are running and where. Adds GET /swarm/deckies on the controller
(joins DeckyShard with SwarmHost for name/address/status) plus the CLI
wrapper with --host / --state filters and --json.
This commit is contained in:
2026-04-18 21:10:07 -04:00
parent 4db9c7464c
commit 8914c27220
6 changed files with 233 additions and 0 deletions

View File

@@ -494,6 +494,66 @@ def swarm_check(
console.print(table)
@swarm_app.command("deckies")
def swarm_deckies(
host: Optional[str] = typer.Option(None, "--host", help="Filter by worker name or UUID"),
state: Optional[str] = typer.Option(None, "--state", help="Filter by shard state (pending|running|failed|torn_down)"),
url: Optional[str] = typer.Option(None, "--url", help="Override swarm controller URL"),
json_out: bool = typer.Option(False, "--json", help="Emit JSON instead of a table"),
) -> None:
"""List deployed deckies across the swarm with their owning worker host."""
base = _swarmctl_base_url(url)
host_uuid: Optional[str] = None
if host:
resp = _http_request("GET", base + "/swarm/hosts")
rows = resp.json()
match = next((r for r in rows if r.get("uuid") == host or r.get("name") == host), None)
if match is None:
console.print(f"[red]No enrolled worker matching '{host}'.[/]")
raise typer.Exit(1)
host_uuid = match["uuid"]
query = []
if host_uuid:
query.append(f"host_uuid={host_uuid}")
if state:
query.append(f"state={state}")
path = "/swarm/deckies" + ("?" + "&".join(query) if query else "")
resp = _http_request("GET", base + path)
rows = resp.json()
if json_out:
console.print_json(data=rows)
return
if not rows:
console.print("[dim]No deckies deployed.[/]")
return
table = Table(title="DECNET swarm deckies")
for col in ("decky", "host", "address", "state", "services"):
table.add_column(col)
for r in rows:
services = ",".join(r.get("services") or []) or ""
state_val = r.get("state") or "pending"
colored = {
"running": f"[green]{state_val}[/]",
"failed": f"[red]{state_val}[/]",
"pending": f"[yellow]{state_val}[/]",
"torn_down": f"[dim]{state_val}[/]",
}.get(state_val, state_val)
table.add_row(
r.get("decky_name") or "",
r.get("host_name") or "<unknown>",
r.get("host_address") or "",
colored,
services,
)
console.print(table)
@swarm_app.command("decommission")
def swarm_decommission(
name: Optional[str] = typer.Option(None, "--name", help="Worker hostname"),

View File

@@ -307,6 +307,20 @@ class SwarmHostView(BaseModel):
notes: Optional[str] = None
class DeckyShardView(BaseModel):
"""One decky → host mapping, enriched with the host's identity for display."""
decky_name: str
host_uuid: str
host_name: str
host_address: str
host_status: str
services: list[str]
state: str
last_error: Optional[str] = None
compose_hash: Optional[str] = None
updated_at: datetime
class SwarmDeployRequest(BaseModel):
config: DecnetConfig
dry_run: bool = False

View File

@@ -15,6 +15,7 @@ from .api_deploy_swarm import router as deploy_swarm_router
from .api_teardown_swarm import router as teardown_swarm_router
from .api_get_swarm_health import router as get_swarm_health_router
from .api_check_hosts import router as check_hosts_router
from .api_list_deckies import router as list_deckies_router
swarm_router = APIRouter(prefix="/swarm")
@@ -27,6 +28,7 @@ swarm_router.include_router(decommission_host_router)
# Deployments
swarm_router.include_router(deploy_swarm_router)
swarm_router.include_router(teardown_swarm_router)
swarm_router.include_router(list_deckies_router)
# Health
swarm_router.include_router(get_swarm_health_router)

View File

@@ -0,0 +1,47 @@
"""GET /swarm/deckies — list decky shards with their worker host's identity.
The DeckyShard table maps decky_name → host_uuid; users want to see which
deckies are running and *where*, so we enrich each shard with the owning
host's name/address/status from SwarmHost rather than making callers do
the join themselves.
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends
from decnet.web.db.repository import BaseRepository
from decnet.web.dependencies import get_repo
from decnet.web.db.models import DeckyShardView
router = APIRouter()
@router.get("/deckies", response_model=list[DeckyShardView], tags=["Swarm Deckies"])
async def api_list_deckies(
host_uuid: Optional[str] = None,
state: Optional[str] = None,
repo: BaseRepository = Depends(get_repo),
) -> list[DeckyShardView]:
shards = await repo.list_decky_shards(host_uuid)
hosts = {h["uuid"]: h for h in await repo.list_swarm_hosts()}
out: list[DeckyShardView] = []
for s in shards:
if state and s.get("state") != state:
continue
host = hosts.get(s["host_uuid"], {})
out.append(DeckyShardView(
decky_name=s["decky_name"],
host_uuid=s["host_uuid"],
host_name=host.get("name") or "<unknown>",
host_address=host.get("address") or "",
host_status=host.get("status") or "unknown",
services=s.get("services") or [],
state=s.get("state") or "pending",
last_error=s.get("last_error"),
compose_hash=s.get("compose_hash"),
updated_at=s["updated_at"],
))
return out

View File

@@ -152,6 +152,65 @@ def test_swarm_check_json_output(http_stub) -> None:
assert '"decky01"' in result.output
# ------------------------------------------------------------- swarm deckies
def test_swarm_deckies_empty(http_stub) -> None:
http_stub.script[("GET", "/swarm/deckies")] = _FakeResp([])
result = runner.invoke(app, ["swarm", "deckies"])
assert result.exit_code == 0, result.output
assert "No deckies" in result.output
def test_swarm_deckies_renders_table(http_stub) -> None:
http_stub.script[("GET", "/swarm/deckies")] = _FakeResp([
{"decky_name": "decky-01", "host_uuid": "u-1", "host_name": "w1",
"host_address": "10.0.0.1", "host_status": "active",
"services": ["ssh"], "state": "running", "last_error": None,
"compose_hash": None, "updated_at": "2026-04-18T00:00:00Z"},
{"decky_name": "decky-02", "host_uuid": "u-2", "host_name": "w2",
"host_address": "10.0.0.2", "host_status": "active",
"services": ["smb", "ssh"], "state": "failed", "last_error": "boom",
"compose_hash": None, "updated_at": "2026-04-18T00:00:00Z"},
])
result = runner.invoke(app, ["swarm", "deckies"])
assert result.exit_code == 0, result.output
assert "decky-01" in result.output
assert "decky-02" in result.output
assert "w1" in result.output and "w2" in result.output
assert "smb,ssh" in result.output
def test_swarm_deckies_json_output(http_stub) -> None:
http_stub.script[("GET", "/swarm/deckies")] = _FakeResp([
{"decky_name": "decky-01", "host_uuid": "u-1", "host_name": "w1",
"host_address": "10.0.0.1", "host_status": "active",
"services": ["ssh"], "state": "running", "last_error": None,
"compose_hash": None, "updated_at": "2026-04-18T00:00:00Z"},
])
result = runner.invoke(app, ["swarm", "deckies", "--json"])
assert result.exit_code == 0
assert '"decky_name"' in result.output
assert '"decky-01"' in result.output
def test_swarm_deckies_filter_by_host_name_looks_up_uuid(http_stub) -> None:
http_stub.script[("GET", "/swarm/hosts")] = _FakeResp([
{"uuid": "u-x", "name": "w1"},
])
http_stub.script[("GET", "/swarm/deckies?host_uuid=u-x")] = _FakeResp([])
result = runner.invoke(app, ["swarm", "deckies", "--host", "w1"])
assert result.exit_code == 0
assert http_stub[-1][1].endswith("/swarm/deckies?host_uuid=u-x")
def test_swarm_deckies_filter_by_state(http_stub) -> None:
http_stub.script[("GET", "/swarm/deckies?state=failed")] = _FakeResp([])
result = runner.invoke(app, ["swarm", "deckies", "--state", "failed"])
assert result.exit_code == 0
assert http_stub[-1][1].endswith("/swarm/deckies?state=failed")
# ------------------------------------------------------------- swarm decommission

View File

@@ -287,6 +287,57 @@ def test_check_marks_hosts_active(client: TestClient, stub_agent) -> None:
assert one["last_heartbeat"] is not None
# ---------------------------------------------------------------- /deckies
def test_list_deckies_empty(client: TestClient) -> None:
resp = client.get("/swarm/deckies")
assert resp.status_code == 200
assert resp.json() == []
def test_list_deckies_joins_host_identity(client: TestClient, repo) -> None:
import asyncio
h1 = client.post(
"/swarm/enroll",
json={"name": "deck-host-1", "address": "10.0.0.11", "agent_port": 8765},
).json()
h2 = client.post(
"/swarm/enroll",
json={"name": "deck-host-2", "address": "10.0.0.12", "agent_port": 8765},
).json()
async def _seed() -> None:
await repo.upsert_decky_shard({
"decky_name": "decky-01", "host_uuid": h1["host_uuid"],
"services": ["ssh"], "state": "running",
})
await repo.upsert_decky_shard({
"decky_name": "decky-02", "host_uuid": h2["host_uuid"],
"services": ["smb", "ssh"], "state": "failed", "last_error": "boom",
})
asyncio.get_event_loop().run_until_complete(_seed())
rows = client.get("/swarm/deckies").json()
assert len(rows) == 2
by_name = {r["decky_name"]: r for r in rows}
assert by_name["decky-01"]["host_name"] == "deck-host-1"
assert by_name["decky-01"]["host_address"] == "10.0.0.11"
assert by_name["decky-01"]["state"] == "running"
assert by_name["decky-02"]["services"] == ["smb", "ssh"]
assert by_name["decky-02"]["last_error"] == "boom"
# host_uuid filter
only = client.get(f"/swarm/deckies?host_uuid={h1['host_uuid']}").json()
assert [r["decky_name"] for r in only] == ["decky-01"]
# state filter
failed = client.get("/swarm/deckies?state=failed").json()
assert [r["decky_name"] for r in failed] == ["decky-02"]
# ---------------------------------------------------------------- /health (root)