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)