Deployment
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENV_PATH = Path(os.environ.get("VAULT_ENV_PATH", "/vault-secrets/.env"))
|
||||
MAX_WAIT_SECONDS = 30
|
||||
WATCH_INTERVAL_SECONDS = 5
|
||||
|
||||
def _file_hash() -> str | None:
|
||||
if not ENV_PATH.exists():
|
||||
return None
|
||||
try:
|
||||
return hashlib.sha256(ENV_PATH.read_bytes()).hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_env_file(path: Path) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
with path.open() as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
result[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return result
|
||||
|
||||
|
||||
def wait_for_env_file(timeout: int = MAX_WAIT_SECONDS) -> None:
|
||||
"""Блокирующее ожидание появления .env (для entrypoint до старта uvicorn)."""
|
||||
start = time.time()
|
||||
while not ENV_PATH.exists():
|
||||
if time.time() - start > timeout:
|
||||
raise RuntimeError(
|
||||
f"Vault Agent did not render {ENV_PATH} within {timeout}s"
|
||||
)
|
||||
logger.info("Waiting for %s ...", ENV_PATH)
|
||||
time.sleep(0.5)
|
||||
time.sleep(1.5)
|
||||
|
||||
|
||||
def load_env(force: bool = False) -> bool:
|
||||
if not ENV_PATH.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
values = _parse_env_file(ENV_PATH)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to read %s: %s", ENV_PATH, e)
|
||||
return False
|
||||
|
||||
for key, value in values.items():
|
||||
if force or key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
global _last_hash
|
||||
_last_hash = _file_hash()
|
||||
return True
|
||||
|
||||
|
||||
def reload_if_changed() -> bool:
|
||||
current = _file_hash()
|
||||
if current is None or current == _last_hash:
|
||||
return False
|
||||
logger.info("Detected change in %s, reloading secrets", ENV_PATH)
|
||||
return load_env(force=True)
|
||||
|
||||
|
||||
async def watch_env_file(interval: int = WATCH_INTERVAL_SECONDS) -> None:
|
||||
while True:
|
||||
try:
|
||||
reload_if_changed()
|
||||
except Exception:
|
||||
logger.exception("env watcher failed")
|
||||
await asyncio.sleep(interval)
|
||||
@@ -0,0 +1,107 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agents.issue_triage.IssueTriageAgent import IssueTriageAgent
|
||||
from agents.issue_triage.context_builder import YouTrackContextBuilder
|
||||
from agents.registry import AgentRegistry
|
||||
from common.llm_client import LLMClient
|
||||
from common.youtrack_mcp_client import YouTrackMCPClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentSingleton:
|
||||
"""
|
||||
Синглтон для IssueTriageAgent и его зависимостей.
|
||||
Потокобезопасен через asyncio.Lock — инициализация не гоняется.
|
||||
"""
|
||||
|
||||
_instance: Optional["AgentSingleton"] = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._agent: Optional[IssueTriageAgent] = None
|
||||
self._youtrack_mcp: Optional[YouTrackMCPClient] = None
|
||||
self._http_for_attachments: Optional[httpx.AsyncClient] = None
|
||||
self._initialized = False
|
||||
|
||||
@classmethod
|
||||
async def get(cls) -> "AgentSingleton":
|
||||
if cls._instance is None:
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
async def ensure_initialized(self) -> None:
|
||||
"""Инициализация при первом обращении или после reset()."""
|
||||
if self._initialized:
|
||||
return
|
||||
async with self._lock:
|
||||
if self._initialized:
|
||||
return
|
||||
await self._initialize()
|
||||
self._initialized = True
|
||||
|
||||
async def _initialize(self) -> None:
|
||||
from main import env, env_optional # локальный импорт, чтобы не плодить циклы
|
||||
|
||||
logger.info("Initializing AgentSingleton (or re-initializing)")
|
||||
|
||||
self._youtrack_mcp = await YouTrackMCPClient(
|
||||
env("YOUTRACK_MCP_SERVER"),
|
||||
env("YOUTRACK_MCP_TOKEN"),
|
||||
).connect()
|
||||
|
||||
self._http_for_attachments = httpx.AsyncClient(
|
||||
headers={"Authorization": f"Bearer {env('YOUTRACK_MCP_TOKEN')}"},
|
||||
proxy=env_optional("HTTPS_PROXY"),
|
||||
timeout=httpx.Timeout(30.0, connect=10.0, read=60.0),
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
self._agent = IssueTriageAgent(
|
||||
YouTrackContextBuilder(self._youtrack_mcp, self._http_for_attachments),
|
||||
LLMClient(
|
||||
base_url=env("LLM_ADDRESS"),
|
||||
api_key=env("LLM_API_KEY"),
|
||||
model=env("LLM_MODEL"),
|
||||
),
|
||||
AgentRegistry(),
|
||||
self._youtrack_mcp,
|
||||
)
|
||||
|
||||
@property
|
||||
def agent(self) -> IssueTriageAgent:
|
||||
if self._agent is None:
|
||||
raise RuntimeError("AgentSingleton not initialized. Call ensure_initialized() first.")
|
||||
return self._agent
|
||||
|
||||
@property
|
||||
def youtrack_mcp(self) -> YouTrackMCPClient:
|
||||
if self._youtrack_mcp is None:
|
||||
raise RuntimeError("AgentSingleton not initialized.")
|
||||
return self._youtrack_mcp
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Закрывает старые ресурсы и помечает синглтон как неинициализированный."""
|
||||
async with self._lock:
|
||||
logger.info("Resetting AgentSingleton (secrets rotated?)")
|
||||
if self._http_for_attachments is not None:
|
||||
await self._http_for_attachments.aclose()
|
||||
if self._youtrack_mcp is not None:
|
||||
await self._youtrack_mcp.close()
|
||||
self._agent = None
|
||||
self._youtrack_mcp = None
|
||||
self._http_for_attachments = None
|
||||
self._initialized = False
|
||||
|
||||
@classmethod
|
||||
async def destroy(cls) -> None:
|
||||
"""Полное уничтожение синглтона — для shutdown."""
|
||||
if cls._instance is not None:
|
||||
await cls._instance.reset()
|
||||
cls._instance = None
|
||||
Reference in New Issue
Block a user