diff --git a/README.md b/README.md
index 354037a..a46af04 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@ PLBA (`Platform Runtime for Business Applications`) - это runtime-слой д
- `health` (`HealthRegistry`) - агрегирование здоровья воркеров и дополнительных компонентов.
- `workflow` (`WorkflowEngine` и persistence-слой) - исполнение шагов бизнес-процесса с переходами и фиксацией состояния.
- `control plane` (`ControlPlaneService`, `HttpControlChannel`) - внешние health/action endpoints.
+- `application HTTP` (`ApplicationHttpService`, `HttpApplicationChannel`) - пользовательские HTTP routes бизнес-приложения.
- `queue` (`InMemoryTaskQueue`) - локальный in-memory буфер как утилита прикладного уровня.
## 3. Архитектура
@@ -89,6 +90,7 @@ classDiagram
class HealthRegistry
class TraceService
class ControlPlaneService
+ class ApplicationHttpService
class WorkflowRuntimeFactory
class WorkflowEngine
class WorkflowPersistence
@@ -106,6 +108,7 @@ classDiagram
RuntimeManager --> TraceService
RuntimeManager --> WorkerSupervisor
RuntimeManager --> ControlPlaneService
+ RuntimeManager --> ApplicationHttpService
WorkerSupervisor --> Worker
Worker --> Routine : invokes
@@ -127,7 +130,8 @@ classDiagram
- Реализация: контракт `app_runtime.contracts.application.ApplicationModule`, регистрация через `app_runtime.core.registration.ModuleRegistry`.
- Как работает / API / вызовы / таблицы:
- 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.
- В БД напрямую не пишет.
- Типовая схема использования:
@@ -314,7 +318,72 @@ with traces.open_context(alias="email:123", kind="email") as message_trace_id:
- Producer в рутине кладет элементы через `put`.
- 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("
Demo
")
+
+ @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`.
2. В модуле собрать одну `Routine` и один `Worker` (1 worker -> 1 routine).
@@ -374,4 +443,4 @@ runtime = create_runtime(DemoModule(), config_path="config.yml")
runtime.start()
```
-Для production-сценария после MVP обычно добавляют `tracing`, `health contributors`, `workflow` и HTTP control plane, но базовый запуск не требует этих расширений.
+Для production-сценария после MVP обычно добавляют `tracing`, `health contributors`, `workflow`, HTTP control plane и при необходимости `application HTTP`, но базовый запуск не требует этих расширений.
diff --git a/pyproject.toml b/pyproject.toml
index 044fc8e..ab9814a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,6 +12,7 @@ dependencies = [
"fastapi>=0.129.0",
"PyMySQL>=1.1",
"PyYAML>=6.0.3",
+ "python-multipart>=0.0.9",
"uvicorn>=0.41.0",
]
diff --git a/src/app_runtime/control/http_runner.py b/src/app_runtime/control/http_runner.py
index bca3afd..b76ebca 100644
--- a/src/app_runtime/control/http_runner.py
+++ b/src/app_runtime/control/http_runner.py
@@ -8,10 +8,11 @@ from uvicorn import Config, Server
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._port = port
self._timeout = timeout
+ self._thread_name = thread_name
self._server: Server | None = None
self._thread: Thread | None = None
self._error: BaseException | None = None
@@ -22,7 +23,7 @@ class UvicornThreadRunner:
self._error = None
config = Config(app=app, host=self._host, port=self._port, log_level="warning")
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()
await self._wait_until_started()
diff --git a/src/app_runtime/core/registration.py b/src/app_runtime/core/registration.py
index 361c297..4529bbe 100644
--- a/src/app_runtime/core/registration.py
+++ b/src/app_runtime/core/registration.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from app_runtime.contracts.health import HealthContributor
from app_runtime.contracts.worker import Worker
from app_runtime.core.service_container import ServiceContainer
+from app_runtime.http.base import HttpRouteRegistrar
class ModuleRegistry:
@@ -10,6 +11,7 @@ class ModuleRegistry:
self.services = services
self.workers: list[Worker] = []
self.health_contributors: list[HealthContributor] = []
+ self.http_route_registrars: list[HttpRouteRegistrar] = []
self.modules: list[str] = []
def register_module(self, name: str) -> None:
@@ -20,3 +22,6 @@ class ModuleRegistry:
def add_health_contributor(self, contributor: HealthContributor) -> None:
self.health_contributors.append(contributor)
+
+ def add_http_routes(self, registrar: HttpRouteRegistrar) -> None:
+ self.http_route_registrars.append(registrar)
diff --git a/src/app_runtime/core/runtime.py b/src/app_runtime/core/runtime.py
index 28e3c4b..c9b002e 100644
--- a/src/app_runtime/core/runtime.py
+++ b/src/app_runtime/core/runtime.py
@@ -13,6 +13,7 @@ from app_runtime.core.registration import ModuleRegistry
from app_runtime.core.service_container import ServiceContainer
from app_runtime.core.types import HealthPayload, LifecycleState
from app_runtime.health.registry import HealthRegistry
+from app_runtime.http.service import ApplicationHttpService
from app_runtime.logging.manager import LogManager
from app_runtime.tracing.reader import build_trace_log_reader
from app_runtime.tracing.service import TraceService
@@ -32,6 +33,7 @@ class RuntimeManager:
logs: LogManager | None = None,
workers: WorkerSupervisor | None = None,
control_plane: ControlPlaneService | None = None,
+ application_http: ApplicationHttpService | None = None,
) -> None:
self.configuration = configuration or ConfigurationManager()
self.services = services or ServiceContainer()
@@ -40,6 +42,7 @@ class RuntimeManager:
self.logs = logs or LogManager()
self.workers = workers or WorkerSupervisor()
self.control_plane = control_plane or ControlPlaneService()
+ self.application_http = application_http or ApplicationHttpService()
self.registry = ModuleRegistry(self.services)
self._started = False
self._state = LifecycleState.IDLE
@@ -67,6 +70,8 @@ class RuntimeManager:
self.workers.start()
if start_control_plane:
self.control_plane.start(self)
+ self._register_application_http_routes()
+ self.application_http.start(self)
self._started = True
self._refresh_state()
@@ -75,6 +80,7 @@ class RuntimeManager:
return
self._state = LifecycleState.STOPPING
self.workers.stop(timeout=timeout, force=force)
+ self.application_http.stop()
if stop_control_plane:
self.control_plane.stop()
self._started = False
@@ -120,6 +126,7 @@ class RuntimeManager:
except TimeoutError:
return self._action_detail("runtime stop is still in progress", timed_out=True)
+ self.application_http.stop()
self._refresh_state()
if self._state == LifecycleState.STOPPED:
self._started = False
@@ -148,6 +155,7 @@ class RuntimeManager:
self.services.register("logs", self.logs)
self.services.register("workers", self.workers)
self.services.register("control_plane", self.control_plane)
+ self.services.register("application_http", self.application_http)
self._core_registered = True
def _register_health_contributors(self) -> None:
@@ -161,6 +169,11 @@ class RuntimeManager:
self.workers.register(worker)
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:
lifecycle = self.workers.lifecycle_state()
diff --git a/src/app_runtime/http/__init__.py b/src/app_runtime/http/__init__.py
new file mode 100644
index 0000000..bf9b28b
--- /dev/null
+++ b/src/app_runtime/http/__init__.py
@@ -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",
+]
diff --git a/src/app_runtime/http/base.py b/src/app_runtime/http/base.py
new file mode 100644
index 0000000..bb2795b
--- /dev/null
+++ b/src/app_runtime/http/base.py
@@ -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."""
diff --git a/src/app_runtime/http/http_app.py b/src/app_runtime/http/http_app.py
new file mode 100644
index 0000000..0370d91
--- /dev/null
+++ b/src/app_runtime/http/http_app.py
@@ -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
diff --git a/src/app_runtime/http/http_channel.py b/src/app_runtime/http/http_channel.py
new file mode 100644
index 0000000..d62d70f
--- /dev/null
+++ b/src/app_runtime/http/http_channel.py
@@ -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
diff --git a/src/app_runtime/http/service.py b/src/app_runtime/http/service.py
new file mode 100644
index 0000000..c00d504
--- /dev/null
+++ b/src/app_runtime/http/service.py
@@ -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()
diff --git a/src/plba/__init__.py b/src/plba/__init__.py
index ba58d89..2690d00 100644
--- a/src/plba/__init__.py
+++ b/src/plba/__init__.py
@@ -15,6 +15,13 @@ from plba.contracts import (
)
from plba.core import ConfigurationManager, RuntimeManager, ServiceContainer
from plba.health import HealthRegistry
+from plba.http import (
+ ApplicationHttpChannel,
+ ApplicationHttpService,
+ HttpApplicationAppFactory,
+ HttpApplicationChannel,
+ HttpRouteRegistrar,
+)
from plba.logging import LogManager
from plba.queue import InMemoryTaskQueue
from plba.tracing import MySqlTraceTransport, NoOpTraceTransport, TraceService
@@ -43,6 +50,11 @@ __all__ = [
"FileConfigProvider",
"HealthContributor",
"HealthRegistry",
+ "ApplicationHttpChannel",
+ "ApplicationHttpService",
+ "HttpApplicationAppFactory",
+ "HttpApplicationChannel",
+ "HttpRouteRegistrar",
"HttpControlChannel",
"InMemoryTaskQueue",
"LogManager",
diff --git a/src/plba/bootstrap.py b/src/plba/bootstrap.py
index 80318da..8d2d916 100644
--- a/src/plba/bootstrap.py
+++ b/src/plba/bootstrap.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from app_runtime.control.http_channel import HttpControlChannel
from app_runtime.core.runtime import RuntimeManager
from app_runtime.contracts.application import ApplicationModule
+from app_runtime.http.http_channel import HttpApplicationChannel
def create_runtime(
@@ -13,6 +14,9 @@ def create_runtime(
control_host: str = "127.0.0.1",
control_port: int = 8080,
control_timeout: int = 5,
+ application_host: str | None = None,
+ application_port: int = 15000,
+ application_timeout: int = 5,
) -> RuntimeManager:
runtime = RuntimeManager()
if config_path is not None:
@@ -25,5 +29,13 @@ def create_runtime(
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)
return runtime
diff --git a/src/plba/http.py b/src/plba/http.py
new file mode 100644
index 0000000..bf9b28b
--- /dev/null
+++ b/src/plba/http.py
@@ -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",
+]
diff --git a/tests/test_application_http.py b/tests/test_application_http.py
new file mode 100644
index 0000000..87869b4
--- /dev/null
+++ b/tests/test_application_http.py
@@ -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)