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")
|
||||||
+97
-33
@@ -1,10 +1,9 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
from app.auth.jwt_auth import get_current_user, require_admin
|
from app.auth.jwt_auth import get_current_user, require_admin
|
||||||
from app.config import get_settings
|
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_git_release import get_git_release_versions
|
||||||
from app.services.agent_update import (
|
from app.services.agent_update import (
|
||||||
AgentUpdateNotAllowedError,
|
AgentUpdateNotAllowedError,
|
||||||
execute_agent_update_fallback,
|
|
||||||
host_version_status,
|
host_version_status,
|
||||||
request_agent_update,
|
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_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.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.host_delete import delete_host_and_related
|
||||||
@@ -264,6 +269,32 @@ class HostAgentConfigResponse(BaseModel):
|
|||||||
settings: dict[str, object]
|
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(
|
def _ssh_action_response(
|
||||||
result: SshCommandResult,
|
result: SshCommandResult,
|
||||||
*,
|
*,
|
||||||
@@ -399,12 +430,16 @@ def test_host_ssh(
|
|||||||
return response.model_copy(update={"ssh_admin_ok": host.ssh_admin_ok})
|
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(
|
def update_host_agent_via_ssh(
|
||||||
host_id: int,
|
host_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user=Depends(require_admin),
|
_user=Depends(require_admin),
|
||||||
) -> HostSshActionResponse:
|
) -> HostRemoteActionStartResponse:
|
||||||
host = db.get(Host, host_id)
|
host = db.get(Host, host_id)
|
||||||
if host is None:
|
if host is None:
|
||||||
raise HTTPException(status_code=404, detail="Host not found")
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
@@ -413,23 +448,21 @@ def update_host_agent_via_ssh(
|
|||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
||||||
|
|
||||||
agent_cfg = get_effective_agent_update_config(db)
|
title = "Обновление ssh-monitor (SSH)"
|
||||||
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
|
try:
|
||||||
branch = (agent_cfg.git_branch or "main").strip() or "main"
|
start_host_remote_action(
|
||||||
response = _run_ssh_action_on_host(
|
db,
|
||||||
host,
|
host,
|
||||||
cfg,
|
title=title,
|
||||||
action=partial(run_ssh_monitor_update, repo_url=repo_url, git_branch=branch),
|
runner=run_ssh_monitor_update_action,
|
||||||
)
|
)
|
||||||
_set_ssh_admin_status(host, response.ok)
|
except RemoteActionAlreadyRunningError as exc:
|
||||||
if response.ok and response.product_version:
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
host.product_version = response.product_version
|
return HostRemoteActionStartResponse(
|
||||||
db.commit()
|
status="running",
|
||||||
return response.model_copy(
|
host_id=host.id,
|
||||||
update={
|
title=title,
|
||||||
"product_version": host.product_version if response.ok else response.product_version,
|
message="Обновление запущено на сервере SAC и продолжится в фоне",
|
||||||
"ssh_admin_ok": host.ssh_admin_ok,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -455,28 +488,59 @@ 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(
|
def post_agent_update_fallback(
|
||||||
host_id: int,
|
host_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user=Depends(require_admin),
|
_user=Depends(require_admin),
|
||||||
) -> HostSshActionResponse:
|
) -> HostRemoteActionStartResponse:
|
||||||
host = db.get(Host, host_id)
|
host = db.get(Host, host_id)
|
||||||
if host is None:
|
if host is None:
|
||||||
raise HTTPException(status_code=404, detail="Host not found")
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
result = execute_agent_update_fallback(db, host)
|
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||||
db.commit()
|
from app.services.ssh_connect import is_linux_host
|
||||||
return HostSshActionResponse(
|
from app.services.winrm_connect import is_windows_host
|
||||||
ok=result.ok,
|
|
||||||
message=result.message,
|
product = (host.product or "").strip()
|
||||||
target=result.target or host.hostname,
|
if product == PRODUCT_RDP or is_windows_host(host):
|
||||||
stdout=result.stdout or None,
|
title = "Обновление через WinRM"
|
||||||
stderr=result.stderr or None,
|
elif product == PRODUCT_SSH or is_linux_host(host):
|
||||||
exit_code=result.exit_code,
|
title = "Fallback SSH (ssh-monitor)"
|
||||||
product_version=result.product_version or (host.product_version if result.ok else None),
|
else:
|
||||||
ssh_admin_ok=host.ssh_admin_ok,
|
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)
|
@router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class Host(Base):
|
|||||||
agent_update_state: Mapped[str | None] = mapped_column(String(32))
|
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_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
agent_update_last_error: Mapped[str | None] = mapped_column(Text)
|
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())
|
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())
|
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)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.20.18"
|
APP_VERSION = "0.20.19"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
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
|
@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_api_key", lambda: None)
|
||||||
monkeypatch.setattr("app.main.bootstrap_users", 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():
|
def override_get_db():
|
||||||
|
session = test_session_local()
|
||||||
try:
|
try:
|
||||||
yield db_session
|
yield session
|
||||||
finally:
|
finally:
|
||||||
pass
|
session.close()
|
||||||
|
|
||||||
fastapi_app.dependency_overrides[get_db] = override_get_db
|
fastapi_app.dependency_overrides[get_db] = override_get_db
|
||||||
with TestClient(fastapi_app) as c:
|
with TestClient(fastapi_app) as c:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for agent update settings, poll, request and ingest lifecycle."""
|
"""Tests for agent update settings, poll, request and ingest lifecycle."""
|
||||||
|
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -17,6 +18,21 @@ from app.services.ingest import ingest_event
|
|||||||
from tests.test_ingest import VALID_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):
|
def test_agent_update_settings_sac_mode(db_session):
|
||||||
cfg = upsert_agent_update_settings(
|
cfg = upsert_agent_update_settings(
|
||||||
db_session,
|
db_session,
|
||||||
@@ -203,6 +219,8 @@ def test_api_agent_update_fallback_ssh(jwt_headers, client, db_session, monkeypa
|
|||||||
headers=jwt_headers,
|
headers=jwt_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 202
|
||||||
assert response.json()["ok"] is True
|
assert response.json()["status"] == "running"
|
||||||
assert response.json()["product_version"] == "2.1.0-SAC"
|
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():
|
def test_version_constants():
|
||||||
assert APP_VERSION == "0.20.1"
|
assert APP_VERSION == "0.20.19"
|
||||||
assert APP_NAME == "Security Alert Center"
|
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
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models import Event, Host, Problem
|
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()
|
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
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["hostname"] == "test-host"
|
assert body["hostname"] == "test-host"
|
||||||
assert body["deleted_events"] == 1
|
assert body["deleted_events"] == 1
|
||||||
assert body["deleted_problems"] == 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):
|
def test_delete_host_404(client, jwt_headers):
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
"""Additional Linux admin API tests."""
|
"""Additional Linux admin API tests."""
|
||||||
|
|
||||||
|
import time
|
||||||
from unittest.mock import patch
|
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):
|
def test_get_linux_admin_settings_env_default(jwt_headers, client, monkeypatch):
|
||||||
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "")
|
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "")
|
||||||
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "")
|
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.commit()
|
||||||
db_session.refresh(host)
|
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(
|
mock_update.return_value = SshCommandResult(
|
||||||
ok=True,
|
ok=True,
|
||||||
message="SSH OK (ubabuba), exit 0\nSUMMARY updated",
|
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,
|
headers=jwt_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 202
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["ok"] is True
|
assert body["status"] == "running"
|
||||||
assert body["target"] == "ubabuba"
|
job = wait_remote_job(client, host.id, jwt_headers)
|
||||||
if "product_version" in body:
|
assert job["ok"] is True
|
||||||
assert body["product_version"] is None or isinstance(body["product_version"], str)
|
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 datetime import datetime, timezone
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import inspect as sa_inspect
|
||||||
|
|
||||||
from app.models import Event, Host
|
from app.models import Event, Host
|
||||||
from app.services.winrm_connect import WinRmCmdResult
|
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")
|
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.add_all([ws, gw])
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
ws_id = ws.id
|
||||||
event = _flap_event(db_session, gw=gw, ws=ws)
|
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"
|
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"],
|
["Andrisonova-PC=OK"],
|
||||||
)
|
)
|
||||||
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
|
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
|
assert response.status_code == 200
|
||||||
body = response.json()
|
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 body["target"] == "Andrisonova-PC"
|
||||||
assert "papatramp" in (body["result_stdout"] or "")
|
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):
|
def test_qwinsta_client_not_in_hosts(jwt_headers, client, db_session, monkeypatch):
|
||||||
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
|
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
|
||||||
|
|||||||
@@ -4,6 +4,16 @@
|
|||||||
<main :class="showShell ? 'app-main' : ''">
|
<main :class="showShell ? 'app-main' : ''">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
|
<HostActionLogModal
|
||||||
|
v-if="showShell"
|
||||||
|
:open="hostRemoteActionLog.open"
|
||||||
|
:title="hostRemoteActionLog.title"
|
||||||
|
:loading="hostRemoteActionLog.loading"
|
||||||
|
:ok="hostRemoteActionLog.ok"
|
||||||
|
:message="hostRemoteActionLog.message"
|
||||||
|
:output="hostRemoteActionLog.output"
|
||||||
|
@close="closeHostRemoteActionLog"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -12,6 +22,11 @@ import { computed, onMounted } from "vue";
|
|||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { getToken } from "./api";
|
import { getToken } from "./api";
|
||||||
import AppSidebar from "./components/AppSidebar.vue";
|
import AppSidebar from "./components/AppSidebar.vue";
|
||||||
|
import HostActionLogModal from "./components/HostActionLogModal.vue";
|
||||||
|
import {
|
||||||
|
closeHostRemoteActionLog,
|
||||||
|
hostRemoteActionLog,
|
||||||
|
} from "./composables/useHostRemoteAction";
|
||||||
import { refreshSessionRole } from "./router";
|
import { refreshSessionRole } from "./router";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|||||||
+36
-6
@@ -517,8 +517,33 @@ export function testHostSsh(hostId: number): Promise<HostSshActionResult> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateHostAgentViaSsh(hostId: number): Promise<HostSshActionResult> {
|
export interface HostRemoteActionStartResult {
|
||||||
return apiFetch<HostSshActionResult>(`/api/v1/hosts/${hostId}/actions/agent-update`, {
|
status: string;
|
||||||
|
host_id: number;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostRemoteActionJobStatus {
|
||||||
|
active: boolean;
|
||||||
|
host_id: number;
|
||||||
|
hostname?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
stdout?: string | null;
|
||||||
|
stderr?: string | null;
|
||||||
|
output?: string | null;
|
||||||
|
ok?: boolean | null;
|
||||||
|
exit_code?: number | null;
|
||||||
|
product_version?: string | null;
|
||||||
|
agent_update_state?: string | null;
|
||||||
|
started_at?: string | null;
|
||||||
|
finished_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateHostAgentViaSsh(hostId: number): Promise<HostRemoteActionStartResult> {
|
||||||
|
return apiFetch<HostRemoteActionStartResult>(`/api/v1/hosts/${hostId}/actions/agent-update`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -537,10 +562,15 @@ export function requestHostAgentUpdate(hostId: number): Promise<HostAgentUpdateR
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runHostAgentUpdateFallback(hostId: number): Promise<HostSshActionResult> {
|
export function runHostAgentUpdateFallback(hostId: number): Promise<HostRemoteActionStartResult> {
|
||||||
return apiFetch<HostSshActionResult>(`/api/v1/hosts/${hostId}/actions/agent-update-fallback`, {
|
return apiFetch<HostRemoteActionStartResult>(
|
||||||
method: "POST",
|
`/api/v1/hosts/${hostId}/actions/agent-update-fallback`,
|
||||||
});
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
|
||||||
|
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostAgentConfigResponse {
|
export interface HostAgentConfigResponse {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { reactive } from "vue";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
fetchHost,
|
||||||
|
fetchHostRemoteJob,
|
||||||
|
runHostAgentUpdateFallback,
|
||||||
|
updateHostAgentViaSsh,
|
||||||
|
type HostDetail,
|
||||||
|
type HostRemoteActionJobStatus,
|
||||||
|
} from "../api";
|
||||||
|
import { emitHostPatch } from "../utils/hostPatchBus";
|
||||||
|
|
||||||
|
export type HostRemoteActionKind = "fallback" | "ssh-update";
|
||||||
|
|
||||||
|
export const hostRemoteActionLog = reactive({
|
||||||
|
open: false,
|
||||||
|
title: "",
|
||||||
|
loading: false,
|
||||||
|
ok: null as boolean | null,
|
||||||
|
message: "",
|
||||||
|
output: "",
|
||||||
|
hostId: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const POLL_MS = 1500;
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyJobToModal(job: HostRemoteActionJobStatus) {
|
||||||
|
hostRemoteActionLog.title = job.title || hostRemoteActionLog.title;
|
||||||
|
hostRemoteActionLog.message = job.message || hostRemoteActionLog.message;
|
||||||
|
hostRemoteActionLog.output = job.output || job.stdout || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishModal(job: HostRemoteActionJobStatus) {
|
||||||
|
hostRemoteActionLog.loading = false;
|
||||||
|
hostRemoteActionLog.ok = job.ok ?? job.status === "success";
|
||||||
|
applyJobToModal(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
|
||||||
|
for (;;) {
|
||||||
|
const job = await fetchHostRemoteJob(hostId);
|
||||||
|
applyJobToModal(job);
|
||||||
|
if (!job.active && job.status !== "running") {
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
await sleep(POLL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchHostFromJob(hostId: number, job: HostRemoteActionJobStatus) {
|
||||||
|
try {
|
||||||
|
const detail: HostDetail = await fetchHost(hostId);
|
||||||
|
if (job.product_version) {
|
||||||
|
detail.product_version = job.product_version;
|
||||||
|
}
|
||||||
|
if (job.agent_update_state) {
|
||||||
|
detail.agent_update_state = job.agent_update_state;
|
||||||
|
}
|
||||||
|
if (job.ok === false && job.message) {
|
||||||
|
detail.agent_update_last_error = job.message;
|
||||||
|
}
|
||||||
|
emitHostPatch(detail);
|
||||||
|
} catch {
|
||||||
|
/* list refresh on next open is enough */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRemoteAction(hostId: number, kind: HostRemoteActionKind) {
|
||||||
|
if (kind === "fallback") {
|
||||||
|
await runHostAgentUpdateFallback(hostId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updateHostAgentViaSsh(hostId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runHostRemoteAction(
|
||||||
|
hostId: number,
|
||||||
|
title: string,
|
||||||
|
kind: HostRemoteActionKind,
|
||||||
|
): Promise<HostRemoteActionJobStatus | null> {
|
||||||
|
hostRemoteActionLog.open = true;
|
||||||
|
hostRemoteActionLog.loading = true;
|
||||||
|
hostRemoteActionLog.ok = null;
|
||||||
|
hostRemoteActionLog.title = title;
|
||||||
|
hostRemoteActionLog.message = "Запуск на сервере SAC…";
|
||||||
|
hostRemoteActionLog.output = "";
|
||||||
|
hostRemoteActionLog.hostId = hostId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await startRemoteAction(hostId, kind);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 409) {
|
||||||
|
/* already running — poll existing job */
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hostRemoteActionLog.message =
|
||||||
|
"Выполняется на удалённом хосте… Можно перейти в другой раздел SAC.";
|
||||||
|
const job = await pollRemoteJob(hostId);
|
||||||
|
finishModal(job);
|
||||||
|
await patchHostFromJob(hostId, job);
|
||||||
|
return job;
|
||||||
|
} catch (e) {
|
||||||
|
hostRemoteActionLog.loading = false;
|
||||||
|
hostRemoteActionLog.ok = false;
|
||||||
|
hostRemoteActionLog.message = e instanceof Error ? e.message : "Ошибка выполнения";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeHostRemoteActionLog() {
|
||||||
|
if (hostRemoteActionLog.loading) return;
|
||||||
|
hostRemoteActionLog.open = false;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||||
export const APP_NAME = "Security Alert Center";
|
export const APP_NAME = "Security Alert Center";
|
||||||
export const APP_VERSION = "0.20.18";
|
export const APP_VERSION = "0.20.19";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
{{ runningAgentFallback ? "Обновление…" : "Обновить через WinRM" }}
|
{{ runningAgentFallback ? "Обновление…" : "Обновить через WinRM" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="agentControlMessage && !actionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
|
<p v-if="agentControlMessage && !hostRemoteActionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLinuxHost" class="host-actions">
|
<div v-if="isLinuxHost" class="host-actions">
|
||||||
<div class="host-actions-row">
|
<div class="host-actions-row">
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
{{ runningAgentFallback ? "Fallback…" : "Fallback SSH" }}
|
{{ runningAgentFallback ? "Fallback…" : "Fallback SSH" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="agentControlMessage && !actionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
|
<p v-if="agentControlMessage && !hostRemoteActionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
|
||||||
<div v-if="showSshTestFeedback && (sshTestMessage || sshTestOutput)" class="host-action-feedback">
|
<div v-if="showSshTestFeedback && (sshTestMessage || sshTestOutput)" class="host-action-feedback">
|
||||||
<p :class="sshTestOk ? 'success' : 'error'">{{ sshTestMessage }}</p>
|
<p :class="sshTestOk ? 'success' : 'error'">{{ sshTestMessage }}</p>
|
||||||
<pre v-if="sshTestOutput" class="host-ssh-output">{{ sshTestOutput }}</pre>
|
<pre v-if="sshTestOutput" class="host-ssh-output">{{ sshTestOutput }}</pre>
|
||||||
@@ -272,38 +272,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<HostActionLogModal
|
|
||||||
:open="actionLog.open"
|
|
||||||
:title="actionLog.title"
|
|
||||||
:loading="actionLog.loading"
|
|
||||||
:ok="actionLog.ok"
|
|
||||||
:message="actionLog.message"
|
|
||||||
:output="actionLog.output"
|
|
||||||
@close="closeActionLog"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
|
import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||||||
|
import {
|
||||||
|
hostRemoteActionLog,
|
||||||
|
runHostRemoteAction,
|
||||||
|
} from "../composables/useHostRemoteAction";
|
||||||
import {
|
import {
|
||||||
apiFetch,
|
apiFetch,
|
||||||
fetchHost,
|
fetchHost,
|
||||||
fetchRecentEvents,
|
fetchRecentEvents,
|
||||||
testHostWinRm,
|
testHostWinRm,
|
||||||
testHostSsh,
|
testHostSsh,
|
||||||
updateHostAgentViaSsh,
|
|
||||||
requestHostAgentUpdate,
|
requestHostAgentUpdate,
|
||||||
runHostAgentUpdateFallback,
|
|
||||||
patchHostAgentConfig,
|
patchHostAgentConfig,
|
||||||
type EventListResponse,
|
type EventListResponse,
|
||||||
type HostDetail,
|
type HostDetail,
|
||||||
type HostSshActionResult,
|
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { emitHostPatch } from "../utils/hostPatchBus";
|
import { emitHostPatch } from "../utils/hostPatchBus";
|
||||||
import HostActionLogModal from "../components/HostActionLogModal.vue";
|
|
||||||
|
|
||||||
const HOST_ACTION_FEEDBACK_MS = 30_000;
|
const HOST_ACTION_FEEDBACK_MS = 30_000;
|
||||||
|
|
||||||
@@ -328,14 +318,6 @@ const showSshTestFeedback = ref(false);
|
|||||||
const sshTestMessage = ref("");
|
const sshTestMessage = ref("");
|
||||||
const sshTestOk = ref(false);
|
const sshTestOk = ref(false);
|
||||||
const sshTestOutput = ref("");
|
const sshTestOutput = ref("");
|
||||||
const actionLog = reactive({
|
|
||||||
open: false,
|
|
||||||
title: "",
|
|
||||||
loading: false,
|
|
||||||
ok: null as boolean | null,
|
|
||||||
message: "",
|
|
||||||
output: "",
|
|
||||||
});
|
|
||||||
const requestingAgentUpdate = ref(false);
|
const requestingAgentUpdate = ref(false);
|
||||||
const runningAgentFallback = ref(false);
|
const runningAgentFallback = ref(false);
|
||||||
const agentControlMessage = ref("");
|
const agentControlMessage = ref("");
|
||||||
@@ -609,59 +591,6 @@ function formatSshOutput(result: { stdout: string | null; stderr: string | null;
|
|||||||
return parts.join("\n\n");
|
return parts.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openActionLog(title: string) {
|
|
||||||
actionLog.open = true;
|
|
||||||
actionLog.title = title;
|
|
||||||
actionLog.loading = true;
|
|
||||||
actionLog.ok = null;
|
|
||||||
actionLog.message = "Подключение к хосту и выполнение команды…";
|
|
||||||
actionLog.output = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function finishActionLog(result: { ok: boolean; message: string; output?: string }) {
|
|
||||||
actionLog.loading = false;
|
|
||||||
actionLog.ok = result.ok;
|
|
||||||
actionLog.message = result.message;
|
|
||||||
actionLog.output = result.output ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeActionLog() {
|
|
||||||
if (actionLog.loading) return;
|
|
||||||
actionLog.open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runRemoteHostAction(
|
|
||||||
title: string,
|
|
||||||
request: () => Promise<HostSshActionResult>,
|
|
||||||
options: { refreshHost?: boolean; applyProductVersion?: boolean } = {},
|
|
||||||
) {
|
|
||||||
openActionLog(title);
|
|
||||||
try {
|
|
||||||
const result = await request();
|
|
||||||
finishActionLog({
|
|
||||||
ok: result.ok,
|
|
||||||
message: result.message,
|
|
||||||
output: formatSshOutput(result),
|
|
||||||
});
|
|
||||||
if (result.ok && result.product_version && host.value && options.applyProductVersion !== false) {
|
|
||||||
host.value = { ...host.value, product_version: result.product_version };
|
|
||||||
emitHostPatch(host.value);
|
|
||||||
} else if (host.value && result.ssh_admin_ok != null) {
|
|
||||||
host.value = { ...host.value, ssh_admin_ok: result.ssh_admin_ok };
|
|
||||||
}
|
|
||||||
if (options.refreshHost) {
|
|
||||||
await loadHost({ preserveActionLog: true });
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch (e) {
|
|
||||||
finishActionLog({
|
|
||||||
ok: false,
|
|
||||||
message: e instanceof Error ? e.message : "Ошибка выполнения",
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyHostDetail(detail: HostDetail) {
|
function applyHostDetail(detail: HostDetail) {
|
||||||
host.value = detail;
|
host.value = detail;
|
||||||
syncConfigFormFromHost(detail);
|
syncConfigFormFromHost(detail);
|
||||||
@@ -691,14 +620,13 @@ async function runAgentFallback() {
|
|||||||
agentControlMessage.value = "";
|
agentControlMessage.value = "";
|
||||||
const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)";
|
const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)";
|
||||||
try {
|
try {
|
||||||
const result = await runRemoteHostAction(
|
const job = await runHostRemoteAction(hostId.value, title, "fallback");
|
||||||
title,
|
if (job) {
|
||||||
() => runHostAgentUpdateFallback(hostId.value),
|
agentControlOk.value = job.ok ?? false;
|
||||||
{ refreshHost: true },
|
agentControlMessage.value = job.message || "";
|
||||||
);
|
if (job.ok) {
|
||||||
if (result) {
|
await loadHost();
|
||||||
agentControlOk.value = result.ok;
|
}
|
||||||
agentControlMessage.value = result.message;
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
runningAgentFallback.value = false;
|
runningAgentFallback.value = false;
|
||||||
@@ -732,7 +660,7 @@ async function saveAgentConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadHost(options: { preserveActionLog?: boolean } = {}) {
|
async function loadHost() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
winRmTestMessage.value = "";
|
winRmTestMessage.value = "";
|
||||||
@@ -743,9 +671,6 @@ async function loadHost(options: { preserveActionLog?: boolean } = {}) {
|
|||||||
showSshTestFeedback.value = false;
|
showSshTestFeedback.value = false;
|
||||||
sshTestMessage.value = "";
|
sshTestMessage.value = "";
|
||||||
sshTestOutput.value = "";
|
sshTestOutput.value = "";
|
||||||
if (!options.preserveActionLog) {
|
|
||||||
closeActionLog();
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const detail = await fetchHost(hostId.value);
|
const detail = await fetchHost(hostId.value);
|
||||||
applyHostDetail(detail);
|
applyHostDetail(detail);
|
||||||
@@ -762,18 +687,15 @@ async function loadHost(options: { preserveActionLog?: boolean } = {}) {
|
|||||||
async function runAgentUpdate() {
|
async function runAgentUpdate() {
|
||||||
updatingAgent.value = true;
|
updatingAgent.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await runRemoteHostAction(
|
const job = await runHostRemoteAction(
|
||||||
|
hostId.value,
|
||||||
"Обновление ssh-monitor (SSH)",
|
"Обновление ssh-monitor (SSH)",
|
||||||
() => updateHostAgentViaSsh(hostId.value),
|
"ssh-update",
|
||||||
{ refreshHost: false },
|
|
||||||
);
|
);
|
||||||
if (result?.ok) {
|
if (job?.ok) {
|
||||||
const detail = await fetchHost(hostId.value);
|
const detail = await fetchHost(hostId.value);
|
||||||
if (result.product_version) {
|
if (job.product_version) {
|
||||||
detail.product_version = result.product_version;
|
detail.product_version = job.product_version;
|
||||||
}
|
|
||||||
if (result.ssh_admin_ok != null) {
|
|
||||||
detail.ssh_admin_ok = result.ssh_admin_ok;
|
|
||||||
}
|
}
|
||||||
applyHostDetail(detail);
|
applyHostDetail(detail);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user