fix: reset stale remote agent updates after sac-api restart (0.4.3)
Clear zombie running jobs on deploy and add admin API to cancel a stuck job. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user