feat: add graceful shutdown endpoint for UVM API server and update documentation

This commit is contained in:
2026-09-23 06:30:58 +05:30
parent 0f72ac693a
commit eb6119c797
7 changed files with 119 additions and 9 deletions
+39 -8
View File
@@ -7,7 +7,7 @@ import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import Mock, patch
from uvm.cli import build_parser, main
from uvm.config import Settings
@@ -99,19 +99,31 @@ class ServerCliTests(unittest.TestCase):
application = fake_application(api_token="test-token")
application.settings.api_tls_cert = certificate
application.settings.api_tls_key = key
fake_uvicorn = SimpleNamespace(run=lambda *_args, **_kwargs: None)
api = SimpleNamespace(state=SimpleNamespace())
server = SimpleNamespace(run=Mock())
config = object()
fake_uvicorn = SimpleNamespace(
Config=Mock(return_value=config),
Server=Mock(return_value=server),
)
with (
patch("uvm.server._validate_server_settings"),
patch("uvm.server.create_api", return_value=object()),
patch("uvm.server.create_api", return_value=api),
patch.dict(sys.modules, {"uvicorn": fake_uvicorn}),
patch.object(fake_uvicorn, "run") as run_mock,
):
run_server(application, host="0.0.0.0", port=8443)
run_mock.assert_called_once()
self.assertEqual(run_mock.call_args.kwargs["ssl_certfile"], str(certificate))
self.assertEqual(run_mock.call_args.kwargs["ssl_keyfile"], str(key))
fake_uvicorn.Config.assert_called_once_with(
api,
host="0.0.0.0",
port=8443,
ssl_certfile=str(certificate),
ssl_keyfile=str(key),
)
fake_uvicorn.Server.assert_called_once_with(config)
self.assertIs(api.state.uvicorn_server, server)
server.run.assert_called_once_with()
def test_api_token_rejects_non_ascii_or_whitespace(self) -> None:
for token in ("s\u00e9cret", "contains space", ""):
@@ -300,7 +312,8 @@ class ApiRouterTests(unittest.TestCase):
)
application.state_store = SimpleNamespace(initialize=lambda: None)
self.headers = {"X-UVM-Token": "test-token"}
self.client = TestClient(create_api(application, host="127.0.0.1"))
self.api = create_api(application, host="127.0.0.1")
self.client = TestClient(self.api)
self.addCleanup(self.client.close)
def test_health_is_available_without_credentials(self) -> None:
@@ -339,6 +352,7 @@ class ApiRouterTests(unittest.TestCase):
("POST", "/vms/test-vm/ports", {}),
("POST", "/vms/test-vm/ports/sync", {}),
("GET", "/diagnostics", {}),
("POST", "/exit", {}),
("POST", "/install", {}),
):
for headers in ({}, {"X-UVM-Token": "wrong-token"}):
@@ -353,6 +367,22 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(authorized.json()[0]["id"], "vm-test")
self.assertNotIn("password", authorized.json()[0])
def test_exit_requests_a_graceful_authenticated_server_shutdown(self) -> None:
server = SimpleNamespace(should_exit=False)
self.api.state.uvicorn_server = server
response = self.client.post("/exit", headers=self.headers)
self.assertEqual(response.status_code, 202)
self.assertEqual(response.json(), {"status": "stopping"})
self.assertTrue(server.should_exit)
def test_exit_requires_a_running_uvicorn_server(self) -> None:
response = self.client.post("/exit", headers=self.headers)
self.assertEqual(response.status_code, 503)
self.assertEqual(response.json(), {"detail": "server shutdown is unavailable outside uvm --serve"})
def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
created = self.client.post(
"/vms",
@@ -752,6 +782,7 @@ class ApiRouterTests(unittest.TestCase):
("/", "get"),
("/dashboard", "get"),
("/health", "get"),
("/exit", "post"),
("/install", "post"),
("/vms", "get"),
("/vms", "post"),