Files
Nexus/server/application/services/server_batch_service.py
T

722 lines
27 KiB
Python
Raw Normal View History

"""Background server batch jobs (category, health check, Agent ops, path detect)."""
from __future__ import annotations
import asyncio
import logging
import shlex
from typing import Any, Optional
from server.application.server_batch_common import (
agent_install_skip_result,
batch_server_display_name,
detect_and_save_target_path,
ensure_agent_api_key,
install_error_msg,
mark_agent_uninstalled,
prefetch_batch_context,
result_item_dict,
sudo_wrap,
)
from server.utils.agent_version import get_central_agent_version
from server.config import settings
from server.domain.models import AuditLog, Server
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.redis import server_batch_store as sbs
from server.infrastructure.redis.client import get_redis
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
logger = logging.getLogger("nexus.server_batch_service")
_BG_TASKS: set[asyncio.Task] = set()
CONCURRENCY = 5
OP_LABELS = {
"category": "批量修改分类",
"health-check": "健康检查",
"detect-path": "自动检测目标路径",
"onboard": "新服务器初始化",
"install-agent": "批量安装 Agent",
"upgrade-agent": "批量升级 Agent",
"uninstall-agent": "批量卸载 Agent",
}
AUDIT_ACTIONS = {
"category": "batch_update_category",
"health-check": "batch_health_check",
"detect-path": "batch_detect_path",
"onboard": "batch_server_onboard",
"install-agent": "batch_install_agent",
"upgrade-agent": "batch_upgrade_agent",
"uninstall-agent": "batch_uninstall_agent",
}
HEARTBEAT_PREFIX = "heartbeat:"
async def _notify_progress(live: dict[str, Any]) -> None:
try:
from server.api.websocket import broadcast_script_progress
stats = sbs.progress_stats(live)
await broadcast_script_progress({
"execution_id": int(live["id"]),
"task_kind": "server_batch",
"op": live.get("op") or "",
"label": live.get("label") or "",
"status": live.get("status") or "running",
"long_task": False,
"operator": live.get("operator") or "",
**stats,
})
except Exception as e:
logger.warning("server batch progress notify failed: %s", e)
async def _notify_complete(live: dict[str, Any]) -> None:
try:
from server.api.websocket import broadcast_script_complete
stats = sbs.progress_stats(live)
await broadcast_script_complete({
"execution_id": int(live["id"]),
"task_kind": "server_batch",
"op": live.get("op") or "",
"label": live.get("label") or "",
"status": live.get("status") or "completed",
"long_task": False,
"operator": live.get("operator") or "",
**stats,
})
except Exception as e:
logger.warning("server batch complete notify failed: %s", e)
async def _reconcile_live_job(
live: dict[str, Any],
*,
error: str,
auto_stuck: bool,
) -> bool:
"""Fill pending servers as failed, finalize, persist, audit, notify. Returns True if reconciled."""
if live.get("status") != "running":
return False
pending = sbs.pending_server_ids(live)
if not pending and sbs.progress_stats(live)["remaining"] == 0:
sbs.finalize_status(live)
await sbs.save_live(live)
else:
added = sbs.apply_pending_failures(
live,
server_ids=pending,
error=error,
auto_stuck=auto_stuck,
)
if added == 0 and not pending:
return False
sbs.finalize_status(live)
await sbs.save_live(live)
await _persist_job_finalize(live)
await _write_audit(live)
await _notify_complete(live)
job_id = int(live["id"])
logger.info(
"Server batch job reconciled job_id=%s op=%s status=%s auto_stuck=%s",
job_id,
live.get("op"),
live.get("status"),
auto_stuck,
)
return True
async def recover_orphaned_batch_jobs() -> int:
"""On API startup: finalize batch jobs left running after process restart."""
from server.infrastructure.database.server_batch_job_repo import (
ServerBatchJobRepositoryImpl,
)
reconciled_ids: set[int] = set()
count = 0
for live in await sbs.list_running_live_jobs():
job_id = int(live["id"])
if job_id in reconciled_ids:
continue
if await _reconcile_live_job(
live,
error=sbs.INTERRUPTED_MESSAGE,
auto_stuck=False,
):
reconciled_ids.add(job_id)
count += 1
async with AsyncSessionLocal() as session:
repo = ServerBatchJobRepositoryImpl(session)
for row in await repo.list_running():
job_id = int(row.id)
if job_id in reconciled_ids:
continue
live = await sbs.get_live(job_id)
if live is None:
live = sbs.live_from_mysql_row(row)
elif live.get("status") != "running":
continue
if await _reconcile_live_job(
live,
error=sbs.INTERRUPTED_MESSAGE,
auto_stuck=False,
):
reconciled_ids.add(job_id)
count += 1
if count:
logger.info("Recovered %s orphaned server batch job(s) after startup", count)
return count
async def detect_stuck_batch_jobs() -> int:
"""Mark running batch jobs with no progress for STUCK_NO_PROGRESS_SECONDS."""
count = 0
for live in await sbs.list_running_live_jobs():
if not sbs.is_stale_running(live):
continue
if await _reconcile_live_job(
live,
error=sbs.STUCK_MESSAGE,
auto_stuck=True,
):
count += 1
if count:
logger.info("Auto-reconciled %s stuck server batch job(s)", count)
return count
async def start_batch_job(
*,
op: str,
server_ids: list[int],
operator: str,
params: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
if op not in OP_LABELS:
raise ValueError(f"未知的批量操作: {op}")
if not server_ids:
raise ValueError("server_ids 不能为空")
job_id = await sbs.next_job_id()
label = OP_LABELS[op]
if op == "category" and params and params.get("category"):
label = f"{label}{params['category']}"
live = sbs.init_live(
job_id=job_id,
op=op,
label=label,
server_ids=server_ids,
operator=operator,
params=params,
)
await sbs.save_live(live)
await _persist_job_start(live)
await _notify_progress(live)
task = asyncio.create_task(_run_background(job_id))
_BG_TASKS.add(task)
task.add_done_callback(_BG_TASKS.discard)
stats = sbs.progress_stats(live)
return {
"job_id": job_id,
"status": "running",
"label": label,
"op": op,
**stats,
}
async def get_batch_job(job_id: int) -> Optional[dict[str, Any]]:
live = await sbs.get_live(job_id)
if live:
return sbs.live_to_api(live)
return await _get_batch_job_from_mysql(job_id)
async def list_batch_jobs(
*,
limit: int = 50,
offset: int = 0,
op: Optional[str] = None,
) -> dict[str, Any]:
from server.infrastructure.database.server_batch_job_repo import (
ServerBatchJobRepositoryImpl,
job_row_to_api,
)
async with AsyncSessionLocal() as session:
repo = ServerBatchJobRepositoryImpl(session)
rows, total = await repo.list_recent(limit=limit, offset=offset, op=op)
items = [job_row_to_api(row) for row in rows]
return {"items": items, "total": total, "limit": limit, "offset": offset}
async def _persist_job_start(live: dict[str, Any]) -> None:
from server.infrastructure.database.server_batch_job_repo import ServerBatchJobRepositoryImpl
try:
async with AsyncSessionLocal() as session:
repo = ServerBatchJobRepositoryImpl(session)
await repo.upsert_running(
job_id=int(live["id"]),
op=str(live.get("op") or ""),
label=str(live.get("label") or ""),
server_ids=list(live.get("server_ids") or []),
operator=str(live.get("operator") or ""),
params=live.get("params") if isinstance(live.get("params"), dict) else {},
)
except Exception as e:
logger.warning("Server batch MySQL create failed job_id=%s: %s", live.get("id"), e)
async def _persist_job_finalize(live: dict[str, Any]) -> None:
from server.infrastructure.database.server_batch_job_repo import ServerBatchJobRepositoryImpl
try:
api = sbs.live_to_api(live)
async with AsyncSessionLocal() as session:
repo = ServerBatchJobRepositoryImpl(session)
await repo.finalize(
int(live["id"]),
status=str(live.get("status") or "completed"),
results=list(api.get("results") or []),
)
except Exception as e:
logger.warning("Server batch MySQL finalize failed job_id=%s: %s", live.get("id"), e)
async def _get_batch_job_from_mysql(job_id: int) -> Optional[dict[str, Any]]:
from server.infrastructure.database.server_batch_job_repo import (
ServerBatchJobRepositoryImpl,
job_row_to_api,
)
try:
async with AsyncSessionLocal() as session:
repo = ServerBatchJobRepositoryImpl(session)
row = await repo.get_by_id(job_id)
if not row:
return None
return job_row_to_api(row)
except Exception as e:
logger.warning("Server batch MySQL read failed job_id=%s: %s", job_id, e)
return None
async def _run_background(job_id: int) -> None:
live = await sbs.get_live(job_id)
if not live:
return
op = live.get("op") or ""
try:
if op == "category":
await _run_category(live)
elif op == "health-check":
await _run_health_check(live)
elif op == "detect-path":
await _run_detect_path(live)
elif op == "onboard":
await _run_onboard(live)
elif op == "install-agent":
await _run_install_agent(live)
elif op == "upgrade-agent":
await _run_upgrade_agent(live)
elif op == "uninstall-agent":
await _run_uninstall_agent(live)
else:
live["status"] = "failed"
await sbs.save_live(live)
return
live = await sbs.get_live(job_id)
if live:
stats = sbs.progress_stats(live)
if live.get("status") == "running" or stats["remaining"] == 0:
sbs.finalize_status(live)
await sbs.save_live(live)
except Exception:
logger.exception("Server batch job failed job_id=%s op=%s", job_id, op)
live = await sbs.get_live(job_id)
if live:
live["status"] = "failed"
await sbs.save_live(live)
live = await sbs.get_live(job_id)
if live:
if live.get("status") == "running":
sbs.finalize_status(live)
await sbs.save_live(live)
await _persist_job_finalize(live)
await _write_audit(live)
await _notify_complete(live)
async def _write_audit(live: dict[str, Any]) -> None:
import json
op = live.get("op") or ""
action = AUDIT_ACTIONS.get(op, "batch_server_op")
stats = sbs.progress_stats(live)
api = sbs.live_to_api(live)
failures = [
{"server_name": (e.get("server_name") or "").strip() or "未知服务器", "error": (e.get("error") or "")[:300]}
for e in (api.get("results") or [])
if not e.get("success")
]
summary_map = {
"category": f"批量修改分类 更新{stats['success']}台",
"health-check": f"批量健康检查: {stats['success']}/{stats['total']} 在线",
"detect-path": f"批量检测路径: {stats['success']}/{stats['total']} 成功",
"onboard": f"新服务器初始化: {stats['success']}/{stats['total']} 成功",
"install-agent": f"批量安装Agent: {stats['success']}/{stats['total']} 成功",
"upgrade-agent": f"批量升级Agent: {stats['success']}/{stats['total']} 成功",
"uninstall-agent": f"批量卸载Agent: {stats['success']}/{stats['total']} 成功",
}
summary = summary_map.get(op, f"{live.get('label')} {stats['success']}/{stats['total']}")
detail = json.dumps({
"job_id": int(live["id"]),
"summary": summary,
"success": stats["success"],
"failed": stats["failed"],
"total": stats["total"],
"failures": failures[:30],
}, ensure_ascii=False)
try:
async with AsyncSessionLocal() as session:
audit_repo = AuditLogRepositoryImpl(session)
await audit_repo.create(AuditLog(
admin_username=live.get("operator") or "admin",
action=action,
target_type="server_batch_job",
target_id=int(live["id"]),
detail=detail,
ip_address="",
))
await session.commit()
except Exception as e:
logger.warning("Server batch audit failed job_id=%s: %s", live.get("id"), e)
async def _finish_one(live: dict[str, Any], item: dict[str, Any]) -> None:
sbs.record_result(live, item)
await sbs.save_live(live)
await _notify_progress(live)
async def _run_with_semaphore(live: dict[str, Any], handler) -> None:
from server.application.services.server_service import ServerService
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
server_ids = live.get("server_ids") or []
sem = asyncio.Semaphore(CONCURRENCY)
async with AsyncSessionLocal() as session:
service = ServerService(
server_repo=ServerRepositoryImpl(session),
sync_log_repo=SyncLogRepositoryImpl(session),
)
server_map, labels = await prefetch_batch_context(service, server_ids)
async def _one(sid: int) -> None:
async with sem:
item = await handler(sid, server_map, labels, live)
await _finish_one(live, item)
await asyncio.gather(*[_one(sid) for sid in server_ids])
async def _run_category(live: dict[str, Any]) -> None:
params = live.get("params") or {}
category = (params.get("category") or "").strip() or None
server_ids = live.get("server_ids") or []
async with AsyncSessionLocal() as session:
repo = ServerRepositoryImpl(session)
servers = await repo.get_by_ids(server_ids)
found_ids = {s.id for s in servers}
labels = {s.id: batch_server_display_name(s) for s in servers}
for sid in server_ids:
labels.setdefault(sid, "未知服务器")
for sid in server_ids:
if sid not in found_ids:
await _finish_one(live, result_item_dict(
server_id=sid,
server_name=labels[sid],
success=False,
error="服务器不存在",
))
if found_ids:
await repo.update_category_batch(list(found_ids), category)
await session.commit()
cat_label = category or "(清除)"
for sid in found_ids:
await _finish_one(live, result_item_dict(
server_id=sid,
server_name=labels[sid],
success=True,
stdout=f"分类 → {cat_label}",
))
async def _run_health_check(live: dict[str, Any]) -> None:
from server.infrastructure.ssh.remote_probe import ssh_health_probe
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
probe = await ssh_health_probe(server, timeout=15)
online = probe.get("status") == "online"
detail = probe.get("error") or probe.get("channel") or probe.get("status") or ""
return result_item_dict(
server_id=sid,
server_name=label,
success=online,
stdout=str(detail)[:500] if online else "",
error="" if online else str(probe.get("error") or "离线")[:300],
)
except Exception as e:
return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])
await _run_with_semaphore(live, handler)
async def _run_detect_path(live: dict[str, Any]) -> None:
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
outcome = await detect_and_save_target_path(server)
if outcome.get("success"):
return result_item_dict(
server_id=sid,
server_name=label,
success=True,
stdout=str(outcome.get("stdout") or ""),
)
return result_item_dict(
server_id=sid,
server_name=label,
success=False,
error=str(outcome.get("error") or "探测失败"),
)
except Exception as e:
return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])
await _run_with_semaphore(live, handler)
async def _run_onboard(live: dict[str, Any]) -> None:
from server.application.services.files_sudoers_service import install_nexus_files_sudoers
from server.utils.posix_paths import is_unset_target_path
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
steps: list[str] = []
try:
ssh_user = (server.username or "root").strip() or "root"
if ssh_user == "root":
steps.append("sudo: skip(root)")
else:
sudo_outcome = await install_nexus_files_sudoers(server)
action = sudo_outcome.get("action") or "unknown"
if not sudo_outcome.get("ok"):
detail = sudo_outcome.get("detail") or action
return result_item_dict(
server_id=sid,
server_name=label,
success=False,
error=f"sudo 配置失败: {detail}",
stdout="; ".join(steps),
)
steps.append(f"sudo: {action}")
if is_unset_target_path(server.target_path):
detect_outcome = await detect_and_save_target_path(server)
if detect_outcome.get("success"):
steps.append(str(detect_outcome.get("stdout") or "path: ok"))
else:
steps.append(f"path: {detect_outcome.get('error') or 'fail'}(推送仍可用 /www/wwwroot")
else:
steps.append("path: already_set")
return result_item_dict(
server_id=sid,
server_name=label,
success=True,
stdout="; ".join(steps),
)
except Exception as e:
return result_item_dict(
server_id=sid,
server_name=label,
success=False,
error=str(e)[:300],
stdout="; ".join(steps),
)
await _run_with_semaphore(live, handler)
async def _run_install_agent(live: dict[str, Any]) -> None:
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
for sid in live.get("server_ids") or []:
await _finish_one(live, result_item_dict(
server_id=sid, server_name=str(sid), success=False,
error="NEXUS_API_BASE_URL 未配置",
))
return
central_version = get_central_agent_version()
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
skip = await agent_install_skip_result(
server=server,
server_id=sid,
server_name=label,
central_version=central_version,
)
if skip is not None:
return skip
api_key = await ensure_agent_api_key(server)
ssh_user = (server.username or "root").strip()
install_cmd = (
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/install.sh -o \"$TMP\" && bash \"$TMP\" -- "
f"--url {shlex.quote(base_url)} --key {shlex.quote(api_key)} "
f"--id {sid} --port {shlex.quote(str(server.agent_port or 8601))}"
f"; RC=$?; rm -f \"$TMP\"; exit $RC"
)
install_cmd = sudo_wrap(install_cmd, ssh_user)
r = await exec_ssh_command(server, install_cmd, timeout=180)
ok = r["exit_code"] == 0
if ok and "Status: FAILED" in r.get("stdout", ""):
ok = False
return result_item_dict(
server_id=sid, server_name=label, success=ok,
stdout=r["stdout"][:2000] if ok else r["stdout"][:1000],
error="" if ok else install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
)
except Exception as e:
return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])
await _run_with_semaphore(live, handler)
async def _run_upgrade_agent(live: dict[str, Any]) -> None:
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
for sid in live.get("server_ids") or []:
await _finish_one(live, result_item_dict(
server_id=sid, server_name=str(sid), success=False,
error="NEXUS_API_BASE_URL 未配置",
))
return
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
from server.utils.agent_deploy import (
AGENT_INSTALL_DIR,
build_agent_upgrade_cmd,
build_agent_upgrade_rollback_cmd,
)
install_dir = AGENT_INSTALL_DIR
ssh_user = (server.username or "root").strip()
agent_port = server.agent_port or 8601
upgrade_cmd = build_agent_upgrade_cmd(
base_url=base_url,
ssh_user=ssh_user,
install_dir=install_dir,
agent_port=agent_port,
)
r = await exec_ssh_command(server, upgrade_cmd, timeout=120)
ok = r["exit_code"] == 0 and "upgrade_ok" in r["stdout"]
if not ok:
try:
rollback = build_agent_upgrade_rollback_cmd(
ssh_user=ssh_user,
install_dir=install_dir,
)
await exec_ssh_command(server, rollback, timeout=30)
except Exception as rb_err:
logger.warning("Rollback failed for server %s: %s", sid, rb_err)
return result_item_dict(
server_id=sid, server_name=label, success=ok,
stdout=r["stdout"][:500] if ok else "",
error=install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]) if not ok else "",
)
except Exception as e:
return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:200])
await _run_with_semaphore(live, handler)
async def _run_uninstall_agent(live: dict[str, Any]) -> None:
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
for sid in live.get("server_ids") or []:
await _finish_one(live, result_item_dict(
server_id=sid, server_name=str(sid), success=False,
error="NEXUS_API_BASE_URL 未配置",
))
return
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
ssh_user = (server.username or "root").strip()
uninstall_cmd = (
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/uninstall.sh -o \"$TMP\" "
f"&& bash \"$TMP\" ; RC=$?; rm -f \"$TMP\"; exit $RC"
)
uninstall_cmd = sudo_wrap(uninstall_cmd, ssh_user)
r = await exec_ssh_command(server, uninstall_cmd, timeout=60)
ok = r["exit_code"] == 0
if ok:
try:
redis = get_redis()
await redis.delete(f"{HEARTBEAT_PREFIX}{sid}")
except Exception as redis_err:
logger.warning("Failed to clear Redis heartbeat for server %s: %s", sid, redis_err)
await mark_agent_uninstalled(sid)
return result_item_dict(
server_id=sid, server_name=label, success=ok,
stdout=r["stdout"][:500] if ok else "",
error="" if ok else install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
)
except Exception as e:
return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])
await _run_with_semaphore(live, handler)