fix(watch): psutil SSH 安装 apt 走 sudo
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / e2e (push) Blocked by required conditions
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

非 root 用户 apt-get 未加 sudo 导致 Permission denied;补充 run_root 与中文错误提示。
This commit is contained in:
Nexus Agent
2026-06-12 04:09:10 +08:00
parent e57d689fb2
commit 8092000880
5 changed files with 107 additions and 15 deletions
@@ -0,0 +1,17 @@
# 审计 — psutil 安装 apt sudo 修复
**日期**2026-06-12
**Changelog**: `docs/changelog/2026-06-12-psutil-install-sudo-fix.md`
## 范围
| 文件 |
|------|
| `server/utils/psutil_install.py` |
| `server/application/server_batch_common.py` |
| `tests/test_psutil_install.py` |
## Step 3 / Closure / DoD
- H1 PASS — 行为修复,无新 API
- DoD: pytest test_psutil_install 5 passed
@@ -0,0 +1,19 @@
# Changelog — psutil SSH 安装 apt sudo 修复
**日期**2026-06-12
## 摘要
修复非 root SSH 用户点击「安装 psutilSSH)」时 `apt-get` 无 sudo 导致 Permission denied。
## 变更
- `psutil_install` 脚本增加 `run_root()``apt/dnf/yum` 与系统 pip 在非 root 时走 `sudo`
- `sudo_wrap` sudoers 补充 `python3` / `pip3` 路径
- 安装失败时返回中文提示(apt lock / 需免密 sudo
## 验证
```bash
.venv/bin/pytest tests/test_psutil_install.py -q
```
@@ -45,6 +45,8 @@ def sudo_wrap(cmd: str, ssh_user: str) -> str:
f"{ssh_user} ALL=(ALL) NOPASSWD: " f"{ssh_user} ALL=(ALL) NOPASSWD: "
"/usr/bin/systemctl, " "/usr/bin/systemctl, "
"/usr/bin/apt-get, /usr/bin/yum, /usr/bin/dnf, /usr/bin/apk, " "/usr/bin/apt-get, /usr/bin/yum, /usr/bin/dnf, /usr/bin/apk, "
"/usr/bin/python3, /usr/bin/python3.10, /usr/bin/python3.11, /usr/bin/python3.12, "
"/usr/bin/pip3, "
"/usr/bin/rm, /usr/bin/mkdir, /usr/bin/cp, /usr/bin/tee, " "/usr/bin/rm, /usr/bin/mkdir, /usr/bin/cp, /usr/bin/tee, "
"/usr/bin/curl, /usr/bin/fuser, /usr/bin/kill, /usr/bin/pkill, " "/usr/bin/curl, /usr/bin/fuser, /usr/bin/kill, /usr/bin/pkill, "
f"{shlex.quote('/opt/nexus-agent/.venv/bin/pip')}*" f"{shlex.quote('/opt/nexus-agent/.venv/bin/pip')}*"
+54 -12
View File
@@ -2,13 +2,22 @@
from __future__ import annotations from __future__ import annotations
import re
from server.application.server_batch_common import sudo_wrap from server.application.server_batch_common import sudo_wrap
from server.domain.models import Server from server.domain.models import Server
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
# Try --user pip, then distro packages, then system pip. Verify with same python3 as watch probe. # pip --user first (no root); package managers and system pip via run_root (sudo when non-root).
PSUTIL_INSTALL_SHELL = r""" PSUTIL_INSTALL_SHELL = r"""
set -e run_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
else
sudo "$@"
fi
}
verify_psutil() { verify_psutil() {
for py in python3 python3.12 python3.11 python3.10; do for py in python3 python3.12 python3.11 python3.10; do
if command -v "$py" >/dev/null 2>&1 && "$py" -c "import psutil" 2>/dev/null; then if command -v "$py" >/dev/null 2>&1 && "$py" -c "import psutil" 2>/dev/null; then
@@ -18,7 +27,8 @@ verify_psutil() {
done done
return 1 return 1
} }
verify_psutil || true
verify_psutil && exit 0
for py in python3 python3.12 python3.11 python3.10; do for py in python3 python3.12 python3.11 python3.10; do
command -v "$py" >/dev/null 2>&1 || continue command -v "$py" >/dev/null 2>&1 || continue
@@ -28,19 +38,25 @@ for py in python3 python3.12 python3.11 python3.10; do
done done
if command -v apt-get >/dev/null 2>&1; then if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-psutil \ if run_root apt-get update -qq \
&& verify_psutil && exit 0 && run_root env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-psutil; then
verify_psutil && exit 0
fi
fi fi
if command -v dnf >/dev/null 2>&1; then if command -v dnf >/dev/null 2>&1; then
dnf install -y -q python3-psutil && verify_psutil && exit 0 if run_root dnf install -y -q python3-psutil; then
verify_psutil && exit 0
fi
fi fi
if command -v yum >/dev/null 2>&1; then if command -v yum >/dev/null 2>&1; then
yum install -y -q python3-psutil && verify_psutil && exit 0 if run_root yum install -y -q python3-psutil; then
verify_psutil && exit 0
fi
fi fi
for py in python3 python3.12 python3.11 python3.10; do for py in python3 python3.12 python3.11 python3.10; do
command -v "$py" >/dev/null 2>&1 || continue command -v "$py" >/dev/null 2>&1 || continue
if "$py" -m pip install -q psutil 2>/dev/null; then if run_root "$py" -m pip install -q psutil 2>/dev/null; then
verify_psutil && exit 0 verify_psutil && exit 0
fi fi
done done
@@ -49,13 +65,40 @@ echo "ERROR: psutil install failed — pip/apt unavailable or permission denied"
exit 1 exit 1
""" """
_APT_PERM_RE = re.compile(
r"lock file|permission denied|unable to lock|not allowed to execute",
re.I,
)
def _psutil_install_error_zh(stderr: str, stdout: str) -> str:
text = f"{stderr}\n{stdout}"
if _APT_PERM_RE.search(text):
return (
"安装 psutil 需要 root 或免密 sudoapt/pip)。"
"请改用 root SSH,或在子机配置免密 sudo 后重试;也可手动执行:"
"sudo apt-get install -y python3-psutil"
)
if "sudo requires a password" in text.lower():
return "sudo 需要密码,请配置免密 sudo 或使用 root SSH 登录"
line = ""
for chunk in (stderr, stdout):
for raw in reversed(chunk.splitlines()):
raw = raw.strip()
if raw and not raw.startswith("W:"):
line = raw[:300]
break
if line:
break
return line or "psutil 安装失败"
def build_psutil_install_cmd(*, ssh_user: str) -> str: def build_psutil_install_cmd(*, ssh_user: str) -> str:
return sudo_wrap(PSUTIL_INSTALL_SHELL.strip(), ssh_user) return sudo_wrap(PSUTIL_INSTALL_SHELL.strip(), ssh_user)
async def install_psutil_via_ssh(server: Server, *, timeout: int = 90) -> dict[str, str | int]: async def install_psutil_via_ssh(server: Server, *, timeout: int = 120) -> dict[str, str | int | bool]:
"""Run remote install; returns {exit_code, stdout, stderr, already_installed?}.""" """Run remote install; returns success, message, stdout/stderr."""
ssh_user = (server.username or "root").strip() ssh_user = (server.username or "root").strip()
cmd = build_psutil_install_cmd(ssh_user=ssh_user) cmd = build_psutil_install_cmd(ssh_user=ssh_user)
result = await exec_ssh_command(server, cmd, timeout=timeout) result = await exec_ssh_command(server, cmd, timeout=timeout)
@@ -71,11 +114,10 @@ async def install_psutil_via_ssh(server: Server, *, timeout: int = 90) -> dict[s
"stderr": stderr[:500], "stderr": stderr[:500],
"message": "psutil 已就绪", "message": "psutil 已就绪",
} }
detail = stderr or stdout or f"exit {exit_code}"
return { return {
"success": False, "success": False,
"exit_code": exit_code, "exit_code": exit_code,
"stdout": stdout[:2000], "stdout": stdout[:2000],
"stderr": stderr[:500], "stderr": stderr[:500],
"message": detail[:300], "message": _psutil_install_error_zh(stderr, stdout),
} }
+15 -3
View File
@@ -9,15 +9,17 @@ from server.utils.psutil_install import build_psutil_install_cmd, install_psutil
def test_build_psutil_install_cmd_root(): def test_build_psutil_install_cmd_root():
cmd = build_psutil_install_cmd(ssh_user="root") cmd = build_psutil_install_cmd(ssh_user="root")
assert "run_root" in cmd
assert "pip install --user -q psutil" in cmd assert "pip install --user -q psutil" in cmd
assert "python3-psutil" in cmd assert "python3-psutil" in cmd
assert "verify_psutil" in cmd assert "run_root apt-get" in cmd
def test_build_psutil_install_cmd_non_root_wraps_sudo(): def test_build_psutil_install_cmd_non_root_wraps_sudo():
cmd = build_psutil_install_cmd(ssh_user="deploy") cmd = build_psutil_install_cmd(ssh_user="deploy")
assert "sudoers.d/nexus-agent" in cmd assert "sudoers.d/nexus-agent" in cmd
assert "pip install --user -q psutil" in cmd assert "run_root apt-get" in cmd
assert 'sudo "$@"' in cmd
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -43,4 +45,14 @@ async def test_install_psutil_via_ssh_failure():
): ):
result = await install_psutil_via_ssh(server) result = await install_psutil_via_ssh(server)
assert result["success"] is False assert result["success"] is False
assert "permission denied" in result["message"] assert result["message"]
def test_psutil_install_error_zh_apt_lock():
from server.utils.psutil_install import _psutil_install_error_zh
msg = _psutil_install_error_zh(
"E: Could not open lock file /var/lib/apt/lists/lock - open (13: Permission denied)",
"",
)
assert "免密 sudo" in msg or "root" in msg