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 (
|
from app.services.host_remote_actions import (
|
||||||
RemoteActionAlreadyRunningError,
|
RemoteActionAlreadyRunningError,
|
||||||
|
cancel_host_remote_action,
|
||||||
|
clear_stale_running_remote_actions,
|
||||||
get_remote_action_status,
|
get_remote_action_status,
|
||||||
run_agent_fallback_action,
|
run_agent_fallback_action,
|
||||||
run_ssh_monitor_update_action,
|
run_ssh_monitor_update_action,
|
||||||
@@ -605,6 +607,21 @@ def get_host_remote_job(
|
|||||||
return HostRemoteActionJobResponse(**get_remote_action_status(host))
|
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)
|
@router.post("/{host_id}/actions/sessions/list", response_model=HostSessionsResponse)
|
||||||
def list_host_sessions(
|
def list_host_sessions(
|
||||||
host_id: int,
|
host_id: int,
|
||||||
|
|||||||
@@ -219,6 +219,59 @@ def run_agent_fallback_action(db: Session, host: Host) -> AgentUpdateFallbackRes
|
|||||||
return execute_agent_update_fallback(db, host)
|
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]:
|
def get_remote_action_status(host: Host) -> dict[str, Any]:
|
||||||
payload = dict(host.remote_action or {})
|
payload = dict(host.remote_action or {})
|
||||||
if not payload:
|
if not payload:
|
||||||
|
|||||||
@@ -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.4.2"
|
APP_VERSION = "0.4.3"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
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)
|
job = wait_remote_job(client, host.id, jwt_headers)
|
||||||
assert job["ok"] is True
|
assert job["ok"] is True
|
||||||
assert job["product_version"] == "2.1.0-SAC"
|
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"
|
||||||
|
|||||||
@@ -105,6 +105,25 @@ print('db: OK')
|
|||||||
" || die "PostgreSQL: проверьте DATABASE_URL в ${CONFIG_FILE} и systemctl status postgresql"
|
" || die "PostgreSQL: проверьте DATABASE_URL в ${CONFIG_FILE} и systemctl status postgresql"
|
||||||
|
|
||||||
log "systemctl restart ${SERVICE_NAME} (краткий 502 в UI возможен ~10 с)"
|
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 restart "${SERVICE_NAME}"
|
||||||
systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active"
|
systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active"
|
||||||
|
|
||||||
|
|||||||
@@ -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.4.2";
|
export const APP_VERSION = "0.4.3";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user