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.
164 lines
6.1 KiB
Python
164 lines
6.1 KiB
Python
"""
|
|
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 httpx
|
|
import pytest
|
|
|
|
|
|
class TestMutateDecky:
|
|
@pytest.mark.asyncio
|
|
async def test_unauthenticated_returns_401(self, client: httpx.AsyncClient):
|
|
resp = await client.post("/api/v1/deckies/decky-01/mutate")
|
|
assert resp.status_code == 401
|
|
|
|
@pytest.mark.asyncio
|
|
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.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_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"]
|