ec5ec62240
Pass REPO_URL/GIT_BRANCH from agent settings over SSH, preflight git remotes on stale hosts, and stop using GitHub origin on routers.
535 lines
18 KiB
Python
535 lines
18 KiB
Python
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 functools import partial
|
|
|
|
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")
|
|
|
|
agent_cfg = get_effective_agent_update_config(db)
|
|
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
|
|
branch = (agent_cfg.git_branch or "main").strip() or "main"
|
|
response = _run_ssh_action_on_host(
|
|
host,
|
|
cfg,
|
|
action=partial(run_ssh_monitor_update, repo_url=repo_url, git_branch=branch),
|
|
)
|
|
_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,
|
|
)
|
|
|