feat: background remote agent updates survive SAC navigation (0.20.19)
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
"""host remote_action JSON for background SSH/WinRM jobs
|
||||
|
||||
Revision ID: 022_host_remote_action
|
||||
Revises: 021_agent_git_release
|
||||
Create Date: 2026-06-21
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "022_host_remote_action"
|
||||
down_revision = "021_agent_git_release"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"hosts",
|
||||
sa.Column("remote_action", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("hosts", "remote_action")
|
||||
+99
-35
@@ -1,10 +1,9 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
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
|
||||
@@ -18,10 +17,16 @@ from app.services.agent_version import (
|
||||
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.host_remote_actions import (
|
||||
RemoteActionAlreadyRunningError,
|
||||
get_remote_action_status,
|
||||
run_agent_fallback_action,
|
||||
run_ssh_monitor_update_action,
|
||||
start_host_remote_action,
|
||||
)
|
||||
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
|
||||
@@ -264,6 +269,32 @@ class HostAgentConfigResponse(BaseModel):
|
||||
settings: dict[str, object]
|
||||
|
||||
|
||||
class HostRemoteActionStartResponse(BaseModel):
|
||||
status: str
|
||||
host_id: int
|
||||
title: str
|
||||
message: str
|
||||
|
||||
|
||||
class HostRemoteActionJobResponse(BaseModel):
|
||||
active: bool
|
||||
host_id: int
|
||||
hostname: str | None = None
|
||||
status: str | None = None
|
||||
title: str = ""
|
||||
message: str = ""
|
||||
stdout: str | None = None
|
||||
stderr: str | None = None
|
||||
output: str | None = None
|
||||
ok: bool | None = None
|
||||
exit_code: int | None = None
|
||||
product_version: str | None = None
|
||||
target: str | None = None
|
||||
agent_update_state: str | None = None
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
|
||||
|
||||
def _ssh_action_response(
|
||||
result: SshCommandResult,
|
||||
*,
|
||||
@@ -399,12 +430,16 @@ def test_host_ssh(
|
||||
return response.model_copy(update={"ssh_admin_ok": host.ssh_admin_ok})
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/agent-update", response_model=HostSshActionResponse)
|
||||
@router.post(
|
||||
"/{host_id}/actions/agent-update",
|
||||
response_model=HostRemoteActionStartResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
def update_host_agent_via_ssh(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostSshActionResponse:
|
||||
) -> HostRemoteActionStartResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
@@ -413,23 +448,21 @@ def update_host_agent_via_ssh(
|
||||
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,
|
||||
}
|
||||
title = "Обновление ssh-monitor (SSH)"
|
||||
try:
|
||||
start_host_remote_action(
|
||||
db,
|
||||
host,
|
||||
title=title,
|
||||
runner=run_ssh_monitor_update_action,
|
||||
)
|
||||
except RemoteActionAlreadyRunningError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return HostRemoteActionStartResponse(
|
||||
status="running",
|
||||
host_id=host.id,
|
||||
title=title,
|
||||
message="Обновление запущено на сервере SAC и продолжится в фоне",
|
||||
)
|
||||
|
||||
|
||||
@@ -455,30 +488,61 @@ def post_request_agent_update(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/agent-update-fallback", response_model=HostSshActionResponse)
|
||||
@router.post(
|
||||
"/{host_id}/actions/agent-update-fallback",
|
||||
response_model=HostRemoteActionStartResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
def post_agent_update_fallback(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostSshActionResponse:
|
||||
) -> HostRemoteActionStartResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
result = execute_agent_update_fallback(db, host)
|
||||
db.commit()
|
||||
return HostSshActionResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target or host.hostname,
|
||||
stdout=result.stdout or None,
|
||||
stderr=result.stderr or None,
|
||||
exit_code=result.exit_code,
|
||||
product_version=result.product_version or (host.product_version if result.ok else None),
|
||||
ssh_admin_ok=host.ssh_admin_ok,
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
from app.services.ssh_connect import is_linux_host
|
||||
from app.services.winrm_connect import is_windows_host
|
||||
|
||||
product = (host.product or "").strip()
|
||||
if product == PRODUCT_RDP or is_windows_host(host):
|
||||
title = "Обновление через WinRM"
|
||||
elif product == PRODUCT_SSH or is_linux_host(host):
|
||||
title = "Fallback SSH (ssh-monitor)"
|
||||
else:
|
||||
title = "Обновление агента (fallback)"
|
||||
|
||||
try:
|
||||
start_host_remote_action(
|
||||
db,
|
||||
host,
|
||||
title=title,
|
||||
runner=run_agent_fallback_action,
|
||||
)
|
||||
except RemoteActionAlreadyRunningError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return HostRemoteActionStartResponse(
|
||||
status="running",
|
||||
host_id=host.id,
|
||||
title=title,
|
||||
message="Обновление запущено на сервере SAC и продолжится в фоне",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{host_id}/actions/remote-job", response_model=HostRemoteActionJobResponse)
|
||||
def get_host_remote_job(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostRemoteActionJobResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
return HostRemoteActionJobResponse(**get_remote_action_status(host))
|
||||
|
||||
|
||||
@router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||
def patch_host_agent_config(
|
||||
host_id: int,
|
||||
|
||||
@@ -34,6 +34,7 @@ class Host(Base):
|
||||
agent_update_state: Mapped[str | None] = mapped_column(String(32))
|
||||
agent_update_last_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
agent_update_last_error: Mapped[str | None] = mapped_column(Text)
|
||||
remote_action: Mapped[dict | None] = mapped_column(JSONB)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Background SSH/WinRM host actions (survives UI navigation and multi-worker API)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import Host
|
||||
from app.services.agent_update import execute_agent_update_fallback
|
||||
from app.services.agent_update_types import AgentUpdateFallbackResult
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||
from app.services.agent_update_settings import get_effective_agent_update_config
|
||||
from app.services.ssh_connect import iter_ssh_targets, run_ssh_monitor_update, SshCommandResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ActionRunner = Callable[[Session, Host], AgentUpdateFallbackResult | SshCommandResult]
|
||||
|
||||
_lock = threading.Lock()
|
||||
_running: set[int] = set()
|
||||
|
||||
|
||||
class RemoteActionAlreadyRunningError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _run_inline() -> bool:
|
||||
return os.environ.get("SAC_REMOTE_ACTION_INLINE", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _format_output(result: AgentUpdateFallbackResult | SshCommandResult) -> str:
|
||||
parts: list[str] = []
|
||||
stdout = (getattr(result, "stdout", None) or "").strip()
|
||||
stderr = (getattr(result, "stderr", None) or "").strip()
|
||||
if stdout:
|
||||
parts.append(stdout)
|
||||
if stderr:
|
||||
parts.append(stderr)
|
||||
exit_code = getattr(result, "exit_code", None)
|
||||
if exit_code is not None:
|
||||
parts.append(f"exit code: {exit_code}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _result_payload(
|
||||
result: AgentUpdateFallbackResult | SshCommandResult,
|
||||
*,
|
||||
title: str,
|
||||
started_at: str,
|
||||
) -> dict[str, Any]:
|
||||
finished = _utcnow().isoformat()
|
||||
product_version = getattr(result, "product_version", None) or getattr(result, "agent_version", None)
|
||||
return {
|
||||
"title": title,
|
||||
"status": "success" if result.ok else "failed",
|
||||
"message": result.message,
|
||||
"stdout": getattr(result, "stdout", None) or "",
|
||||
"stderr": getattr(result, "stderr", None) or "",
|
||||
"output": _format_output(result),
|
||||
"ok": result.ok,
|
||||
"exit_code": getattr(result, "exit_code", None),
|
||||
"product_version": product_version,
|
||||
"target": getattr(result, "target", None) or "",
|
||||
"started_at": started_at,
|
||||
"finished_at": finished,
|
||||
}
|
||||
|
||||
|
||||
def _mark_running(db: Session, host: Host, *, title: str) -> str:
|
||||
started_at = _utcnow().isoformat()
|
||||
host.agent_update_state = "running"
|
||||
host.agent_update_last_error = None
|
||||
host.remote_action = {
|
||||
"title": title,
|
||||
"status": "running",
|
||||
"message": "Подключение к хосту и выполнение команды…",
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"output": "",
|
||||
"ok": None,
|
||||
"exit_code": None,
|
||||
"product_version": None,
|
||||
"target": host.hostname,
|
||||
"started_at": started_at,
|
||||
"finished_at": None,
|
||||
}
|
||||
db.flush()
|
||||
return started_at
|
||||
|
||||
|
||||
def _apply_ssh_update_result(db: Session, host: Host, result: SshCommandResult) -> None:
|
||||
now = _utcnow()
|
||||
host.agent_update_last_at = now
|
||||
if result.ok:
|
||||
host.agent_update_state = "success"
|
||||
host.agent_update_last_error = None
|
||||
if result.agent_version:
|
||||
host.product_version = result.agent_version
|
||||
host.ssh_admin_ok = True
|
||||
host.ssh_admin_checked_at = now
|
||||
else:
|
||||
host.agent_update_state = "failed"
|
||||
host.agent_update_last_error = (result.message or "SSH update failed")[:2000]
|
||||
|
||||
|
||||
def start_host_remote_action(
|
||||
db: Session,
|
||||
host: Host,
|
||||
*,
|
||||
title: str,
|
||||
runner: ActionRunner,
|
||||
) -> dict[str, Any]:
|
||||
host_id = int(host.id)
|
||||
with _lock:
|
||||
if host_id in _running or host.agent_update_state == "running":
|
||||
raise RemoteActionAlreadyRunningError(
|
||||
f"Remote action already running for host {host.hostname}"
|
||||
)
|
||||
_running.add(host_id)
|
||||
|
||||
started_at = _mark_running(db, host, title=title)
|
||||
db.commit()
|
||||
|
||||
def _worker() -> None:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
row = session.get(Host, host_id)
|
||||
if row is None:
|
||||
return
|
||||
result = runner(session, row)
|
||||
started = (row.remote_action or {}).get("started_at") or started_at
|
||||
row.remote_action = _result_payload(result, title=title, started_at=started)
|
||||
if isinstance(result, SshCommandResult):
|
||||
_apply_ssh_update_result(session, row, result)
|
||||
session.commit()
|
||||
except Exception:
|
||||
logger.exception("Background remote action failed for host_id=%s", host_id)
|
||||
session.rollback()
|
||||
row = session.get(Host, host_id)
|
||||
if row is not None:
|
||||
started = (row.remote_action or {}).get("started_at") or started_at
|
||||
row.agent_update_state = "failed"
|
||||
row.agent_update_last_error = "Internal error during remote action"
|
||||
row.remote_action = {
|
||||
"title": title,
|
||||
"status": "failed",
|
||||
"message": "Внутренняя ошибка SAC при выполнении действия",
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"output": "",
|
||||
"ok": False,
|
||||
"exit_code": None,
|
||||
"product_version": None,
|
||||
"target": row.hostname,
|
||||
"started_at": started,
|
||||
"finished_at": _utcnow().isoformat(),
|
||||
}
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
with _lock:
|
||||
_running.discard(host_id)
|
||||
|
||||
if _run_inline():
|
||||
_worker()
|
||||
else:
|
||||
threading.Thread(target=_worker, name=f"sac-remote-action-{host_id}", daemon=True).start()
|
||||
return dict(host.remote_action or {})
|
||||
|
||||
|
||||
def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult:
|
||||
cfg = get_effective_linux_admin_config(db)
|
||||
if not cfg.configured:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message="Linux SSH admin is not configured",
|
||||
target=host.hostname or "",
|
||||
)
|
||||
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"
|
||||
try:
|
||||
targets = iter_ssh_targets(host)
|
||||
except Exception as exc:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=str(exc),
|
||||
target=host.hostname or "",
|
||||
)
|
||||
last: SshCommandResult | None = None
|
||||
for target in targets:
|
||||
last = run_ssh_monitor_update(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
repo_url=repo_url,
|
||||
git_branch=branch,
|
||||
)
|
||||
if last.ok:
|
||||
return last
|
||||
return last or SshCommandResult(
|
||||
ok=False,
|
||||
message="SSH update failed",
|
||||
target=host.hostname or "",
|
||||
)
|
||||
|
||||
|
||||
def run_agent_fallback_action(db: Session, host: Host) -> AgentUpdateFallbackResult:
|
||||
return execute_agent_update_fallback(db, host)
|
||||
|
||||
|
||||
def get_remote_action_status(host: Host) -> dict[str, Any]:
|
||||
payload = dict(host.remote_action or {})
|
||||
if not payload:
|
||||
return {"active": False, "host_id": host.id}
|
||||
status = payload.get("status") or host.agent_update_state or "unknown"
|
||||
active = status == "running" or host.agent_update_state == "running"
|
||||
return {
|
||||
"active": active,
|
||||
"host_id": host.id,
|
||||
"hostname": host.hostname,
|
||||
"status": status,
|
||||
"title": payload.get("title") or "",
|
||||
"message": payload.get("message") or "",
|
||||
"stdout": payload.get("stdout") or "",
|
||||
"stderr": payload.get("stderr") or "",
|
||||
"output": payload.get("output") or "",
|
||||
"ok": payload.get("ok"),
|
||||
"exit_code": payload.get("exit_code"),
|
||||
"product_version": payload.get("product_version") or host.product_version,
|
||||
"target": payload.get("target") or host.hostname,
|
||||
"agent_update_state": host.agent_update_state,
|
||||
"started_at": payload.get("started_at"),
|
||||
"finished_at": payload.get("finished_at"),
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||
|
||||
APP_NAME = "Security Alert Center"
|
||||
APP_VERSION = "0.20.18"
|
||||
APP_VERSION = "0.20.19"
|
||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||
|
||||
@@ -104,16 +104,32 @@ def mock_agent_git_release(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_remote_action_state():
|
||||
from app.services import host_remote_actions
|
||||
|
||||
with host_remote_actions._lock:
|
||||
host_remote_actions._running.clear()
|
||||
yield
|
||||
with host_remote_actions._lock:
|
||||
host_remote_actions._running.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db_session, monkeypatch):
|
||||
def client(db_session, db_engine, monkeypatch):
|
||||
monkeypatch.setenv("SAC_REMOTE_ACTION_INLINE", "1")
|
||||
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
|
||||
monkeypatch.setattr("app.main.bootstrap_users", lambda: None)
|
||||
|
||||
test_session_local = sessionmaker(bind=db_engine, autocommit=False, autoflush=False)
|
||||
monkeypatch.setattr("app.services.host_remote_actions.SessionLocal", test_session_local)
|
||||
|
||||
def override_get_db():
|
||||
session = test_session_local()
|
||||
try:
|
||||
yield db_session
|
||||
yield session
|
||||
finally:
|
||||
pass
|
||||
session.close()
|
||||
|
||||
fastapi_app.dependency_overrides[get_db] = override_get_db
|
||||
with TestClient(fastapi_app) as c:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for agent update settings, poll, request and ingest lifecycle."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
@@ -17,6 +18,21 @@ from app.services.ingest import ingest_event
|
||||
from tests.test_ingest import VALID_EVENT
|
||||
|
||||
|
||||
def wait_remote_job(client, host_id, headers, timeout=5.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
response = client.get(
|
||||
f"/api/v1/hosts/{host_id}/actions/remote-job",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
if not body.get("active") and body.get("status") != "running":
|
||||
return body
|
||||
time.sleep(0.05)
|
||||
raise AssertionError("remote job did not finish in time")
|
||||
|
||||
|
||||
def test_agent_update_settings_sac_mode(db_session):
|
||||
cfg = upsert_agent_update_settings(
|
||||
db_session,
|
||||
@@ -203,6 +219,8 @@ def test_api_agent_update_fallback_ssh(jwt_headers, client, db_session, monkeypa
|
||||
headers=jwt_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["ok"] is True
|
||||
assert response.json()["product_version"] == "2.1.0-SAC"
|
||||
assert response.status_code == 202
|
||||
assert response.json()["status"] == "running"
|
||||
job = wait_remote_job(client, host.id, jwt_headers)
|
||||
assert job["ok"] is True
|
||||
assert job["product_version"] == "2.1.0-SAC"
|
||||
|
||||
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
||||
|
||||
|
||||
def test_version_constants():
|
||||
assert APP_VERSION == "0.20.1"
|
||||
assert APP_VERSION == "0.20.19"
|
||||
assert APP_NAME == "Security Alert Center"
|
||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.20.1"
|
||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.20.19"
|
||||
|
||||
@@ -4,6 +4,7 @@ import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Event, Host, Problem
|
||||
@@ -138,13 +139,14 @@ def test_delete_host_removes_events_and_problems(client, db_session, jwt_headers
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
r = client.delete(f"/api/v1/hosts/{h.id}", headers=jwt_headers)
|
||||
host_id = h.id
|
||||
r = client.delete(f"/api/v1/hosts/{host_id}", headers=jwt_headers)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["hostname"] == "test-host"
|
||||
assert body["deleted_events"] == 1
|
||||
assert body["deleted_problems"] == 1
|
||||
assert db_session.get(Host, h.id) is None
|
||||
assert db_session.scalar(select(Host.id).where(Host.id == host_id)) is None
|
||||
|
||||
|
||||
def test_delete_host_404(client, jwt_headers):
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
"""Additional Linux admin API tests."""
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def wait_remote_job(client, host_id, headers, timeout=5.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
response = client.get(
|
||||
f"/api/v1/hosts/{host_id}/actions/remote-job",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
if not body.get("active") and body.get("status") != "running":
|
||||
return body
|
||||
time.sleep(0.05)
|
||||
raise AssertionError("remote job did not finish in time")
|
||||
|
||||
|
||||
def test_get_linux_admin_settings_env_default(jwt_headers, client, monkeypatch):
|
||||
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "")
|
||||
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "")
|
||||
@@ -137,7 +153,7 @@ def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch)
|
||||
db_session.commit()
|
||||
db_session.refresh(host)
|
||||
|
||||
with patch("app.api.v1.hosts.run_ssh_monitor_update") as mock_update:
|
||||
with patch("app.services.host_remote_actions.run_ssh_monitor_update") as mock_update:
|
||||
mock_update.return_value = SshCommandResult(
|
||||
ok=True,
|
||||
message="SSH OK (ubabuba), exit 0\nSUMMARY updated",
|
||||
@@ -150,9 +166,11 @@ def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch)
|
||||
headers=jwt_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 202
|
||||
body = response.json()
|
||||
assert body["ok"] is True
|
||||
assert body["target"] == "ubabuba"
|
||||
if "product_version" in body:
|
||||
assert body["product_version"] is None or isinstance(body["product_version"], str)
|
||||
assert body["status"] == "running"
|
||||
job = wait_remote_job(client, host.id, jwt_headers)
|
||||
assert job["ok"] is True
|
||||
assert job["target"] == "ubabuba"
|
||||
if "product_version" in job:
|
||||
assert job["product_version"] is None or isinstance(job["product_version"], str)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
|
||||
from app.models import Event, Host
|
||||
from app.services.winrm_connect import WinRmCmdResult
|
||||
|
||||
@@ -47,6 +49,7 @@ def test_qwinsta_via_winrm_on_client_host(jwt_headers, client, db_session, monke
|
||||
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
|
||||
db_session.add_all([ws, gw])
|
||||
db_session.commit()
|
||||
ws_id = ws.id
|
||||
event = _flap_event(db_session, gw=gw, ws=ws)
|
||||
|
||||
qwinsta_out = " SESSIONNAME USERNAME ID STATE\r\n rdp-tcp#0 B26\\papatramp 2 Active\r\n"
|
||||
@@ -63,6 +66,10 @@ def test_qwinsta_via_winrm_on_client_host(jwt_headers, client, db_session, monke
|
||||
["Andrisonova-PC=OK"],
|
||||
)
|
||||
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
|
||||
mock_run.assert_called_once()
|
||||
called_identity = sa_inspect(mock_run.call_args.args[0]).identity
|
||||
assert called_identity is not None
|
||||
assert called_identity[0] == ws_id
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
@@ -71,10 +78,6 @@ def test_qwinsta_via_winrm_on_client_host(jwt_headers, client, db_session, monke
|
||||
assert body["target"] == "Andrisonova-PC"
|
||||
assert "papatramp" in (body["result_stdout"] or "")
|
||||
|
||||
mock_run.assert_called_once()
|
||||
called_host = mock_run.call_args.args[0]
|
||||
assert called_host.hostname == "Andrisonova-PC"
|
||||
|
||||
|
||||
def test_qwinsta_client_not_in_hosts(jwt_headers, client, db_session, monkeypatch):
|
||||
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
|
||||
|
||||
Reference in New Issue
Block a user