feat: add graceful shutdown endpoint for UVM API server and update documentation
This commit is contained in:
@@ -808,6 +808,7 @@ or secret manager when that matters.
|
|||||||
| `GET` | `/` | `server.py` | Redirect to `/dashboard` |
|
| `GET` | `/` | `server.py` | Redirect to `/dashboard` |
|
||||||
| `GET` | `/dashboard` | `dashboard.py` | Public static dashboard shell |
|
| `GET` | `/dashboard` | `dashboard.py` | Public static dashboard shell |
|
||||||
| `GET` | `/health` | `health.py` | Unauthenticated liveness response |
|
| `GET` | `/health` | `health.py` | Unauthenticated liveness response |
|
||||||
|
| `POST` | `/exit` | `exit.py` | Request graceful Uvicorn shutdown |
|
||||||
| `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks |
|
| `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks |
|
||||||
| `POST` | `/install` | `installation.py` | `{ "force": false }` body |
|
| `POST` | `/install` | `installation.py` | `{ "force": false }` body |
|
||||||
| `GET` | `/vms` | `vms.py` | List persisted VM records and observed status |
|
| `GET` | `/vms` | `vms.py` | List persisted VM records and observed status |
|
||||||
@@ -829,6 +830,12 @@ All management routes use `get_authorized_application()` and require:
|
|||||||
X-UVM-Token: <UVM_API_TOKEN>
|
X-UVM-Token: <UVM_API_TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`POST /exit` is a management route. `run_server()` creates the Uvicorn server,
|
||||||
|
stores it on the FastAPI application state, and runs it in-process. The route
|
||||||
|
returns `202` before a background task sets `should_exit`, allowing Uvicorn to
|
||||||
|
deliver the response and perform its normal graceful shutdown. It does not
|
||||||
|
stop or delete any managed VM.
|
||||||
|
|
||||||
Request schemas reject unexpected fields. `UvmError` instances are rendered as
|
Request schemas reject unexpected fields. `UvmError` instances are rendered as
|
||||||
JSON errors by `server.py`; Pydantic request validation uses FastAPI's normal
|
JSON errors by `server.py`; Pydantic request validation uses FastAPI's normal
|
||||||
422 response format. Create accepts `name: str | null`, `ports: list | null`,
|
422 response format. Create accepts `name: str | null`, `ports: list | null`,
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ The server exposes these routes:
|
|||||||
| `GET` | `/` | Redirect to the local dashboard |
|
| `GET` | `/` | Redirect to the local dashboard |
|
||||||
| `GET` | `/dashboard` | Serve the local management dashboard |
|
| `GET` | `/dashboard` | Serve the local management dashboard |
|
||||||
| `GET` | `/health` | Unauthenticated liveness response |
|
| `GET` | `/health` | Unauthenticated liveness response |
|
||||||
|
| `POST` | `/exit` | Gracefully stop the UVM API server |
|
||||||
| `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view |
|
| `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view |
|
||||||
| `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` |
|
| `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` |
|
||||||
| `GET` | `/vms` | List VM records and observed status |
|
| `GET` | `/vms` | List VM records and observed status |
|
||||||
@@ -364,6 +365,18 @@ curl \
|
|||||||
http://127.0.0.1:8000/vms
|
http://127.0.0.1:8000/vms
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Stop the API server without stopping its managed VMs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST \
|
||||||
|
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||||
|
http://127.0.0.1:8000/exit
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint returns `202 {"status":"stopping"}` and then gracefully terminates
|
||||||
|
the UVM API process. It is a protected management endpoint; it does not stop or
|
||||||
|
delete managed VMs.
|
||||||
|
|
||||||
Do not expose this server directly to an untrusted network. For remote access,
|
Do not expose this server directly to an untrusted network. For remote access,
|
||||||
keep TLS enabled and consider placing it behind an additional authenticated
|
keep TLS enabled and consider placing it behind an additional authenticated
|
||||||
reverse proxy.
|
reverse proxy.
|
||||||
|
|||||||
@@ -141,6 +141,21 @@ go to stderr. VM JSON omits passwords. Lifecycle and sync results are arrays of
|
|||||||
`changes`). `ports list --json` returns an array even for one VM. JSON does
|
`changes`). `ports list --json` returns an array even for one VM. JSON does
|
||||||
not disable confirmations, and errors may still be plain text on stderr.
|
not disable confirmations, and errors may still be plain text on stderr.
|
||||||
|
|
||||||
|
### Stop the Management API
|
||||||
|
|
||||||
|
The API can be stopped remotely through its authenticated shutdown endpoint.
|
||||||
|
This stops only the UVM API process; it does not stop or delete any VM:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST \
|
||||||
|
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||||
|
http://127.0.0.1:8000/exit
|
||||||
|
```
|
||||||
|
|
||||||
|
The server first returns `202 {"status":"stopping"}`, then performs Uvicorn's
|
||||||
|
graceful shutdown. Use the actual API host and port when they differ from the
|
||||||
|
defaults. `GET /exit` is intentionally not supported.
|
||||||
|
|
||||||
## 4. Install UVM Assets
|
## 4. Install UVM Assets
|
||||||
|
|
||||||
UVM installs host packages, Firecracker, Jailer, a guest kernel, and a guest
|
UVM installs host packages, Firecracker, Jailer, a guest kernel, and a guest
|
||||||
|
|||||||
+39
-8
@@ -7,7 +7,7 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from uvm.cli import build_parser, main
|
from uvm.cli import build_parser, main
|
||||||
from uvm.config import Settings
|
from uvm.config import Settings
|
||||||
@@ -99,19 +99,31 @@ class ServerCliTests(unittest.TestCase):
|
|||||||
application = fake_application(api_token="test-token")
|
application = fake_application(api_token="test-token")
|
||||||
application.settings.api_tls_cert = certificate
|
application.settings.api_tls_cert = certificate
|
||||||
application.settings.api_tls_key = key
|
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 (
|
with (
|
||||||
patch("uvm.server._validate_server_settings"),
|
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.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_server(application, host="0.0.0.0", port=8443)
|
||||||
|
|
||||||
run_mock.assert_called_once()
|
fake_uvicorn.Config.assert_called_once_with(
|
||||||
self.assertEqual(run_mock.call_args.kwargs["ssl_certfile"], str(certificate))
|
api,
|
||||||
self.assertEqual(run_mock.call_args.kwargs["ssl_keyfile"], str(key))
|
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:
|
def test_api_token_rejects_non_ascii_or_whitespace(self) -> None:
|
||||||
for token in ("s\u00e9cret", "contains space", ""):
|
for token in ("s\u00e9cret", "contains space", ""):
|
||||||
@@ -300,7 +312,8 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
application.state_store = SimpleNamespace(initialize=lambda: None)
|
application.state_store = SimpleNamespace(initialize=lambda: None)
|
||||||
self.headers = {"X-UVM-Token": "test-token"}
|
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)
|
self.addCleanup(self.client.close)
|
||||||
|
|
||||||
def test_health_is_available_without_credentials(self) -> None:
|
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", {}),
|
||||||
("POST", "/vms/test-vm/ports/sync", {}),
|
("POST", "/vms/test-vm/ports/sync", {}),
|
||||||
("GET", "/diagnostics", {}),
|
("GET", "/diagnostics", {}),
|
||||||
|
("POST", "/exit", {}),
|
||||||
("POST", "/install", {}),
|
("POST", "/install", {}),
|
||||||
):
|
):
|
||||||
for headers in ({}, {"X-UVM-Token": "wrong-token"}):
|
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.assertEqual(authorized.json()[0]["id"], "vm-test")
|
||||||
self.assertNotIn("password", authorized.json()[0])
|
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:
|
def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
|
||||||
created = self.client.post(
|
created = self.client.post(
|
||||||
"/vms",
|
"/vms",
|
||||||
@@ -752,6 +782,7 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
("/", "get"),
|
("/", "get"),
|
||||||
("/dashboard", "get"),
|
("/dashboard", "get"),
|
||||||
("/health", "get"),
|
("/health", "get"),
|
||||||
|
("/exit", "post"),
|
||||||
("/install", "post"),
|
("/install", "post"),
|
||||||
("/vms", "get"),
|
("/vms", "get"),
|
||||||
("/vms", "post"),
|
("/vms", "post"),
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class HealthResponse(BaseModel):
|
|||||||
status: str = "ok"
|
status: str = "ok"
|
||||||
|
|
||||||
|
|
||||||
|
class ServerExitResponse(BaseModel):
|
||||||
|
status: str = "stopping"
|
||||||
|
|
||||||
|
|
||||||
class InstallRequest(BaseModel):
|
class InstallRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Authenticated graceful shutdown endpoint for the UVM API server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
from ..api_models import ServerExitResponse
|
||||||
|
from ..app import Application
|
||||||
|
from .dependencies import get_authorized_application
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["server"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/exit", response_model=ServerExitResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
def exit_server(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
request: Request,
|
||||||
|
_application: Application = Depends(get_authorized_application),
|
||||||
|
) -> ServerExitResponse:
|
||||||
|
"""Stop the Uvicorn process after this response has been delivered."""
|
||||||
|
server = getattr(request.app.state, "uvicorn_server", None)
|
||||||
|
if server is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="server shutdown is unavailable outside uvm --serve",
|
||||||
|
)
|
||||||
|
background_tasks.add_task(_request_server_exit, server)
|
||||||
|
return ServerExitResponse()
|
||||||
|
|
||||||
|
|
||||||
|
def _request_server_exit(server: Any) -> None:
|
||||||
|
server.should_exit = True
|
||||||
+6
-1
@@ -35,6 +35,7 @@ def create_api(application: Application | None = None, *, host: str | None = Non
|
|||||||
) from error
|
) from error
|
||||||
|
|
||||||
from .routers.health import router as health_router
|
from .routers.health import router as health_router
|
||||||
|
from .routers.exit import router as exit_router
|
||||||
from .routers.installation import router as installation_router
|
from .routers.installation import router as installation_router
|
||||||
from .routers.vms import router as vms_router
|
from .routers.vms import router as vms_router
|
||||||
from .routers.diagnostics import router as diagnostics_router
|
from .routers.diagnostics import router as diagnostics_router
|
||||||
@@ -55,6 +56,7 @@ def create_api(application: Application | None = None, *, host: str | None = Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
api.include_router(health_router)
|
api.include_router(health_router)
|
||||||
|
api.include_router(exit_router)
|
||||||
api.include_router(installation_router)
|
api.include_router(installation_router)
|
||||||
api.include_router(vms_router)
|
api.include_router(vms_router)
|
||||||
api.include_router(diagnostics_router)
|
api.include_router(diagnostics_router)
|
||||||
@@ -91,7 +93,10 @@ def run_server(application: Application, *, host: str, port: int) -> None:
|
|||||||
if settings.api_tls_cert is not None:
|
if settings.api_tls_cert is not None:
|
||||||
options["ssl_certfile"] = str(settings.api_tls_cert)
|
options["ssl_certfile"] = str(settings.api_tls_cert)
|
||||||
options["ssl_keyfile"] = str(settings.api_tls_key)
|
options["ssl_keyfile"] = str(settings.api_tls_key)
|
||||||
uvicorn.run(create_api(application, host=host), **options)
|
api = create_api(application, host=host)
|
||||||
|
server = uvicorn.Server(uvicorn.Config(api, **options))
|
||||||
|
api.state.uvicorn_server = server
|
||||||
|
server.run()
|
||||||
|
|
||||||
|
|
||||||
def _is_loopback_host(host: str) -> bool:
|
def _is_loopback_host(host: str) -> bool:
|
||||||
|
|||||||
Reference in New Issue
Block a user