feat(api): /deckies/deploy and /mutate become 202 fire-and-forget
This is the unblock for the wizard hang. Both endpoints used to run
docker compose synchronously inside the HTTP handler -- on master
(unihost) or via asyncio.gather of worker /deploy POSTs at 600s
timeout each (swarm) -- blocking every other API request.
New flow:
1. Commit the new config shape to repo state (fast).
2. Create one DeckyLifecycle row per decky (status=pending).
3. Spawn asyncio.create_task(run_deploy / run_mutate) -- the
lifecycle runner drives rows through running -> succeeded|failed
and emits decky.<name>.lifecycle on the bus.
4. Return 202 with {lifecycle_ids: [...]}. Wizard polls
GET /deckies/lifecycle?ids=... (next commit).
mutator/engine.py gains pick_new_services() -- shared between the
async API path and the watch-loop's synchronous mutate_decky().
DeployResponse grows lifecycle_ids[]. The old dispatch_decnet_config
helper still exists for the CLI swarm-deploy command path; it just
isn't called from the API handler anymore.
Test changes: 200 -> 202, drop dispatch_decnet_config mocks (handler
no longer calls it), assert lifecycle_ids in response + committed
state matches expectations.
This commit is contained in:
@@ -57,7 +57,7 @@ services = ssh
|
||||
json={"ini_content": ini},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 202
|
||||
persisted = await repo.get_state("deployment")
|
||||
names = [d["name"] for d in persisted["config"]["deckies"]]
|
||||
assert names == ["only-decky"]
|
||||
@@ -81,4 +81,4 @@ services = ssh
|
||||
if resp.status_code == 409:
|
||||
assert "limit" not in resp.json()["detail"].lower()
|
||||
else:
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 202
|
||||
|
||||
@@ -24,7 +24,7 @@ def mock_network():
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_automode_unihost_when_no_swarm_hosts(client, auth_token, monkeypatch):
|
||||
"""No swarm hosts enrolled → local unihost deploy."""
|
||||
"""No swarm hosts enrolled → local unihost deploy returns 202 with lifecycle ids."""
|
||||
monkeypatch.setenv("DECNET_MODE", "master")
|
||||
for row in await repo.list_swarm_hosts():
|
||||
await repo.delete_swarm_host(row["uuid"])
|
||||
@@ -36,13 +36,21 @@ async def test_deploy_automode_unihost_when_no_swarm_hosts(client, auth_token, m
|
||||
json={"ini_content": ini},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["mode"] == "unihost"
|
||||
assert resp.status_code == 202, resp.text
|
||||
body = resp.json()
|
||||
assert body["mode"] == "unihost"
|
||||
assert len(body["lifecycle_ids"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_automode_shards_when_swarm_host_enrolled(client, auth_token, monkeypatch):
|
||||
"""Master + one active swarm host → swarm mode, dispatch invoked."""
|
||||
"""Master + one active swarm host → swarm mode, lifecycle rows + 202.
|
||||
|
||||
The handler no longer awaits dispatch synchronously — it commits the
|
||||
new shape, creates lifecycle rows, and spawns the runner. We
|
||||
verify the commit + the per-decky host_uuid assignment via the
|
||||
committed deployment state, and that 202 carries one lifecycle id
|
||||
per decky."""
|
||||
monkeypatch.setenv("DECNET_MODE", "master")
|
||||
await repo.set_state("deployment", None)
|
||||
|
||||
@@ -63,27 +71,23 @@ async def test_deploy_automode_shards_when_swarm_host_enrolled(client, auth_toke
|
||||
"notes": "",
|
||||
})
|
||||
|
||||
fake_response = SwarmDeployResponse(results=[
|
||||
SwarmHostResult(host_uuid="host-A", host_name="worker-a", ok=True, detail={})
|
||||
])
|
||||
ini = "[decky-01]\nservices = ssh\n[decky-02]\nservices = http\n"
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/deploy",
|
||||
json={"ini_content": ini},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.fleet.api_deploy_deckies.dispatch_decnet_config",
|
||||
new=AsyncMock(return_value=fake_response),
|
||||
) as mock_dispatch:
|
||||
ini = "[decky-01]\nservices = ssh\n[decky-02]\nservices = http\n"
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/deploy",
|
||||
json={"ini_content": ini},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 202, resp.text
|
||||
body = resp.json()
|
||||
assert body["mode"] == "swarm"
|
||||
assert len(body["lifecycle_ids"]) == 2
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["mode"] == "swarm"
|
||||
assert mock_dispatch.await_count == 1
|
||||
dispatched_config = mock_dispatch.await_args.args[0]
|
||||
assert dispatched_config.mode == "swarm"
|
||||
assert all(d.host_uuid == "host-A" for d in dispatched_config.deckies)
|
||||
committed = await repo.get_state("deployment")
|
||||
assert committed is not None
|
||||
cfg = committed["config"]
|
||||
assert cfg["mode"] == "swarm"
|
||||
assert {d["host_uuid"] for d in cfg["deckies"]} == {"host-A"}
|
||||
|
||||
await repo.delete_swarm_host("host-A")
|
||||
|
||||
@@ -130,25 +134,19 @@ async def test_deploy_automode_resets_stale_host_uuid(client, auth_token, monkey
|
||||
"compose_path": "",
|
||||
})
|
||||
|
||||
fake_response = SwarmDeployResponse(results=[
|
||||
SwarmHostResult(host_uuid="host-LIVE", host_name="live", ok=True, detail={})
|
||||
])
|
||||
ini = "[decky-new]\nservices = ssh\n"
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/deploy",
|
||||
json={"ini_content": ini},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.fleet.api_deploy_deckies.dispatch_decnet_config",
|
||||
new=AsyncMock(return_value=fake_response),
|
||||
) as mock_dispatch:
|
||||
ini = "[decky-new]\nservices = ssh\n"
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/deploy",
|
||||
json={"ini_content": ini},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
dispatched = mock_dispatch.await_args.args[0]
|
||||
# Both the carried-over decky and the new one must point at the live host.
|
||||
assert {d.host_uuid for d in dispatched.deckies} == {"host-LIVE"}
|
||||
assert resp.status_code == 202, resp.text
|
||||
committed = await repo.get_state("deployment")
|
||||
assert committed is not None
|
||||
cfg = committed["config"]
|
||||
# The carried-over decky and the new one must both point at the live host.
|
||||
assert {d["host_uuid"] for d in cfg["deckies"]} == {"host-LIVE"}
|
||||
|
||||
await repo.delete_swarm_host("host-LIVE")
|
||||
await repo.set_state("deployment", None)
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
"""
|
||||
Tests for the mutate decky API endpoint.
|
||||
"""
|
||||
Tests for the mutate decky API endpoint — now 202 fire-and-forget.
|
||||
|
||||
The handler must:
|
||||
1. Reject anonymous callers (401).
|
||||
2. 404 when no active deployment exists.
|
||||
3. 404 when the named decky isn't in the current state.
|
||||
4. 422 when decky_name pattern fails validation.
|
||||
5. On the happy path, create a DeckyLifecycle row, spawn a background
|
||||
task, return 202 with the row's id.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
|
||||
class TestMutateDecky:
|
||||
@@ -14,30 +25,139 @@ class TestMutateDecky:
|
||||
assert resp.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_mutation(self, client: httpx.AsyncClient, auth_token: str, monkeypatch: pytest.MonkeyPatch):
|
||||
async def test_no_deployment_returns_404(
|
||||
self, client: httpx.AsyncClient, auth_token: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.delenv("DECNET_CONTRACT_TEST", raising=False)
|
||||
with patch("decnet.web.router.fleet.api_mutate_decky.mutate_decky", return_value=True):
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/decky-01/mutate",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "Successfully mutated" in resp.json()["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_mutation_returns_404(self, client: httpx.AsyncClient, auth_token: str, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.delenv("DECNET_CONTRACT_TEST", raising=False)
|
||||
with patch("decnet.web.router.fleet.api_mutate_decky.mutate_decky", return_value=False):
|
||||
with patch(
|
||||
"decnet.web.router.fleet.api_mutate_decky.repo.get_state",
|
||||
new_callable=AsyncMock, return_value=None,
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/decky-01/mutate",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "No active deployment" in resp.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_decky_name_returns_422(self, client: httpx.AsyncClient, auth_token: str):
|
||||
async def test_unknown_decky_returns_404(
|
||||
self, client: httpx.AsyncClient, auth_token: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.delenv("DECNET_CONTRACT_TEST", raising=False)
|
||||
from decnet.config import DecnetConfig, DeckyConfig
|
||||
cfg = DecnetConfig(
|
||||
mode="unihost", interface="eth0",
|
||||
subnet="10.0.0.0/24", gateway="10.0.0.1",
|
||||
deckies=[DeckyConfig(
|
||||
name="decky-existing", ip="10.0.0.10",
|
||||
services=["ssh"], distro="debian",
|
||||
base_image="debian:bookworm-slim", hostname="d01",
|
||||
)],
|
||||
)
|
||||
with patch(
|
||||
"decnet.web.router.fleet.api_mutate_decky.repo.get_state",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"config": cfg.model_dump(), "compose_path": "c.yml"},
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/decky-missing/mutate",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_decky_name_returns_422(
|
||||
self, client: httpx.AsyncClient, auth_token: str,
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/INVALID NAME!!/mutate",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_mutate_returns_202_with_lifecycle_id(
|
||||
self, client: httpx.AsyncClient, auth_token: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.delenv("DECNET_CONTRACT_TEST", raising=False)
|
||||
from decnet.config import DecnetConfig, DeckyConfig
|
||||
cfg = DecnetConfig(
|
||||
mode="unihost", interface="eth0",
|
||||
subnet="10.0.0.0/24", gateway="10.0.0.1",
|
||||
deckies=[DeckyConfig(
|
||||
name="decky-01", ip="10.0.0.10",
|
||||
services=["ssh"], distro="debian",
|
||||
base_image="debian:bookworm-slim", hostname="d01",
|
||||
)],
|
||||
)
|
||||
|
||||
spawned: list[str] = []
|
||||
real_create_task = asyncio.create_task
|
||||
|
||||
def _capture(coro, **kw):
|
||||
spawned.append(kw.get("name", ""))
|
||||
coro.close()
|
||||
async def _noop(): return None
|
||||
return real_create_task(_noop())
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.fleet.api_mutate_decky.repo.get_state",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"config": cfg.model_dump(), "compose_path": "c.yml"},
|
||||
), patch(
|
||||
"decnet.web.router.fleet.api_mutate_decky.repo.set_state",
|
||||
new_callable=AsyncMock, return_value=None,
|
||||
), patch(
|
||||
"decnet.web.router.fleet.api_mutate_decky.repo.create_lifecycle",
|
||||
new_callable=AsyncMock, return_value="lid-abc",
|
||||
), patch(
|
||||
"decnet.web.router.fleet.api_mutate_decky.pick_new_services",
|
||||
return_value=["http", "ftp"],
|
||||
), patch(
|
||||
"decnet.web.router.fleet.api_mutate_decky.asyncio.create_task",
|
||||
side_effect=_capture,
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/decky-01/mutate",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 202, resp.text
|
||||
body = resp.json()
|
||||
assert body["lifecycle_ids"] == ["lid-abc"]
|
||||
assert spawned and spawned[0].startswith("mutate-")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_services_available_returns_404(
|
||||
self, client: httpx.AsyncClient, auth_token: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.delenv("DECNET_CONTRACT_TEST", raising=False)
|
||||
from decnet.config import DecnetConfig, DeckyConfig
|
||||
cfg = DecnetConfig(
|
||||
mode="unihost", interface="eth0",
|
||||
subnet="10.0.0.0/24", gateway="10.0.0.1",
|
||||
deckies=[DeckyConfig(
|
||||
name="decky-01", ip="10.0.0.10",
|
||||
services=["ssh"], distro="debian",
|
||||
base_image="debian:bookworm-slim", hostname="d01",
|
||||
)],
|
||||
)
|
||||
with patch(
|
||||
"decnet.web.router.fleet.api_mutate_decky.repo.get_state",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"config": cfg.model_dump(), "compose_path": "c.yml"},
|
||||
), patch(
|
||||
"decnet.web.router.fleet.api_mutate_decky.pick_new_services",
|
||||
return_value=None,
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/decky-01/mutate",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "No services available" in resp.json()["detail"]
|
||||
|
||||
Reference in New Issue
Block a user