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.
85 lines
2.7 KiB
Python
85 lines
2.7 KiB
Python
import pytest
|
|
from unittest.mock import patch
|
|
|
|
from decnet.web.dependencies import repo
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def contract_test_mode(monkeypatch):
|
|
"""Skip actual Docker deployment in tests."""
|
|
monkeypatch.setenv("DECNET_CONTRACT_TEST", "true")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_network():
|
|
"""Mock network detection so deploy doesn't call `ip addr show`."""
|
|
with patch("decnet.web.router.fleet.api_deploy_deckies.get_host_ip", return_value="192.168.1.100"):
|
|
yield
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_deploy_respects_limit(client, auth_token, mock_state_file):
|
|
"""Deploy should reject if the *submitted* INI exceeds the limit.
|
|
The INI is the source of truth — prior state is fully replaced — so the
|
|
check runs on the new decky count alone."""
|
|
await repo.set_state("config_limits", {"deployment_limit": 1})
|
|
await repo.set_state("deployment", mock_state_file)
|
|
|
|
ini = """[decky-a]
|
|
services = ssh
|
|
|
|
[decky-b]
|
|
services = ssh
|
|
"""
|
|
resp = await client.post(
|
|
"/api/v1/deckies/deploy",
|
|
json={"ini_content": ini},
|
|
headers={"Authorization": f"Bearer {auth_token}"},
|
|
)
|
|
# 2 new deckies > limit of 1
|
|
assert resp.status_code == 409
|
|
assert "limit" in resp.json()["detail"].lower()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_deploy_replaces_prior_state(client, auth_token, mock_state_file):
|
|
"""Submitting an INI with 1 decky must not silently re-include the 2
|
|
deckies from prior state (that caused the 'Address already in use'
|
|
regression when stale decky2/decky3 redeployed on stale IPs)."""
|
|
await repo.set_state("config_limits", {"deployment_limit": 10})
|
|
await repo.set_state("deployment", mock_state_file)
|
|
|
|
ini = """[only-decky]
|
|
services = ssh
|
|
"""
|
|
resp = await client.post(
|
|
"/api/v1/deckies/deploy",
|
|
json={"ini_content": ini},
|
|
headers={"Authorization": f"Bearer {auth_token}"},
|
|
)
|
|
assert resp.status_code == 202
|
|
persisted = await repo.get_state("deployment")
|
|
names = [d["name"] for d in persisted["config"]["deckies"]]
|
|
assert names == ["only-decky"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_deploy_within_limit(client, auth_token, mock_state_file):
|
|
"""Deploy should succeed when within limit."""
|
|
await repo.set_state("config_limits", {"deployment_limit": 100})
|
|
await repo.set_state("deployment", mock_state_file)
|
|
|
|
ini = """[decky-new]
|
|
services = ssh
|
|
"""
|
|
resp = await client.post(
|
|
"/api/v1/deckies/deploy",
|
|
json={"ini_content": ini},
|
|
headers={"Authorization": f"Bearer {auth_token}"},
|
|
)
|
|
# Should not fail due to limit
|
|
if resp.status_code == 409:
|
|
assert "limit" not in resp.json()["detail"].lower()
|
|
else:
|
|
assert resp.status_code == 202
|