diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index b8a2815..bee0027 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -22,6 +22,8 @@ from app.services.agent_update import ( ) from app.services.host_remote_actions import ( RemoteActionAlreadyRunningError, + cancel_host_remote_action, + clear_stale_running_remote_actions, get_remote_action_status, run_agent_fallback_action, run_ssh_monitor_update_action, @@ -605,6 +607,21 @@ def get_host_remote_job( return HostRemoteActionJobResponse(**get_remote_action_status(host)) +@router.post("/{host_id}/actions/remote-job/cancel", response_model=HostRemoteActionJobResponse) +def cancel_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") + if not cancel_host_remote_action(db, host): + raise HTTPException(status_code=409, detail="No running remote action for this host") + db.refresh(host) + return HostRemoteActionJobResponse(**get_remote_action_status(host)) + + @router.post("/{host_id}/actions/sessions/list", response_model=HostSessionsResponse) def list_host_sessions( host_id: int, diff --git a/backend/app/services/host_remote_actions.py b/backend/app/services/host_remote_actions.py index 4781ad9..01a5a16 100644 --- a/backend/app/services/host_remote_actions.py +++ b/backend/app/services/host_remote_actions.py @@ -219,6 +219,59 @@ def run_agent_fallback_action(db: Session, host: Host) -> AgentUpdateFallbackRes return execute_agent_update_fallback(db, host) +def clear_stale_running_remote_actions( + db: Session, + *, + reason: str = "Прервано перезапуском SAC (фоновый worker не завершил job)", + host_ids: list[int] | None = None, +) -> list[str]: + """Сбросить зависшие running после restart sac-api (daemon thread умер, запись в БД осталась).""" + from sqlalchemy import select + + stmt = select(Host).where(Host.agent_update_state == "running") + if host_ids is not None: + stmt = stmt.where(Host.id.in_(host_ids)) + rows = list(db.scalars(stmt).all()) + if not rows: + return [] + + finished = _utcnow().isoformat() + hostnames: list[str] = [] + for host in rows: + started = (host.remote_action or {}).get("started_at") + payload = dict(host.remote_action or {}) + payload.update( + { + "title": payload.get("title") or "Remote action", + "status": "failed", + "message": reason, + "ok": False, + "finished_at": finished, + "started_at": started, + "target": payload.get("target") or host.hostname, + } + ) + host.remote_action = payload + host.agent_update_state = "failed" + host.agent_update_last_error = reason[:2000] + hostnames.append(host.hostname or str(host.id)) + + db.commit() + with _lock: + for host in rows: + _running.discard(int(host.id)) + return hostnames + + +def cancel_host_remote_action(db: Session, host: Host, *, reason: str | None = None) -> bool: + """Отменить зависший running job вручную (admin).""" + if host.agent_update_state != "running": + return False + msg = reason or "Отменено администратором SAC" + cleared = clear_stale_running_remote_actions(db, reason=msg, host_ids=[int(host.id)]) + return bool(cleared) + + def get_remote_action_status(host: Host) -> dict[str, Any]: payload = dict(host.remote_action or {}) if not payload: diff --git a/backend/app/version.py b/backend/app/version.py index d0f23c0..e799045 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.4.2" +APP_VERSION = "0.4.3" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_agent_update.py b/backend/tests/test_agent_update.py index 4858a76..249ff68 100644 --- a/backend/tests/test_agent_update.py +++ b/backend/tests/test_agent_update.py @@ -224,3 +224,45 @@ def test_api_agent_update_fallback_ssh(jwt_headers, client, db_session, monkeypa job = wait_remote_job(client, host.id, jwt_headers) assert job["ok"] is True assert job["product_version"] == "2.1.0-SAC" + + +def test_clear_stale_running_remote_actions(db_session): + from app.services.host_remote_actions import clear_stale_running_remote_actions + + host = Host( + hostname="stale-win", + os_family="windows", + product="rdp-login-monitor", + agent_update_state="running", + remote_action={"status": "running", "message": "Подключение…"}, + ) + db_session.add(host) + db_session.commit() + + cleared = clear_stale_running_remote_actions(db_session, reason="test reset") + assert cleared == ["stale-win"] + db_session.refresh(host) + assert host.agent_update_state == "failed" + assert host.remote_action["status"] == "failed" + assert host.remote_action["ok"] is False + + +def test_cancel_host_remote_job_api(jwt_headers, client, db_session): + host = Host( + hostname="cancel-me", + os_family="windows", + product="rdp-login-monitor", + agent_update_state="running", + remote_action={"status": "running", "title": "Обновление через WinRM"}, + ) + db_session.add(host) + db_session.commit() + + response = client.post( + f"/api/v1/hosts/{host.id}/actions/remote-job/cancel", + headers=jwt_headers, + ) + assert response.status_code == 200 + body = response.json() + assert body["active"] is False + assert body["status"] == "failed" diff --git a/deploy/sac-deploy.sh b/deploy/sac-deploy.sh index 6f21296..908a9c5 100644 --- a/deploy/sac-deploy.sh +++ b/deploy/sac-deploy.sh @@ -105,6 +105,25 @@ print('db: OK') " || die "PostgreSQL: проверьте DATABASE_URL в ${CONFIG_FILE} и systemctl status postgresql" log "systemctl restart ${SERVICE_NAME} (краткий 502 в UI возможен ~10 с)" +log "Сброс зависших remote_action (running без worker после restart)" +sudo -u "${APP_USER}" bash -c " + set -a + # shellcheck source=/dev/null + source '${CONFIG_FILE}' + set +a + cd '${APP_ROOT}/backend' + '${VENV}/bin/python' -c \" +from app.database import SessionLocal +from app.services.host_remote_actions import clear_stale_running_remote_actions +session = SessionLocal() +try: + names = clear_stale_running_remote_actions(session) + for name in names: + print(f'stale remote_action reset: {name}') +finally: + session.close() +\" +" systemctl restart "${SERVICE_NAME}" systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active" diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 20f8518..ee8bbca 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.4.2"; +export const APP_VERSION = "0.4.3"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;