Добавлена возможность регистрировать прикладные апи

This commit is contained in:
2026-04-30 13:47:23 +03:00
parent 9eb7282437
commit 90422a0c2a
14 changed files with 474 additions and 5 deletions
+71 -2
View File
@@ -40,6 +40,7 @@ PLBA (`Platform Runtime for Business Applications`) - это runtime-слой д
- `health` (`HealthRegistry`) - агрегирование здоровья воркеров и дополнительных компонентов. - `health` (`HealthRegistry`) - агрегирование здоровья воркеров и дополнительных компонентов.
- `workflow` (`WorkflowEngine` и persistence-слой) - исполнение шагов бизнес-процесса с переходами и фиксацией состояния. - `workflow` (`WorkflowEngine` и persistence-слой) - исполнение шагов бизнес-процесса с переходами и фиксацией состояния.
- `control plane` (`ControlPlaneService`, `HttpControlChannel`) - внешние health/action endpoints. - `control plane` (`ControlPlaneService`, `HttpControlChannel`) - внешние health/action endpoints.
- `application HTTP` (`ApplicationHttpService`, `HttpApplicationChannel`) - пользовательские HTTP routes бизнес-приложения.
- `queue` (`InMemoryTaskQueue`) - локальный in-memory буфер как утилита прикладного уровня. - `queue` (`InMemoryTaskQueue`) - локальный in-memory буфер как утилита прикладного уровня.
## 3. Архитектура ## 3. Архитектура
@@ -89,6 +90,7 @@ classDiagram
class HealthRegistry class HealthRegistry
class TraceService class TraceService
class ControlPlaneService class ControlPlaneService
class ApplicationHttpService
class WorkflowRuntimeFactory class WorkflowRuntimeFactory
class WorkflowEngine class WorkflowEngine
class WorkflowPersistence class WorkflowPersistence
@@ -106,6 +108,7 @@ classDiagram
RuntimeManager --> TraceService RuntimeManager --> TraceService
RuntimeManager --> WorkerSupervisor RuntimeManager --> WorkerSupervisor
RuntimeManager --> ControlPlaneService RuntimeManager --> ControlPlaneService
RuntimeManager --> ApplicationHttpService
WorkerSupervisor --> Worker WorkerSupervisor --> Worker
Worker --> Routine : invokes Worker --> Routine : invokes
@@ -128,6 +131,7 @@ classDiagram
- Как работает / API / вызовы / таблицы: - Как работает / API / вызовы / таблицы:
- API: `name`, `register(registry)`. - API: `name`, `register(registry)`.
- Типичные вызовы: `registry.add_worker(worker)`, `registry.add_health_contributor(contributor)`. - Типичные вызовы: `registry.add_worker(worker)`, `registry.add_health_contributor(contributor)`.
- Для HTTP-модулей: `registry.add_http_routes(registrar)`.
- `RuntimeManager.register_module()` вызывает `module.register(...)` и добавляет имя модуля в снимок runtime. - `RuntimeManager.register_module()` вызывает `module.register(...)` и добавляет имя модуля в снимок runtime.
- В БД напрямую не пишет. - В БД напрямую не пишет.
- Типовая схема использования: - Типовая схема использования:
@@ -314,7 +318,72 @@ with traces.open_context(alias="email:123", kind="email") as message_trace_id:
- Producer в рутине кладет элементы через `put`. - Producer в рутине кладет элементы через `put`.
- Consumer-воркер извлекает через `get(timeout)` и обрабатывает. - Consumer-воркер извлекает через `get(timeout)` и обрабатывает.
## 5. MVP бизнес-приложения ## 5. Application HTTP
`PLBA` поддерживает отдельный прикладной HTTP-слой для пользовательских страниц и API бизнес-приложения. Он не смешивается с `control plane` и поднимается отдельным сервисом внутри того же runtime.
`Control Plane` и `Application HTTP` обслуживают разные контуры:
- `Control Plane` используется для `/health`, `/actions/*`, `/traces/*`.
- `Application HTTP` используется для бизнес-маршрутов приложения, например `/estimate`, `/estimate/api/tasks`, `/api/orders`.
### Основные компоненты
- `ApplicationHttpService` управляет lifecycle прикладного HTTP на уровне runtime.
- `HttpApplicationChannel` поднимает отдельный `FastAPI` app через `uvicorn`.
- `HttpRouteRegistrar` регистрирует пользовательские routes и получает `ServiceContainer`.
- `HttpApplicationAppFactory` собирает `FastAPI(title="PLBA Application API")`, middleware и routes.
### Минимальный пример
```python
from fastapi import FastAPI, File, UploadFile
from fastapi.responses import HTMLResponse
from plba import ApplicationModule, HttpApplicationChannel, create_runtime
class DemoRoutes:
def register(self, app: FastAPI, services) -> None:
@app.get("/demo")
async def demo_page():
return HTMLResponse("<h1>Demo</h1>")
@app.post("/demo/api/tasks")
async def create_task(file: UploadFile = File(...)):
payload = await file.read()
return {"filename": file.filename, "size": len(payload)}
class DemoModule(ApplicationModule):
@property
def name(self) -> str:
return "demo"
def register(self, registry) -> None:
registry.add_http_routes(DemoRoutes())
runtime = create_runtime(DemoModule(), config_path="config.yml")
runtime.application_http.register_channel(
HttpApplicationChannel(host="0.0.0.0", port=15000, timeout=5)
)
runtime.start()
```
После старта runtime приложение будет обслуживать:
- `GET /demo`
- `POST /demo/api/tasks`
### Типовые сценарии
- `Web UI для фоновых задач`: список задач, запуск, статус, cancel, download result.
- `Внутренний REST API`: например `POST /jobs`, `GET /jobs/{id}`, `POST /jobs/{id}/cancel`.
- `Файловый шлюз`: загрузка входного файла, асинхронная обработка через worker, скачивание результата.
- `Webhook endpoint`: прием callback от внешней системы и передача события в worker pipeline.
### Ограничения и рекомендации
- Не смешивайте business routes с `control plane`.
- Держите бизнес-логику в сервисах, а handlers используйте как тонкий HTTP-адаптер.
- Runtime предоставляет доступ к зависимостям через `services`, поэтому handlers не должны зависеть от `RuntimeManager`.
- В первой версии `Application HTTP` не решает auth, static files, шаблонизаторы, websocket и OpenAPI customization.
- Для публичного доступа выносите auth, TLS и reverse proxy на внешний ingress.
## 6. MVP бизнес-приложения
Минимальная конфигурация запуска: Минимальная конфигурация запуска:
1. Создать один `ApplicationModule`. 1. Создать один `ApplicationModule`.
2. В модуле собрать одну `Routine` и один `Worker` (1 worker -> 1 routine). 2. В модуле собрать одну `Routine` и один `Worker` (1 worker -> 1 routine).
@@ -374,4 +443,4 @@ runtime = create_runtime(DemoModule(), config_path="config.yml")
runtime.start() runtime.start()
``` ```
Для production-сценария после MVP обычно добавляют `tracing`, `health contributors`, `workflow` и HTTP control plane, но базовый запуск не требует этих расширений. Для production-сценария после MVP обычно добавляют `tracing`, `health contributors`, `workflow`, HTTP control plane и при необходимости `application HTTP`, но базовый запуск не требует этих расширений.
+1
View File
@@ -12,6 +12,7 @@ dependencies = [
"fastapi>=0.129.0", "fastapi>=0.129.0",
"PyMySQL>=1.1", "PyMySQL>=1.1",
"PyYAML>=6.0.3", "PyYAML>=6.0.3",
"python-multipart>=0.0.9",
"uvicorn>=0.41.0", "uvicorn>=0.41.0",
] ]
+3 -2
View File
@@ -8,10 +8,11 @@ from uvicorn import Config, Server
class UvicornThreadRunner: class UvicornThreadRunner:
def __init__(self, host: str, port: int, timeout: int) -> None: def __init__(self, host: str, port: int, timeout: int, *, thread_name: str = "plba-http-control") -> None:
self._host = host self._host = host
self._port = port self._port = port
self._timeout = timeout self._timeout = timeout
self._thread_name = thread_name
self._server: Server | None = None self._server: Server | None = None
self._thread: Thread | None = None self._thread: Thread | None = None
self._error: BaseException | None = None self._error: BaseException | None = None
@@ -22,7 +23,7 @@ class UvicornThreadRunner:
self._error = None self._error = None
config = Config(app=app, host=self._host, port=self._port, log_level="warning") config = Config(app=app, host=self._host, port=self._port, log_level="warning")
self._server = Server(config) self._server = Server(config)
self._thread = Thread(target=self._serve, name="plba-http-control", daemon=True) self._thread = Thread(target=self._serve, name=self._thread_name, daemon=True)
self._thread.start() self._thread.start()
await self._wait_until_started() await self._wait_until_started()
+5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from app_runtime.contracts.health import HealthContributor from app_runtime.contracts.health import HealthContributor
from app_runtime.contracts.worker import Worker from app_runtime.contracts.worker import Worker
from app_runtime.core.service_container import ServiceContainer from app_runtime.core.service_container import ServiceContainer
from app_runtime.http.base import HttpRouteRegistrar
class ModuleRegistry: class ModuleRegistry:
@@ -10,6 +11,7 @@ class ModuleRegistry:
self.services = services self.services = services
self.workers: list[Worker] = [] self.workers: list[Worker] = []
self.health_contributors: list[HealthContributor] = [] self.health_contributors: list[HealthContributor] = []
self.http_route_registrars: list[HttpRouteRegistrar] = []
self.modules: list[str] = [] self.modules: list[str] = []
def register_module(self, name: str) -> None: def register_module(self, name: str) -> None:
@@ -20,3 +22,6 @@ class ModuleRegistry:
def add_health_contributor(self, contributor: HealthContributor) -> None: def add_health_contributor(self, contributor: HealthContributor) -> None:
self.health_contributors.append(contributor) self.health_contributors.append(contributor)
def add_http_routes(self, registrar: HttpRouteRegistrar) -> None:
self.http_route_registrars.append(registrar)
+13
View File
@@ -13,6 +13,7 @@ from app_runtime.core.registration import ModuleRegistry
from app_runtime.core.service_container import ServiceContainer from app_runtime.core.service_container import ServiceContainer
from app_runtime.core.types import HealthPayload, LifecycleState from app_runtime.core.types import HealthPayload, LifecycleState
from app_runtime.health.registry import HealthRegistry from app_runtime.health.registry import HealthRegistry
from app_runtime.http.service import ApplicationHttpService
from app_runtime.logging.manager import LogManager from app_runtime.logging.manager import LogManager
from app_runtime.tracing.reader import build_trace_log_reader from app_runtime.tracing.reader import build_trace_log_reader
from app_runtime.tracing.service import TraceService from app_runtime.tracing.service import TraceService
@@ -32,6 +33,7 @@ class RuntimeManager:
logs: LogManager | None = None, logs: LogManager | None = None,
workers: WorkerSupervisor | None = None, workers: WorkerSupervisor | None = None,
control_plane: ControlPlaneService | None = None, control_plane: ControlPlaneService | None = None,
application_http: ApplicationHttpService | None = None,
) -> None: ) -> None:
self.configuration = configuration or ConfigurationManager() self.configuration = configuration or ConfigurationManager()
self.services = services or ServiceContainer() self.services = services or ServiceContainer()
@@ -40,6 +42,7 @@ class RuntimeManager:
self.logs = logs or LogManager() self.logs = logs or LogManager()
self.workers = workers or WorkerSupervisor() self.workers = workers or WorkerSupervisor()
self.control_plane = control_plane or ControlPlaneService() self.control_plane = control_plane or ControlPlaneService()
self.application_http = application_http or ApplicationHttpService()
self.registry = ModuleRegistry(self.services) self.registry = ModuleRegistry(self.services)
self._started = False self._started = False
self._state = LifecycleState.IDLE self._state = LifecycleState.IDLE
@@ -67,6 +70,8 @@ class RuntimeManager:
self.workers.start() self.workers.start()
if start_control_plane: if start_control_plane:
self.control_plane.start(self) self.control_plane.start(self)
self._register_application_http_routes()
self.application_http.start(self)
self._started = True self._started = True
self._refresh_state() self._refresh_state()
@@ -75,6 +80,7 @@ class RuntimeManager:
return return
self._state = LifecycleState.STOPPING self._state = LifecycleState.STOPPING
self.workers.stop(timeout=timeout, force=force) self.workers.stop(timeout=timeout, force=force)
self.application_http.stop()
if stop_control_plane: if stop_control_plane:
self.control_plane.stop() self.control_plane.stop()
self._started = False self._started = False
@@ -120,6 +126,7 @@ class RuntimeManager:
except TimeoutError: except TimeoutError:
return self._action_detail("runtime stop is still in progress", timed_out=True) return self._action_detail("runtime stop is still in progress", timed_out=True)
self.application_http.stop()
self._refresh_state() self._refresh_state()
if self._state == LifecycleState.STOPPED: if self._state == LifecycleState.STOPPED:
self._started = False self._started = False
@@ -148,6 +155,7 @@ class RuntimeManager:
self.services.register("logs", self.logs) self.services.register("logs", self.logs)
self.services.register("workers", self.workers) self.services.register("workers", self.workers)
self.services.register("control_plane", self.control_plane) self.services.register("control_plane", self.control_plane)
self.services.register("application_http", self.application_http)
self._core_registered = True self._core_registered = True
def _register_health_contributors(self) -> None: def _register_health_contributors(self) -> None:
@@ -161,6 +169,11 @@ class RuntimeManager:
self.workers.register(worker) self.workers.register(worker)
self._workers_registered = True self._workers_registered = True
def _register_application_http_routes(self) -> None:
for registrar in self.registry.http_route_registrars:
self.application_http.register_routes(registrar)
self.registry.http_route_registrars.clear()
def _refresh_state(self) -> None: def _refresh_state(self) -> None:
lifecycle = self.workers.lifecycle_state() lifecycle = self.workers.lifecycle_state()
+12
View File
@@ -0,0 +1,12 @@
from app_runtime.http.base import ApplicationHttpChannel, HttpRouteRegistrar
from app_runtime.http.http_app import HttpApplicationAppFactory
from app_runtime.http.http_channel import HttpApplicationChannel
from app_runtime.http.service import ApplicationHttpService
__all__ = [
"ApplicationHttpChannel",
"ApplicationHttpService",
"HttpApplicationAppFactory",
"HttpApplicationChannel",
"HttpRouteRegistrar",
]
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Protocol
from fastapi import FastAPI
from app_runtime.core.service_container import ServiceContainer
class HttpRouteRegistrar(Protocol):
def register(self, app: FastAPI, services: ServiceContainer) -> None:
"""Register application routes on the provided FastAPI app."""
class ApplicationHttpChannel(ABC):
@abstractmethod
async def start(self, app: FastAPI) -> None:
"""Start the HTTP channel with the prepared application app."""
@abstractmethod
async def stop(self) -> None:
"""Stop the HTTP channel and release resources."""
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import logging
import time
from collections.abc import Iterable
from fastapi import FastAPI, Request
from app_runtime.core.service_container import ServiceContainer
from app_runtime.http.base import HttpRouteRegistrar
LOGGER = logging.getLogger(__name__)
class HttpApplicationAppFactory:
def create(self, registrars: Iterable[HttpRouteRegistrar], services: ServiceContainer) -> FastAPI:
app = FastAPI(title="PLBA Application API")
self._register_middleware(app)
for registrar in registrars:
registrar.register(app, services)
return app
def _register_middleware(self, app: FastAPI) -> None:
@app.middleware("http")
async def track_request(request: Request, call_next): # type: ignore[no-untyped-def]
started = time.monotonic()
response = await call_next(request)
duration_ms = int((time.monotonic() - started) * 1000)
response.headers["X-Response-Time-Ms"] = str(duration_ms)
LOGGER.info(
"Application HTTP request handled: method=%s path=%s status=%s duration_ms=%s",
request.method,
request.url.path,
response.status_code,
duration_ms,
)
return response
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from fastapi import FastAPI
from app_runtime.control.http_runner import UvicornThreadRunner
from app_runtime.http.base import ApplicationHttpChannel
class HttpApplicationChannel(ApplicationHttpChannel):
def __init__(self, host: str, port: int, timeout: int) -> None:
self._runner = UvicornThreadRunner(host, port, timeout, thread_name="plba-http-application")
async def start(self, app: FastAPI) -> None:
await self._runner.start(app)
async def stop(self) -> None:
await self._runner.stop()
@property
def port(self) -> int:
return self._runner.port
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from app_runtime.http.base import ApplicationHttpChannel, HttpRouteRegistrar
from app_runtime.http.http_app import HttpApplicationAppFactory
if TYPE_CHECKING:
from app_runtime.core.runtime import RuntimeManager
class ApplicationHttpService:
def __init__(self, app_factory: HttpApplicationAppFactory | None = None) -> None:
self._channels: list[ApplicationHttpChannel] = []
self._registrars: list[HttpRouteRegistrar] = []
self._app_factory = app_factory or HttpApplicationAppFactory()
def register_channel(self, channel: ApplicationHttpChannel) -> None:
self._channels.append(channel)
def register_routes(self, registrar: HttpRouteRegistrar) -> None:
self._registrars.append(registrar)
def start(self, runtime: RuntimeManager) -> None:
if not self._channels:
return
asyncio.run(self._start_async(runtime))
def stop(self) -> None:
if not self._channels:
return
asyncio.run(self._stop_async())
async def _start_async(self, runtime: RuntimeManager) -> None:
app = self._app_factory.create(self._registrars, runtime.services)
for channel in self._channels:
await channel.start(app)
async def _stop_async(self) -> None:
for channel in reversed(self._channels):
await channel.stop()
+12
View File
@@ -15,6 +15,13 @@ from plba.contracts import (
) )
from plba.core import ConfigurationManager, RuntimeManager, ServiceContainer from plba.core import ConfigurationManager, RuntimeManager, ServiceContainer
from plba.health import HealthRegistry from plba.health import HealthRegistry
from plba.http import (
ApplicationHttpChannel,
ApplicationHttpService,
HttpApplicationAppFactory,
HttpApplicationChannel,
HttpRouteRegistrar,
)
from plba.logging import LogManager from plba.logging import LogManager
from plba.queue import InMemoryTaskQueue from plba.queue import InMemoryTaskQueue
from plba.tracing import MySqlTraceTransport, NoOpTraceTransport, TraceService from plba.tracing import MySqlTraceTransport, NoOpTraceTransport, TraceService
@@ -43,6 +50,11 @@ __all__ = [
"FileConfigProvider", "FileConfigProvider",
"HealthContributor", "HealthContributor",
"HealthRegistry", "HealthRegistry",
"ApplicationHttpChannel",
"ApplicationHttpService",
"HttpApplicationAppFactory",
"HttpApplicationChannel",
"HttpRouteRegistrar",
"HttpControlChannel", "HttpControlChannel",
"InMemoryTaskQueue", "InMemoryTaskQueue",
"LogManager", "LogManager",
+12
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from app_runtime.control.http_channel import HttpControlChannel from app_runtime.control.http_channel import HttpControlChannel
from app_runtime.core.runtime import RuntimeManager from app_runtime.core.runtime import RuntimeManager
from app_runtime.contracts.application import ApplicationModule from app_runtime.contracts.application import ApplicationModule
from app_runtime.http.http_channel import HttpApplicationChannel
def create_runtime( def create_runtime(
@@ -13,6 +14,9 @@ def create_runtime(
control_host: str = "127.0.0.1", control_host: str = "127.0.0.1",
control_port: int = 8080, control_port: int = 8080,
control_timeout: int = 5, control_timeout: int = 5,
application_host: str | None = None,
application_port: int = 15000,
application_timeout: int = 5,
) -> RuntimeManager: ) -> RuntimeManager:
runtime = RuntimeManager() runtime = RuntimeManager()
if config_path is not None: if config_path is not None:
@@ -25,5 +29,13 @@ def create_runtime(
timeout=control_timeout, timeout=control_timeout,
) )
) )
if application_host is not None:
runtime.application_http.register_channel(
HttpApplicationChannel(
host=application_host,
port=application_port,
timeout=application_timeout,
)
)
runtime.register_module(module) runtime.register_module(module)
return runtime return runtime
+12
View File
@@ -0,0 +1,12 @@
from app_runtime.http.base import ApplicationHttpChannel, HttpRouteRegistrar
from app_runtime.http.http_app import HttpApplicationAppFactory
from app_runtime.http.http_channel import HttpApplicationChannel
from app_runtime.http.service import ApplicationHttpService
__all__ = [
"ApplicationHttpChannel",
"ApplicationHttpService",
"HttpApplicationAppFactory",
"HttpApplicationChannel",
"HttpRouteRegistrar",
]
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
import http.client
from dataclasses import dataclass
from pathlib import Path
from fastapi import FastAPI, File, UploadFile
from fastapi.responses import FileResponse
from fastapi.testclient import TestClient
import pytest
from app_runtime.contracts.application import ApplicationModule
from app_runtime.control.http_channel import HttpControlChannel
from app_runtime.core.registration import ModuleRegistry
from app_runtime.core.runtime import RuntimeManager
from app_runtime.http.base import ApplicationHttpChannel
from app_runtime.http.http_channel import HttpApplicationChannel
try:
import python_multipart # noqa: F401
except ImportError:
HAS_MULTIPART = False
else:
HAS_MULTIPART = True
class RecordingChannel(ApplicationHttpChannel):
def __init__(self) -> None:
self.apps: list[FastAPI] = []
self.stop_calls = 0
async def start(self, app: FastAPI) -> None:
self.apps.append(app)
async def stop(self) -> None:
self.stop_calls += 1
class PingRoutes:
def register(self, app: FastAPI, services) -> None: # type: ignore[no-untyped-def]
@app.get("/estimate/ping")
async def ping() -> dict[str, str]:
return {"status": "ok"}
@dataclass
class ServiceBackedRoutes:
download_path: Path
def register(self, app: FastAPI, services) -> None: # type: ignore[no-untyped-def]
marker = services.get("task_query_service")
@app.get("/estimate/api/tasks")
async def list_tasks() -> dict[str, object]:
return {"marker": marker["marker"]}
@app.post("/estimate/api/tasks")
async def create_task(file: UploadFile = File(...)) -> dict[str, object]:
payload = await file.read()
return {"filename": file.filename, "size": len(payload)}
@app.get("/estimate/api/tasks/result")
async def download_result() -> FileResponse:
return FileResponse(self.download_path, filename=self.download_path.name)
class MetricsRoutes:
def register(self, app: FastAPI, services) -> None: # type: ignore[no-untyped-def]
@app.get("/estimate/api/metrics")
async def metrics() -> dict[str, int]:
return {"count": 1}
class HttpModule(ApplicationModule):
def __init__(self, *registrars: object) -> None:
self._registrars = registrars
@property
def name(self) -> str:
return "http-module"
def register(self, registry: ModuleRegistry) -> None:
for registrar in self._registrars:
registry.add_http_routes(registrar)
def _application_client(channel: RecordingChannel) -> TestClient:
assert channel.apps
return TestClient(channel.apps[0])
def _http_request(port: int, path: str) -> tuple[int, bytes]:
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=2)
try:
connection.request("GET", path)
response = connection.getresponse()
payload = response.read()
return response.status, payload
finally:
connection.close()
def test_runtime_starts_application_http_and_registers_routes() -> None:
runtime = RuntimeManager()
channel = RecordingChannel()
runtime.application_http.register_channel(channel)
runtime.register_module(HttpModule(PingRoutes()))
runtime.start(start_control_plane=False)
try:
assert len(channel.apps) == 1
client = _application_client(channel)
with client:
response = client.get("/estimate/ping")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
assert response.headers["x-response-time-ms"].isdigit()
finally:
runtime.stop(stop_control_plane=False)
assert channel.stop_calls == 1
def test_application_routes_see_runtime_services_and_support_upload_download(tmp_path: Path) -> None:
if not HAS_MULTIPART:
pytest.skip("python-multipart is not installed in the local environment")
runtime = RuntimeManager()
runtime.services.register("task_query_service", {"marker": "from-container"})
result_path = tmp_path / "result.txt"
result_path.write_text("ready", encoding="utf-8")
channel = RecordingChannel()
runtime.application_http.register_channel(channel)
runtime.register_module(HttpModule(ServiceBackedRoutes(result_path), MetricsRoutes()))
runtime.start(start_control_plane=False)
client = _application_client(channel)
try:
with client:
list_response = client.get("/estimate/api/tasks")
assert list_response.status_code == 200
assert list_response.json() == {"marker": "from-container"}
upload_response = client.post(
"/estimate/api/tasks",
files={"file": ("input.txt", b"payload", "text/plain")},
)
assert upload_response.status_code == 200
assert upload_response.json() == {"filename": "input.txt", "size": 7}
metrics_response = client.get("/estimate/api/metrics")
assert metrics_response.status_code == 200
assert metrics_response.json() == {"count": 1}
download_response = client.get("/estimate/api/tasks/result")
assert download_response.status_code == 200
assert download_response.content == b"ready"
finally:
runtime.stop(stop_control_plane=False)
def test_application_http_stop_shuts_down_real_server() -> None:
runtime = RuntimeManager()
channel = HttpApplicationChannel(host="127.0.0.1", port=0, timeout=2)
runtime.application_http.register_channel(channel)
runtime.register_module(HttpModule(PingRoutes()))
runtime.start(start_control_plane=False)
try:
status, _ = _http_request(channel.port, "/estimate/ping")
assert status == 200
finally:
runtime.stop(stop_control_plane=False)
try:
_http_request(channel.port, "/estimate/ping")
except OSError:
pass
else:
raise AssertionError("application HTTP server is still reachable after stop")
def test_control_plane_and_application_http_work_independently() -> None:
runtime = RuntimeManager()
control_channel = HttpControlChannel(host="127.0.0.1", port=0, timeout=2)
app_channel = HttpApplicationChannel(host="127.0.0.1", port=0, timeout=2)
runtime.control_plane.register_channel(control_channel)
runtime.application_http.register_channel(app_channel)
runtime.register_module(HttpModule(PingRoutes()))
runtime.start()
try:
control_status, _ = _http_request(control_channel.port, "/health")
app_status, _ = _http_request(app_channel.port, "/estimate/ping")
assert control_status == 200
assert app_status == 200
control_missing_status, _ = _http_request(control_channel.port, "/estimate/ping")
app_missing_status, _ = _http_request(app_channel.port, "/health")
assert control_missing_status == 404
assert app_missing_status == 404
runtime.application_http.stop()
control_status, _ = _http_request(control_channel.port, "/health")
assert control_status == 200
finally:
runtime.control_plane.stop()
runtime.stop(stop_control_plane=False)