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:
@@ -494,6 +494,66 @@ def swarm_check(
|
|||||||
console.print(table)
|
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")
|
@swarm_app.command("decommission")
|
||||||
def swarm_decommission(
|
def swarm_decommission(
|
||||||
name: Optional[str] = typer.Option(None, "--name", help="Worker hostname"),
|
name: Optional[str] = typer.Option(None, "--name", help="Worker hostname"),
|
||||||
|
|||||||
@@ -307,6 +307,20 @@ class SwarmHostView(BaseModel):
|
|||||||
notes: Optional[str] = None
|
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):
|
class SwarmDeployRequest(BaseModel):
|
||||||
config: DecnetConfig
|
config: DecnetConfig
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
|
|||||||
@@ -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_teardown_swarm import router as teardown_swarm_router
|
||||||
from .api_get_swarm_health import router as get_swarm_health_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_check_hosts import router as check_hosts_router
|
||||||
|
from .api_list_deckies import router as list_deckies_router
|
||||||
|
|
||||||
swarm_router = APIRouter(prefix="/swarm")
|
swarm_router = APIRouter(prefix="/swarm")
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ swarm_router.include_router(decommission_host_router)
|
|||||||
# Deployments
|
# Deployments
|
||||||
swarm_router.include_router(deploy_swarm_router)
|
swarm_router.include_router(deploy_swarm_router)
|
||||||
swarm_router.include_router(teardown_swarm_router)
|
swarm_router.include_router(teardown_swarm_router)
|
||||||
|
swarm_router.include_router(list_deckies_router)
|
||||||
|
|
||||||
# Health
|
# Health
|
||||||
swarm_router.include_router(get_swarm_health_router)
|
swarm_router.include_router(get_swarm_health_router)
|
||||||
|
|||||||
47
decnet/web/router/swarm/api_list_deckies.py
Normal file
47
decnet/web/router/swarm/api_list_deckies.py
Normal 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
|
||||||
@@ -152,6 +152,65 @@ def test_swarm_check_json_output(http_stub) -> None:
|
|||||||
assert '"decky01"' in result.output
|
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
|
# ------------------------------------------------------------- swarm decommission
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -287,6 +287,57 @@ def test_check_marks_hosts_active(client: TestClient, stub_agent) -> None:
|
|||||||
assert one["last_heartbeat"] is not 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)
|
# ---------------------------------------------------------------- /health (root)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user