Files
Nexus/server/application/agent_diagnose_service.py
T
2026-07-08 22:31:31 +08:00

179 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Nexus Agent remote diagnosis — central Redis/MySQL snapshot + optional SSH probe."""
from __future__ import annotations
import json
import re
from typing import Any
REDIS_KEY_PREFIX = "heartbeat:"
REMOTE_AGENT_DIAG_SHELL = r"""
INSTALL_DIR=/opt/nexus-agent
echo "### systemctl ###"
(systemctl is-active nexus-agent 2>/dev/null || echo inactive)
systemctl show nexus-agent -p ActiveState,SubState,ActiveEnterTimestamp --value 2>/dev/null | tr '\n' ' '
echo
echo "### config_redacted ###"
if [ -f "$INSTALL_DIR/config.json" ]; then
python3 -c "import json; c=json.load(open('$INSTALL_DIR/config.json')); k=c.get('api_key',''); c['api_key']=((k[:8]+'...') if k else ''); print(json.dumps(c, ensure_ascii=False))"
else
echo missing_config
fi
echo "### log_errors ###"
for f in /var/log/nexus-agent.log "$INSTALL_DIR/agent.log"; do
if [ -f "$f" ]; then
echo "--- $f ---"
tail -40 "$f" | grep -E '401|Heartbeat rejected|Stopping heartbeat|Heartbeat error|discarded' || echo "(no matching lines)"
fi
done
if command -v journalctl >/dev/null 2>&1; then
echo "--- journalctl ---"
journalctl -u nexus-agent --no-pager -n 30 2>/dev/null | grep -E '401|Heartbeat rejected|Stopping heartbeat|Heartbeat error|discarded' || echo "(no matching lines)"
fi
echo "### central_health ###"
CU=$(python3 -c "import json; c=json.load(open('$INSTALL_DIR/config.json')); print((c.get('central') or {}).get('url','') or c.get('central_url',''))" 2>/dev/null || true)
if [ -n "$CU" ]; then
curl -fsS -o /dev/null -w "http_code=%{http_code}\n" "${CU%/}/health" --connect-timeout 8 2>&1 || echo curl_failed
else
echo no_central_url
fi
""".strip()
def has_agent_monitoring(server: Any) -> bool:
return bool((server.agent_api_key or "").strip() or (server.agent_version or "").strip())
def central_status(has_agent: bool, heartbeat: dict[str, str] | None) -> str:
if heartbeat:
return "online" if heartbeat.get("is_online") == "True" else "offline"
if has_agent:
return "offline"
return "unknown"
async def probe_remote_agent(server: Any, *, timeout: int = 45) -> dict[str, Any]:
from server.application.server_batch_common import sudo_wrap
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
ssh_user = (server.username or "root").strip()
cmd = sudo_wrap(REMOTE_AGENT_DIAG_SHELL, ssh_user)
try:
r = await exec_ssh_command(server, cmd, timeout=timeout)
stdout = (r.get("stdout") or "").strip()
parsed = parse_remote_probe(stdout)
return {
"ssh_ok": r.get("exit_code") == 0,
"exit_code": r.get("exit_code"),
"stderr": (r.get("stderr") or "").strip()[:500],
"error": "",
**parsed,
}
except Exception as exc:
return {
"ssh_ok": False,
"exit_code": -1,
"stderr": "",
"error": str(exc)[:500],
"systemctl_active": None,
"systemctl_detail": "",
"config": None,
"log_errors": "",
"central_health_http_code": None,
}
def parse_remote_probe(stdout: str) -> dict[str, Any]:
sections: dict[str, str] = {}
current = ""
buf: list[str] = []
for line in stdout.splitlines():
if line.startswith("### ") and line.endswith(" ###"):
if current:
sections[current] = "\n".join(buf).strip()
current = line[4:-4]
buf = []
else:
buf.append(line)
if current:
sections[current] = "\n".join(buf).strip()
systemctl_lines = [ln for ln in sections.get("systemctl", "").splitlines() if ln.strip()]
systemctl_active = systemctl_lines[0].strip() if systemctl_lines else ""
systemctl_detail = systemctl_lines[1].strip() if len(systemctl_lines) > 1 else ""
config_raw = sections.get("config_redacted", "")
config: dict[str, Any] | None = None
if config_raw and config_raw != "missing_config":
try:
config = json.loads(config_raw)
except json.JSONDecodeError:
config = {"raw": config_raw[:500]}
health_raw = sections.get("central_health", "")
http_code: int | None = None
m = re.search(r"http_code=(\d+)", health_raw)
if m:
http_code = int(m.group(1))
return {
"systemctl_active": systemctl_active or None,
"systemctl_detail": systemctl_detail,
"config": config,
"log_errors": sections.get("log_errors", ""),
"central_health_http_code": http_code,
}
def build_diagnose_errors(row: dict[str, Any]) -> list[str]:
errors: list[str] = []
if row.get("has_agent") and row.get("central_status") == "offline":
errors.append("中心侧显示 Agent 离线(Redis 无新鲜心跳)")
remote = row.get("remote") or {}
if remote.get("error"):
errors.append(f"SSH 连接失败: {remote['error']}")
elif remote and not remote.get("ssh_ok"):
stderr = (remote.get("stderr") or "").strip()
errors.append(f"SSH 命令异常(退出码 {remote.get('exit_code')}{': ' + stderr if stderr else ''}")
active = remote.get("systemctl_active")
if active and active != "active":
errors.append(f"子机 nexus-agent 服务状态: {active}")
log_errors = (remote.get("log_errors") or "").strip()
if log_errors and "(no matching lines)" not in log_errors:
snippet = log_errors.replace("\n", " | ")[:400]
errors.append(f"近期 Agent 错误日志: {snippet}")
code = remote.get("central_health_http_code")
if code is not None and code != 200:
errors.append(f"子机访问中心 /health 返回 HTTP {code}")
elif remote.get("config") and code is None and "no_central_url" not in (remote.get("log_errors") or ""):
if remote.get("ssh_ok") and not remote.get("config"):
pass
return errors
async def diagnose_server_record(
server: Any,
heartbeat: dict[str, str] | None,
*,
ssh: bool = True,
timeout: int = 45,
) -> dict[str, Any]:
has_agent = has_agent_monitoring(server)
status = central_status(has_agent, heartbeat)
row: dict[str, Any] = {
"server_id": server.id,
"server_name": server.name,
"server_domain": server.domain,
"central_status": status,
"has_agent": has_agent,
"agent_version_db": (server.agent_version or "").strip(),
"last_heartbeat_db": str(server.last_heartbeat or ""),
"last_heartbeat_redis": (heartbeat or {}).get("last_heartbeat", ""),
"redis_online": (heartbeat or {}).get("is_online") == "True",
}
if ssh:
row["remote"] = await probe_remote_agent(server, timeout=timeout)
row["errors"] = build_diagnose_errors(row)
return row