Перенос LogManager в v2 и обновление документации. Обновлены импорты и исправлены ссылки на LogManager в README и тестах. Удалены устаревшие типы и рефакторинг конфигурации управления.
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
**Ядро (core):**
|
**Ядро (core):**
|
||||||
- **ConfigLoader** — читает конфиг из файла (YAML/JSON), считает хеш и отдаёт конфиг только при изменении; при ошибке парсинга возвращает последний валидный конфиг.
|
- **ConfigLoader** — читает конфиг из файла (YAML/JSON), считает хеш и отдаёт конфиг только при изменении; при ошибке парсинга возвращает последний валидный конфиг.
|
||||||
- **WorkerLoop** — в отдельном потоке циклически вызывает ваш метод `execute()` с паузой между вызовами; реагирует на событие остановки и колбэки успеха/ошибки.
|
- **WorkerLoop** — в отдельном потоке циклически вызывает ваш метод `execute()` с паузой между вызовами; реагирует на событие остановки и колбэки успеха/ошибки.
|
||||||
- **LogManager** (v1) — применяет секцию `log` из конфига к логированию (dictConfig).
|
- **LogManager** — применяет секцию `log` из конфига к логированию (dictConfig).
|
||||||
- **HealthAggregator** — собирает состояние: жизненный цикл (idle/starting/running/…), время последнего успешного `execute()` и таймаут здоровья; формирует единый ответ для health (ok/unhealthy).
|
- **HealthAggregator** — собирает состояние: жизненный цикл (idle/starting/running/…), время последнего успешного `execute()` и таймаут здоровья; формирует единый ответ для health (ok/unhealthy).
|
||||||
- **ControlChannelBridge** — один мост для всех каналов: обработчики on_start/on_stop/on_status (сброс/установка halt, текст статуса).
|
- **ControlChannelBridge** — один мост для всех каналов: обработчики on_start/on_stop/on_status (сброс/установка halt, текст статуса).
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
- **HttpControlChannel** — HTTP API (`/health`, `/actions/start`, `/actions/stop`, `/actions/status`); использует **UvicornServerRunner**; для `/health` вызывает **HealthAggregator.collect()**, для действий — переданные обработчики из **ControlChannelBridge**.
|
- **HttpControlChannel** — HTTP API (`/health`, `/actions/start`, `/actions/stop`, `/actions/status`); использует **UvicornServerRunner**; для `/health` вызывает **HealthAggregator.collect()**, для действий — переданные обработчики из **ControlChannelBridge**.
|
||||||
- **TelegramControlChannel** — реализация через long polling Telegram; команды `/start`, `/stop`, `/status` вызывают переданные обработчики.
|
- **TelegramControlChannel** — реализация через long polling Telegram; команды `/start`, `/stop`, `/status` вызывают переданные обработчики.
|
||||||
|
|
||||||
**Поток работы:** при `start()` менеджер собирает список каналов (при `management_settings.enabled` — **HttpControlChannel**, плюс опционально **control_channel** / **control_channels**), поднимает все каналы с одним **ControlChannelBridge**, затем запускает два цикла: **WorkerLoop** и периодическое обновление конфига через **ConfigLoader**. Остановка по halt (через любой канал) завершает оба цикла; в конце останавливаются все каналы.
|
**Поток работы:** при `start()` менеджер собирает список каналов: при `management.enabled: true` в **config.yaml** (секция `management`) добавляется **HttpControlChannel**, плюс опционально **control_channel** / **control_channels** в конструкторе. Все каналы поднимаются с одним **ControlChannelBridge**, затем запускаются два цикла: **WorkerLoop** и периодическое обновление конфига через **ConfigLoader**. Остановка по halt (через любой канал) завершает оба цикла; в конце останавливаются все каналы. Настройки HTTP-канала (host, port, timeout, health_timeout) задаются в config.yaml в секции `management`.
|
||||||
|
|
||||||
## Диаграмма классов (v1 и v2)
|
## Диаграмма классов (v1 и v2)
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ classDiagram
|
|||||||
**Как проверить, что конфигурация логирования применилась:**
|
**Как проверить, что конфигурация логирования применилась:**
|
||||||
- Убедитесь, что путь к файлу конфига верный и файл загружается при старте (в логах нет ошибки чтения конфига).
|
- Убедитесь, что путь к файлу конфига верный и файл загружается при старте (в логах нет ошибки чтения конфига).
|
||||||
- Убедитесь, что в конфиге есть ключ `log` с `version: 1`, `handlers` и `loggers` (пример — `tests/config.yaml`).
|
- Убедитесь, что в конфиге есть ключ `log` с `version: 1`, `handlers` и `loggers` (пример — `tests/config.yaml`).
|
||||||
- После старта в логе должно появиться сообщение уровня INFO: `"Logging configuration applied"` (из `config_manager.v1.log_manager`). Если его нет, либо секция `log` отсутствует (будет предупреждение), либо уровень root/пакета выше INFO.
|
- После старта в логе должно появиться сообщение уровня INFO: `"Logging configuration applied"` (из `config_manager.v2.core.log_manager`). Если его нет, либо секция `log` отсутствует (будет предупреждение), либо уровень root/пакета выше INFO.
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
``pip install git+https://git.lesha.spb.ru/alex/config_manager.git``
|
``pip install git+https://git.lesha.spb.ru/alex/config_manager.git``
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from .v2 import ConfigManagerV2 as ConfigManager
|
from .v2 import ConfigManagerV2 as ConfigManager
|
||||||
from .v1.cfg_manager import ConfigManager as LegacyConfigManager
|
from .v1.cfg_manager import ConfigManager as LegacyConfigManager
|
||||||
from .v1.log_manager import LogManager
|
from .v2.core.log_manager import LogManager
|
||||||
@@ -6,7 +6,7 @@ import yaml
|
|||||||
import os
|
import os
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from .log_manager import LogManager
|
from ..v2.core.log_manager import LogManager
|
||||||
|
|
||||||
class ConfigManager:
|
class ConfigManager:
|
||||||
DEFAULT_UPDATE_INTERVAL = 5
|
DEFAULT_UPDATE_INTERVAL = 5
|
||||||
|
|||||||
@@ -1,43 +1,4 @@
|
|||||||
import logging
|
"""Обратная совместимость: LogManager перенесён в v2.core.log_manager."""
|
||||||
from typing import Optional
|
from ..v2.core.log_manager import LogManager
|
||||||
|
|
||||||
|
__all__ = ["LogManager"]
|
||||||
class LogManager:
|
|
||||||
"""
|
|
||||||
Управляет конфигурацией логирования приложения.
|
|
||||||
Применяет конфигурацию из словаря с обработкой ошибок.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self._last_valid_config: Optional[dict] = None
|
|
||||||
|
|
||||||
def apply_config(self, config: dict) -> None:
|
|
||||||
"""
|
|
||||||
Применяет конфигурацию логирования из словаря.
|
|
||||||
При ошибке восстанавливает последний валидный конфиг.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: Словарь с настройками логирования (из файла конфига)
|
|
||||||
"""
|
|
||||||
logging_config = config.get("log")
|
|
||||||
if not logging_config:
|
|
||||||
self.logger.warning(
|
|
||||||
"Config has no 'log' section; logging parameters from config are not applied (default level may be WARNING)."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
logging.config.dictConfig(logging_config)
|
|
||||||
self._last_valid_config = logging_config
|
|
||||||
self.logger.info("Logging configuration applied")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error applying logging config: {e}")
|
|
||||||
|
|
||||||
# Если был предыдущий валидный конфиг, восстанавливаем его
|
|
||||||
if self._last_valid_config:
|
|
||||||
try:
|
|
||||||
logging.config.dictConfig(self._last_valid_config)
|
|
||||||
self.logger.info("Previous logging configuration restored")
|
|
||||||
except Exception as restore_error:
|
|
||||||
self.logger.error(f"Error restoring previous config: {restore_error}")
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Публичный API V2: точка входа в менеджер конфигурации и настройки management-сервера.
|
"""Публичный API V2: точка входа в менеджер конфигурации и настройки HTTP-канала из config.yaml.
|
||||||
|
|
||||||
Экспортирует ConfigManagerV2 и типы настроек для использования приложениями."""
|
Экспортирует ConfigManagerV2 и типы настроек для использования приложениями."""
|
||||||
from .core import ConfigManagerV2
|
from .core import ConfigManagerV2
|
||||||
from .types import HealthServerSettings, ManagementServerSettings
|
from .core.types import HealthServerSettings, ManagementServerSettings
|
||||||
|
|
||||||
__all__ = ["ConfigManagerV2", "ManagementServerSettings", "HealthServerSettings"]
|
__all__ = ["ConfigManagerV2", "ManagementServerSettings", "HealthServerSettings"]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from fastapi import Request
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from uvicorn import Config, Server
|
from uvicorn import Config, Server
|
||||||
|
|
||||||
from ..types import HealthPayload
|
from ..core.types import HealthPayload
|
||||||
from .base import ControlChannel, StartHandler, StatusHandler, StopHandler
|
from .base import ControlChannel, StartHandler, StatusHandler, StopHandler
|
||||||
|
|
||||||
PATH_HEALTH = "/health"
|
PATH_HEALTH = "/health"
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
from .config_loader import ConfigLoader
|
from .config_loader import ConfigLoader
|
||||||
from .control_bridge import ControlChannelBridge
|
from .control_bridge import ControlChannelBridge
|
||||||
from .health_aggregator import HealthAggregator
|
from .health_aggregator import HealthAggregator
|
||||||
|
from .log_manager import LogManager
|
||||||
from .manager import ConfigManagerV2
|
from .manager import ConfigManagerV2
|
||||||
from .scheduler import WorkerLoop
|
from .scheduler import WorkerLoop
|
||||||
|
|
||||||
__all__ = ["ConfigLoader", "ConfigManagerV2", "ControlChannelBridge", "HealthAggregator", "WorkerLoop"]
|
__all__ = ["ConfigLoader", "ConfigManagerV2", "ControlChannelBridge", "HealthAggregator", "LogManager", "WorkerLoop"]
|
||||||
|
|||||||
@@ -22,19 +22,19 @@ class ConfigLoader:
|
|||||||
self.config: Any = None
|
self.config: Any = None
|
||||||
self.last_valid_config: Any = None
|
self.last_valid_config: Any = None
|
||||||
self._last_seen_hash: Optional[str] = None
|
self._last_seen_hash: Optional[str] = None
|
||||||
logger.warning("ConfigLoader.__init__ result: path=%s", self.path)
|
logger.debug("ConfigLoader.__init__ result: path=%s", self.path)
|
||||||
|
|
||||||
def _read_file_sync(self) -> str:
|
def _read_file_sync(self) -> str:
|
||||||
"""Синхронно прочитать сырой текст конфига с диска."""
|
"""Синхронно прочитать сырой текст конфига с диска."""
|
||||||
with open(self.path, "r", encoding="utf-8") as fh:
|
with open(self.path, "r", encoding="utf-8") as fh:
|
||||||
data = fh.read()
|
data = fh.read()
|
||||||
logger.warning("ConfigLoader._read_file_sync result: bytes=%s", len(data))
|
logger.debug("ConfigLoader._read_file_sync result: bytes=%s", len(data))
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def read_file_async(self) -> str:
|
async def read_file_async(self) -> str:
|
||||||
"""Прочитать сырой текст конфига с диска в рабочем потоке."""
|
"""Прочитать сырой текст конфига с диска в рабочем потоке."""
|
||||||
result = await asyncio.to_thread(self._read_file_sync)
|
result = await asyncio.to_thread(self._read_file_sync)
|
||||||
logger.warning("ConfigLoader.read_file_async result: bytes=%s", len(result))
|
logger.debug("ConfigLoader.read_file_async result: bytes=%s", len(result))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def parse_config(self, data: str) -> Any:
|
def parse_config(self, data: str) -> Any:
|
||||||
@@ -48,7 +48,7 @@ class ConfigLoader:
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.exception("ConfigLoader.parse_config error: extension=%s", extension)
|
logger.exception("ConfigLoader.parse_config error: extension=%s", extension)
|
||||||
raise
|
raise
|
||||||
logger.warning(
|
logger.debug(
|
||||||
"ConfigLoader.parse_config result: extension=%s type=%s",
|
"ConfigLoader.parse_config result: extension=%s type=%s",
|
||||||
extension,
|
extension,
|
||||||
type(result).__name__,
|
type(result).__name__,
|
||||||
@@ -59,22 +59,35 @@ class ConfigLoader:
|
|||||||
def _calculate_hash(data: str) -> str:
|
def _calculate_hash(data: str) -> str:
|
||||||
"""Вычислить устойчивый хеш содержимого для обнаружения изменений."""
|
"""Вычислить устойчивый хеш содержимого для обнаружения изменений."""
|
||||||
result = hashlib.sha256(data.encode("utf-8")).hexdigest()
|
result = hashlib.sha256(data.encode("utf-8")).hexdigest()
|
||||||
logger.warning("ConfigLoader._calculate_hash result: hash=%s", result)
|
logger.debug("ConfigLoader._calculate_hash result: hash=%s", result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def load_sync(self) -> Any:
|
||||||
|
"""Синхронно загрузить и распарсить конфиг один раз (для чтения настроек при инициализации)."""
|
||||||
|
try:
|
||||||
|
raw_data = self._read_file_sync()
|
||||||
|
parsed = self.parse_config(raw_data)
|
||||||
|
self.config = parsed
|
||||||
|
self.last_valid_config = parsed
|
||||||
|
logger.debug("ConfigLoader.load_sync result: loaded")
|
||||||
|
return parsed
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("ConfigLoader.load_sync error")
|
||||||
|
return None
|
||||||
|
|
||||||
async def load_if_changed(self) -> tuple[bool, Any]:
|
async def load_if_changed(self) -> tuple[bool, Any]:
|
||||||
"""Загрузить и распарсить конфиг только при изменении содержимого файла."""
|
"""Загрузить и распарсить конфиг только при изменении содержимого файла."""
|
||||||
raw_data = await self.read_file_async()
|
raw_data = await self.read_file_async()
|
||||||
current_hash = self._calculate_hash(raw_data)
|
current_hash = self._calculate_hash(raw_data)
|
||||||
if current_hash == self._last_seen_hash:
|
if current_hash == self._last_seen_hash:
|
||||||
logger.warning("ConfigLoader.load_if_changed result: changed=False")
|
logger.debug("ConfigLoader.load_if_changed result: changed=False")
|
||||||
return False, self.config
|
return False, self.config
|
||||||
|
|
||||||
self._last_seen_hash = current_hash
|
self._last_seen_hash = current_hash
|
||||||
parsed = self.parse_config(raw_data)
|
parsed = self.parse_config(raw_data)
|
||||||
self.config = parsed
|
self.config = parsed
|
||||||
self.last_valid_config = parsed
|
self.last_valid_config = parsed
|
||||||
logger.warning("ConfigLoader.load_if_changed result: changed=True")
|
logger.debug("ConfigLoader.load_if_changed result: changed=True")
|
||||||
return True, parsed
|
return True, parsed
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
from ..types import LifecycleState
|
from .types import LifecycleState
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from ..types import HealthPayload, LifecycleState
|
from .types import HealthPayload, LifecycleState
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
38
src/config_manager/v2/core/log_manager.py
Normal file
38
src/config_manager/v2/core/log_manager.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Применение конфигурации логирования из словаря (секция `log` в config.yaml).
|
||||||
|
|
||||||
|
Управляет конфигурацией логирования приложения через dictConfig, с восстановлением последнего валидного конфига при ошибке."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class LogManager:
|
||||||
|
"""Применяет секцию `log` из конфига к логированию (dictConfig). При ошибке восстанавливает последний валидный конфиг."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self._last_valid_config: Optional[dict] = None
|
||||||
|
|
||||||
|
def apply_config(self, config: dict) -> None:
|
||||||
|
"""Применить конфигурацию логирования из словаря. При ошибке восстанавливает последний валидный конфиг."""
|
||||||
|
logging_config = config.get("log")
|
||||||
|
if not logging_config:
|
||||||
|
self.logger.warning(
|
||||||
|
"Config has no 'log' section; logging parameters from config are not applied (default level may be WARNING)."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
logging.config.dictConfig(logging_config)
|
||||||
|
self._last_valid_config = logging_config
|
||||||
|
self.logger.info("Logging configuration applied")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error("Error applying logging config: %s", e)
|
||||||
|
if self._last_valid_config:
|
||||||
|
try:
|
||||||
|
logging.config.dictConfig(self._last_valid_config)
|
||||||
|
self.logger.info("Previous logging configuration restored")
|
||||||
|
except Exception as restore_error:
|
||||||
|
self.logger.error("Error restoring previous config: %s", restore_error)
|
||||||
@@ -7,10 +7,10 @@ import os
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Iterable, Optional
|
from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
from ...v1.log_manager import LogManager
|
from .log_manager import LogManager
|
||||||
from ..control.base import ControlChannel
|
from ..control.base import ControlChannel
|
||||||
from ..control.http_channel import HttpControlChannel
|
from ..control.http_channel import HttpControlChannel
|
||||||
from ..types import HealthPayload, LifecycleState, ManagementServerSettings
|
from .types import HealthPayload, LifecycleState, ManagementServerSettings, management_settings_from_config
|
||||||
from .config_loader import ConfigLoader
|
from .config_loader import ConfigLoader
|
||||||
from .control_bridge import ControlChannelBridge
|
from .control_bridge import ControlChannelBridge
|
||||||
from .health_aggregator import HealthAggregator
|
from .health_aggregator import HealthAggregator
|
||||||
@@ -195,7 +195,6 @@ class ConfigManagerV2(_RuntimeController):
|
|||||||
self,
|
self,
|
||||||
path: str,
|
path: str,
|
||||||
log_manager: Optional[LogManager] = None,
|
log_manager: Optional[LogManager] = None,
|
||||||
management_settings: Optional[ManagementServerSettings] = None,
|
|
||||||
control_channel: Optional[ControlChannel] = None,
|
control_channel: Optional[ControlChannel] = None,
|
||||||
control_channels: Optional[Iterable[ControlChannel]] = None,
|
control_channels: Optional[Iterable[ControlChannel]] = None,
|
||||||
):
|
):
|
||||||
@@ -214,7 +213,9 @@ class ConfigManagerV2(_RuntimeController):
|
|||||||
self._last_execute_error: Optional[str] = None
|
self._last_execute_error: Optional[str] = None
|
||||||
self._last_success_timestamp: Optional[float] = None
|
self._last_success_timestamp: Optional[float] = None
|
||||||
|
|
||||||
settings = management_settings or ManagementServerSettings(enabled=True)
|
initial_config = self._loader.load_sync()
|
||||||
|
self.config = initial_config
|
||||||
|
settings = management_settings_from_config(initial_config if isinstance(initial_config, dict) else {})
|
||||||
self._health_timeout = settings.health_timeout
|
self._health_timeout = settings.health_timeout
|
||||||
self._health_aggregator = HealthAggregator(
|
self._health_aggregator = HealthAggregator(
|
||||||
get_state=lambda: self._state,
|
get_state=lambda: self._state,
|
||||||
|
|||||||
62
src/config_manager/v2/core/types.py
Normal file
62
src/config_manager/v2/core/types.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Типы core: состояние здоровья, жизненного цикла и настройки HTTP-канала из config.yaml.
|
||||||
|
|
||||||
|
Используются в core и control для единообразных контрактов."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Literal, TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
HealthState = Literal["ok", "degraded", "unhealthy"]
|
||||||
|
|
||||||
|
|
||||||
|
class HealthPayload(TypedDict, total=False):
|
||||||
|
status: HealthState
|
||||||
|
detail: str
|
||||||
|
state: str
|
||||||
|
"""Текущее состояние жизненного цикла (idle/starting/running/stopping/stopped)."""
|
||||||
|
|
||||||
|
|
||||||
|
class LifecycleState(str, Enum):
|
||||||
|
IDLE = "idle"
|
||||||
|
STARTING = "starting"
|
||||||
|
RUNNING = "running"
|
||||||
|
STOPPING = "stopping"
|
||||||
|
STOPPED = "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ManagementServerSettings:
|
||||||
|
"""Настройки HTTP-канала управления и healthcheck (читаются из config.yaml, секция management)."""
|
||||||
|
enabled: bool = False
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8000
|
||||||
|
timeout: int = 3
|
||||||
|
"""Таймаут запроса health (секунды)."""
|
||||||
|
health_timeout: int = 30
|
||||||
|
"""Секунды без успешного execute(), после которых health = unhealthy."""
|
||||||
|
|
||||||
|
|
||||||
|
HealthServerSettings = ManagementServerSettings
|
||||||
|
|
||||||
|
|
||||||
|
def management_settings_from_config(config: Any) -> ManagementServerSettings:
|
||||||
|
"""Извлечь настройки HTTP-канала из конфига (секция `management`)."""
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
return ManagementServerSettings(enabled=False)
|
||||||
|
m = config.get("management")
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
return ManagementServerSettings(enabled=False)
|
||||||
|
enabled = bool(m.get("enabled", False))
|
||||||
|
host = str(m.get("host", "0.0.0.0"))
|
||||||
|
port = int(m.get("port", 8000)) if isinstance(m.get("port"), (int, float)) else 8000
|
||||||
|
timeout = int(m.get("timeout", 3)) if isinstance(m.get("timeout"), (int, float)) else 3
|
||||||
|
health_timeout = int(m.get("health_timeout", 30)) if isinstance(m.get("health_timeout"), (int, float)) else 30
|
||||||
|
return ManagementServerSettings(
|
||||||
|
enabled=enabled,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
timeout=timeout,
|
||||||
|
health_timeout=health_timeout,
|
||||||
|
)
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
"""Re-exports для обратной совместимости: HealthAggregator, ControlChannelBridge, ManagementServer/HealthServer (HttpControlChannel)."""
|
|
||||||
from ..control import HttpControlChannel
|
|
||||||
from ..core import ControlChannelBridge, HealthAggregator
|
|
||||||
|
|
||||||
ManagementServer = HttpControlChannel
|
|
||||||
HealthServer = HttpControlChannel
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ControlChannelBridge",
|
|
||||||
"HealthAggregator",
|
|
||||||
"HealthServer",
|
|
||||||
"ManagementServer",
|
|
||||||
]
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""Общие типы V2: состояние здоровья, жизненного цикла и настройки management-сервера.
|
|
||||||
|
|
||||||
Используются в core, management и control для единообразных контрактов."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Literal, TypedDict
|
|
||||||
|
|
||||||
|
|
||||||
HealthState = Literal["ok", "degraded", "unhealthy"]
|
|
||||||
|
|
||||||
|
|
||||||
class HealthPayload(TypedDict, total=False):
|
|
||||||
status: HealthState
|
|
||||||
detail: str
|
|
||||||
state: str
|
|
||||||
"""Текущее состояние жизненного цикла (idle/starting/running/stopping/stopped)."""
|
|
||||||
|
|
||||||
|
|
||||||
class LifecycleState(str, Enum):
|
|
||||||
IDLE = "idle"
|
|
||||||
STARTING = "starting"
|
|
||||||
RUNNING = "running"
|
|
||||||
STOPPING = "stopping"
|
|
||||||
STOPPED = "stopped"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ManagementServerSettings:
|
|
||||||
"""Настройки management HTTP-сервера и healthcheck (один объект на оба)."""
|
|
||||||
enabled: bool = False
|
|
||||||
host: str = "0.0.0.0"
|
|
||||||
port: int = 8000
|
|
||||||
timeout: int = 3
|
|
||||||
"""Таймаут запроса health (секунды)."""
|
|
||||||
health_timeout: int = 30
|
|
||||||
"""Секунды без успешного execute(), после которых health = unhealthy."""
|
|
||||||
|
|
||||||
|
|
||||||
# Backward-compatible alias.
|
|
||||||
HealthServerSettings = ManagementServerSettings
|
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
# === Раздел с общими конфигурационными параметрами ===
|
# === Раздел с общими конфигурационными параметрами ===
|
||||||
runtime: 5
|
runtime: 5
|
||||||
|
|
||||||
|
# === HTTP-канал управления (ConfigManagerV2): /health, /actions/start, /actions/stop ===
|
||||||
|
# management:
|
||||||
|
# enabled: true
|
||||||
|
# host: "0.0.0.0"
|
||||||
|
# port: 8000
|
||||||
|
# timeout: 3
|
||||||
|
# health_timeout: 30
|
||||||
|
|
||||||
# === Логирование ===
|
# === Логирование ===
|
||||||
log:
|
log:
|
||||||
version: 1
|
version: 1
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from config_manager import ConfigManager
|
from config_manager import ConfigManager
|
||||||
from config_manager.v1.log_manager import LogManager
|
from config_manager.v2.core import LogManager
|
||||||
from config_manager.v2 import ManagementServerSettings
|
from config_manager.v2 import ManagementServerSettings
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from config_manager.v2 import ConfigManagerV2, ManagementServerSettings
|
from config_manager.v2 import ConfigManagerV2
|
||||||
|
|
||||||
|
|
||||||
class ReloadApp(ConfigManagerV2):
|
class ReloadApp(ConfigManagerV2):
|
||||||
@@ -14,9 +14,9 @@ class ReloadApp(ConfigManagerV2):
|
|||||||
def test_invalid_config_keeps_last_valid(tmp_path):
|
def test_invalid_config_keeps_last_valid(tmp_path):
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
cfg = tmp_path / "config.yaml"
|
cfg = tmp_path / "config.yaml"
|
||||||
cfg.write_text("log: {}\n", encoding="utf-8")
|
cfg.write_text("log: {}\nmanagement: { enabled: false }\n", encoding="utf-8")
|
||||||
|
|
||||||
app = ReloadApp(str(cfg), management_settings=ManagementServerSettings(enabled=False))
|
app = ReloadApp(str(cfg))
|
||||||
runner = asyncio.create_task(app.start())
|
runner = asyncio.create_task(app.start())
|
||||||
|
|
||||||
await asyncio.sleep(0.12)
|
await asyncio.sleep(0.12)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from config_manager.v2 import ConfigManagerV2, ManagementServerSettings
|
from config_manager.v2 import ConfigManagerV2
|
||||||
|
|
||||||
|
|
||||||
class DemoApp(ConfigManagerV2):
|
class DemoApp(ConfigManagerV2):
|
||||||
@@ -18,9 +18,9 @@ class DemoApp(ConfigManagerV2):
|
|||||||
def test_execute_loop_runs(tmp_path):
|
def test_execute_loop_runs(tmp_path):
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
cfg = tmp_path / "config.yaml"
|
cfg = tmp_path / "config.yaml"
|
||||||
cfg.write_text("log: {}\n", encoding="utf-8")
|
cfg.write_text("log: {}\nmanagement: { enabled: false }\n", encoding="utf-8")
|
||||||
|
|
||||||
app = DemoApp(str(cfg), management_settings=ManagementServerSettings(enabled=False))
|
app = DemoApp(str(cfg))
|
||||||
runner = asyncio.create_task(app.start())
|
runner = asyncio.create_task(app.start())
|
||||||
|
|
||||||
await asyncio.sleep(0.18)
|
await asyncio.sleep(0.18)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from config_manager.v2 import ConfigManagerV2, ManagementServerSettings
|
from config_manager.v2 import ConfigManagerV2
|
||||||
from config_manager.v2.control.base import ControlChannel, StartHandler, StatusHandler, StopHandler
|
from config_manager.v2.control.base import ControlChannel, StartHandler, StatusHandler, StopHandler
|
||||||
|
|
||||||
|
|
||||||
@@ -33,14 +33,10 @@ class ControlledApp(ConfigManagerV2):
|
|||||||
def test_control_channel_can_stop_manager(tmp_path):
|
def test_control_channel_can_stop_manager(tmp_path):
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
cfg = tmp_path / "config.yaml"
|
cfg = tmp_path / "config.yaml"
|
||||||
cfg.write_text("log: {}\n", encoding="utf-8")
|
cfg.write_text("log: {}\nmanagement: { enabled: false }\n", encoding="utf-8")
|
||||||
|
|
||||||
channel = DummyControlChannel()
|
channel = DummyControlChannel()
|
||||||
app = ControlledApp(
|
app = ControlledApp(str(cfg), control_channel=channel)
|
||||||
str(cfg),
|
|
||||||
control_channel=channel,
|
|
||||||
management_settings=ManagementServerSettings(enabled=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
runner = asyncio.create_task(app.start())
|
runner = asyncio.create_task(app.start())
|
||||||
await asyncio.sleep(0.12)
|
await asyncio.sleep(0.12)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from config_manager.v2.management import ManagementServer
|
from config_manager.v2.control import HttpControlChannel
|
||||||
|
|
||||||
|
|
||||||
def test_health_mapping_ok_to_200():
|
def test_health_mapping_ok_to_200():
|
||||||
@@ -9,7 +9,7 @@ def test_health_mapping_ok_to_200():
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
server = ManagementServer(
|
server = HttpControlChannel(
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=8000,
|
port=8000,
|
||||||
timeout=0.2,
|
timeout=0.2,
|
||||||
@@ -27,7 +27,7 @@ def test_health_mapping_unhealthy_to_503():
|
|||||||
return {"status": "unhealthy", "detail": "worker failed"}
|
return {"status": "unhealthy", "detail": "worker failed"}
|
||||||
|
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
server = ManagementServer(
|
server = HttpControlChannel(
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=8000,
|
port=8000,
|
||||||
timeout=0.2,
|
timeout=0.2,
|
||||||
@@ -73,21 +73,21 @@ def test_action_routes_call_callbacks():
|
|||||||
return status_code, payload
|
return status_code, payload
|
||||||
|
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
server = ManagementServer(
|
channel = HttpControlChannel(
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=0,
|
port=0,
|
||||||
timeout=0.2,
|
timeout=0.2,
|
||||||
health_provider=provider,
|
health_provider=provider,
|
||||||
)
|
)
|
||||||
await server.start(on_start, on_stop, on_status)
|
await channel.start(on_start, on_stop, on_status)
|
||||||
try:
|
try:
|
||||||
port = server.port
|
port = channel.port
|
||||||
assert port > 0
|
assert port > 0
|
||||||
|
|
||||||
start_code, start_payload = await request(port, "/actions/start")
|
start_code, start_payload = await request(port, "/actions/start")
|
||||||
stop_code, stop_payload = await request(port, "/actions/stop")
|
stop_code, stop_payload = await request(port, "/actions/stop")
|
||||||
finally:
|
finally:
|
||||||
await server.stop()
|
await channel.stop()
|
||||||
|
|
||||||
assert start_code == 200
|
assert start_code == 200
|
||||||
assert start_payload["status"] == "ok"
|
assert start_payload["status"] == "ok"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import asyncio
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from config_manager.v2 import ConfigManagerV2, ManagementServerSettings
|
from config_manager.v2 import ConfigManagerV2
|
||||||
|
|
||||||
|
|
||||||
class BlockingApp(ConfigManagerV2):
|
class BlockingApp(ConfigManagerV2):
|
||||||
@@ -26,9 +26,9 @@ class BlockingApp(ConfigManagerV2):
|
|||||||
def test_stop_waits_for_active_execute_and_prevents_next_run(tmp_path):
|
def test_stop_waits_for_active_execute_and_prevents_next_run(tmp_path):
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
cfg = tmp_path / "config.yaml"
|
cfg = tmp_path / "config.yaml"
|
||||||
cfg.write_text("log: {}\n", encoding="utf-8")
|
cfg.write_text("log: {}\nmanagement: { enabled: false }\n", encoding="utf-8")
|
||||||
|
|
||||||
app = BlockingApp(str(cfg), management_settings=ManagementServerSettings(enabled=False))
|
app = BlockingApp(str(cfg))
|
||||||
runner = asyncio.create_task(app.start())
|
runner = asyncio.create_task(app.start())
|
||||||
|
|
||||||
started = await asyncio.to_thread(app.started_event.wait, 1.0)
|
started = await asyncio.to_thread(app.started_event.wait, 1.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user