Добавлена возможность регистрировать прикладные апи
This commit is contained in:
@@ -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
|
||||||
@@ -127,7 +130,8 @@ classDiagram
|
|||||||
- Реализация: контракт `app_runtime.contracts.application.ApplicationModule`, регистрация через `app_runtime.core.registration.ModuleRegistry`.
|
- Реализация: контракт `app_runtime.contracts.application.ApplicationModule`, регистрация через `app_runtime.core.registration.ModuleRegistry`.
|
||||||
- Как работает / 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`, но базовый запуск не требует этих расширений.
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,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()
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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."""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user