from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session from datetime import datetime, timezone from app.auth.jwt_auth import get_current_user, require_admin from app.config import get_settings from app.database import get_db from app.models import Event, Host from app.schemas.list_models import HostDetail, HostListResponse, HostSummary from app.services.agent_version import ( latest_agent_versions_by_product, reference_agent_versions_by_product, ) from app.services.agent_git_release import get_git_release_versions from app.services.agent_update import ( AgentUpdateNotAllowedError, execute_agent_update_fallback, host_version_status, request_agent_update, ) from app.services.agent_update_settings import get_effective_agent_update_config from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config from app.services.host_delete import delete_host_and_related from app.services.linux_admin_settings import get_effective_linux_admin_config from app.services.win_admin_settings import get_effective_win_admin_config from app.services.winrm_connect import ( HostNotWindowsError, HostTargetMissingError, WinAdminNotConfiguredError, WinRmTestResult, iter_winrm_targets, test_winrm_connection, ) from app.services.ssh_connect import ( HostNotLinuxError as SshHostNotLinuxError, HostTargetMissingError as SshHostTargetMissingError, LinuxAdminNotConfiguredError, SshCommandResult, iter_ssh_targets, run_ssh_monitor_update, test_ssh_connection, ) from app.services.host_health import ( HEARTBEAT_TYPE, agent_status as compute_agent_status, apply_agent_status_host_filter, max_daily_report_time_by_host, max_event_time_by_host, ) router = APIRouter(prefix="/hosts", tags=["hosts"]) @router.get("", response_model=HostListResponse) def list_hosts( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), product: str | None = None, hostname: str | None = None, agent_status: str | None = Query(None, pattern="^(online|stale|unknown)$"), db: Session = Depends(get_db), _user: str = Depends(get_current_user), ) -> HostListResponse: stmt = select(Host) count_stmt = select(func.count()).select_from(Host) if product: stmt = stmt.where(Host.product == product) count_stmt = count_stmt.where(Host.product == product) if hostname: like = f"%{hostname}%" host_match = Host.hostname.ilike(like) | Host.display_name.ilike(like) stmt = stmt.where(host_match) count_stmt = count_stmt.where(host_match) settings = get_settings() if agent_status: stmt, count_stmt = apply_agent_status_host_filter( stmt, count_stmt, agent_status, stale_minutes=settings.sac_heartbeat_stale_minutes, ) total = db.scalar(count_stmt) or 0 rows = db.scalars( stmt.order_by(Host.last_seen_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ).all() hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE) report_map = max_daily_report_time_by_host(db) latest_map = latest_agent_versions_by_product(db) update_cfg = get_effective_agent_update_config(db) git_release = get_git_release_versions(update_cfg, db=db) reference_map = reference_agent_versions_by_product( update_cfg, latest_map, git_versions=git_release.versions, ) items: list[HostSummary] = [] for host in rows: event_count = db.scalar( select(func.count()).select_from(Event).where(Event.host_id == host.id) ) last_hb = hb_map.get(host.id) items.append( HostSummary( id=host.id, hostname=host.hostname, display_name=host.display_name, os_family=host.os_family, os_version=host.os_version, product=host.product, product_version=host.product_version, ipv4=host.ipv4, last_seen_at=host.last_seen_at, event_count=int(event_count or 0), last_heartbeat_at=last_hb, last_daily_report_at=report_map.get(host.id), last_inventory_at=host.inventory_updated_at, agent_status=compute_agent_status( last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes ), version_status=host_version_status( host, cfg=update_cfg, latest_map=latest_map, git_versions=git_release.versions, ), pending_agent_update=bool(host.pending_agent_update), ) ) return HostListResponse( items=items, total=total, page=page, page_size=page_size, latest_agent_versions=latest_map, reference_agent_versions=reference_map, ) def _host_detail_from_model( host: Host, *, db: Session, settings, ) -> HostDetail: hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE) report_map = max_daily_report_time_by_host(db) event_count = db.scalar( select(func.count()).select_from(Event).where(Event.host_id == host.id) ) last_hb = hb_map.get(host.id) latest_map = latest_agent_versions_by_product(db) update_cfg = get_effective_agent_update_config(db) git_release = get_git_release_versions(update_cfg, db=db) return HostDetail( id=host.id, hostname=host.hostname, display_name=host.display_name, os_family=host.os_family, os_version=host.os_version, product=host.product, product_version=host.product_version, ipv4=host.ipv4, ipv6=host.ipv6, last_seen_at=host.last_seen_at, event_count=int(event_count or 0), last_heartbeat_at=last_hb, last_daily_report_at=report_map.get(host.id), last_inventory_at=host.inventory_updated_at, agent_status=compute_agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes), version_status=host_version_status( host, cfg=update_cfg, latest_map=latest_map, git_versions=git_release.versions, ), pending_agent_update=bool(host.pending_agent_update), agent_instance_id=host.agent_instance_id, tags=host.tags if isinstance(host.tags, list) else [], use_sac_mode=host.use_sac_mode, inventory=host.inventory if isinstance(host.inventory, dict) else None, inventory_updated_at=host.inventory_updated_at, created_at=host.created_at, ssh_admin_ok=host.ssh_admin_ok, ssh_admin_checked_at=host.ssh_admin_checked_at, pending_update_requested_at=host.pending_update_requested_at, pending_update_target_version=host.pending_update_target_version, agent_config_revision=int(host.agent_config_revision or 0), agent_config=get_host_agent_settings(host) or None, agent_update_state=host.agent_update_state, agent_update_last_at=host.agent_update_last_at, agent_update_last_error=host.agent_update_last_error, ) def _set_ssh_admin_status(host: Host, ok: bool) -> None: host.ssh_admin_ok = ok host.ssh_admin_checked_at = datetime.now(timezone.utc) @router.get("/{host_id}", response_model=HostDetail) def get_host( host_id: int, db: Session = Depends(get_db), _user: str = Depends(get_current_user), ) -> HostDetail: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") settings = get_settings() return _host_detail_from_model(host, db=db, settings=settings) class HostDeleteResponse(BaseModel): status: str host_id: int hostname: str deleted_events: int deleted_problems: int class HostWinRmTestResponse(BaseModel): ok: bool message: str target: str hostname: str | None = None class HostSshActionResponse(BaseModel): ok: bool message: str target: str stdout: str | None = None stderr: str | None = None exit_code: int | None = None product_version: str | None = None ssh_admin_ok: bool | None = None class HostAgentUpdateRequestResponse(BaseModel): status: str pending_agent_update: bool target_version: str | None = None agent_update_state: str | None = None class HostAgentConfigUpdate(BaseModel): settings: dict[str, object] = {} class HostAgentConfigResponse(BaseModel): config_revision: int settings: dict[str, object] def _ssh_action_response( result: SshCommandResult, *, attempts: list[str] | None = None, ssh_admin_ok: bool | None = None, ) -> HostSshActionResponse: message = result.message if attempts: message = f"{message} (пробовали: {', '.join(attempts)})" return HostSshActionResponse( ok=result.ok, message=message, target=result.target, stdout=result.stdout or None, stderr=result.stderr or None, exit_code=result.exit_code, product_version=result.agent_version, ssh_admin_ok=ssh_admin_ok, ) def _run_ssh_action_on_host( host: Host, cfg, *, action, ) -> HostSshActionResponse: try: targets = iter_ssh_targets(host) except SshHostNotLinuxError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except SshHostTargetMissingError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc last_result: SshCommandResult | None = None attempts: list[str] = [] for target in targets: try: result = action(target=target, user=cfg.user, password=cfg.password) except LinuxAdminNotConfiguredError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc last_result = result attempts.append(f"{target}={'OK' if result.ok else 'fail'}") if result.ok: return _ssh_action_response(result, attempts=attempts if len(attempts) > 1 else None) assert last_result is not None return _ssh_action_response(last_result, attempts=attempts if len(attempts) > 1 else None) @router.post("/{host_id}/actions/winrm-test", response_model=HostWinRmTestResponse) def test_host_winrm( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostWinRmTestResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") cfg = get_effective_win_admin_config(db) if not cfg.configured: raise HTTPException(status_code=400, detail="Windows domain admin is not configured") try: targets = iter_winrm_targets(host) except HostNotWindowsError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except HostTargetMissingError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc last_result = None attempts: list[str] = [] for target in targets: try: result = test_winrm_connection( target=target, user=cfg.user, password=cfg.password, ) except WinAdminNotConfiguredError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc last_result = result attempts.append(f"{target}={'OK' if result.ok else 'fail'}") if result.ok: if len(targets) > 1: result = WinRmTestResult( ok=True, message=f"{result.message} (пробовали: {', '.join(attempts)})", target=result.target, hostname=result.hostname, ) return HostWinRmTestResponse( ok=result.ok, message=result.message, target=result.target, hostname=result.hostname, ) assert last_result is not None message = last_result.message if len(attempts) > 1: message = f"{message} (пробовали: {', '.join(attempts)})" return HostWinRmTestResponse( ok=False, message=message, target=last_result.target, hostname=last_result.hostname, ) @router.post("/{host_id}/actions/ssh-test", response_model=HostSshActionResponse) def test_host_ssh( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostSshActionResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") cfg = get_effective_linux_admin_config(db) if not cfg.configured: raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection) _set_ssh_admin_status(host, response.ok) db.commit() return response.model_copy(update={"ssh_admin_ok": host.ssh_admin_ok}) @router.post("/{host_id}/actions/agent-update", response_model=HostSshActionResponse) def update_host_agent_via_ssh( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostSshActionResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") cfg = get_effective_linux_admin_config(db) if not cfg.configured: raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") response = _run_ssh_action_on_host(host, cfg, action=run_ssh_monitor_update) _set_ssh_admin_status(host, response.ok) if response.ok and response.product_version: host.product_version = response.product_version db.commit() return response.model_copy( update={ "product_version": host.product_version if response.ok else response.product_version, "ssh_admin_ok": host.ssh_admin_ok, } ) @router.post("/{host_id}/actions/request-agent-update", response_model=HostAgentUpdateRequestResponse) def post_request_agent_update( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostAgentUpdateRequestResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") try: request_agent_update(db, host) except AgentUpdateNotAllowedError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc db.commit() return HostAgentUpdateRequestResponse( status="requested", pending_agent_update=host.pending_agent_update, target_version=host.pending_update_target_version, agent_update_state=host.agent_update_state, ) @router.post("/{host_id}/actions/agent-update-fallback", response_model=HostSshActionResponse) def post_agent_update_fallback( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostSshActionResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") ok, message, version = execute_agent_update_fallback(db, host) db.commit() return HostSshActionResponse( ok=ok, message=message, target=host.hostname, product_version=version or (host.product_version if ok else None), ssh_admin_ok=host.ssh_admin_ok, ) @router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse) def patch_host_agent_config( host_id: int, body: HostAgentConfigUpdate, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostAgentConfigResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") try: update_host_agent_config(db, host, body.settings) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc db.commit() settings = get_host_agent_settings(host) return HostAgentConfigResponse( config_revision=int(host.agent_config_revision or 0), settings=settings, ) @router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse) def get_host_agent_config( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostAgentConfigResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") settings = get_host_agent_settings(host) return HostAgentConfigResponse( config_revision=int(host.agent_config_revision or 0), settings=settings, ) @router.delete("/{host_id}", response_model=HostDeleteResponse) def delete_host( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostDeleteResponse: result = delete_host_and_related(db, host_id) if result is None: raise HTTPException(status_code=404, detail="Host not found") db.commit() return HostDeleteResponse( status="deleted", host_id=result.host_id, hostname=result.hostname, deleted_events=result.deleted_events, deleted_problems=result.deleted_problems, )