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:
2026-06-25 10:43:31 +10:00
parent dd2ae51b43
commit 86fd400f0b
6 changed files with 133 additions and 2 deletions
+17
View File
@@ -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 -1
View File
@@ -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}"
+42
View File
@@ -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"
+19
View File
@@ -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"
+1 -1
View File
@@ -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}`;