feat(watch): 监测槽 SSH 一键安装 psutil
探针报 psutil missing 时显示安装按钮;POST install-psutil 经 SSH pip/apt 安装并审计。
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
# 审计 — 监测槽 SSH 安装 psutil
|
||||||
|
|
||||||
|
**日期**:2026-06-12
|
||||||
|
**Changelog**: `docs/changelog/2026-06-12-watch-install-psutil-ssh.md`
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
| 文件 |
|
||||||
|
|------|
|
||||||
|
| `server/utils/psutil_install.py` |
|
||||||
|
| `server/application/services/watch_service.py` |
|
||||||
|
| `server/api/watch.py` |
|
||||||
|
| `frontend/src/components/watch/WatchSlotCard.vue` |
|
||||||
|
| `frontend/src/components/watch/WatchSlotRow.vue` |
|
||||||
|
| `frontend/src/utils/watchFormat.ts` |
|
||||||
|
| `server/utils/watch_probe_errors.py` |
|
||||||
|
| `tests/test_psutil_install.py` |
|
||||||
|
| `tests/test_watch_pins.py` |
|
||||||
|
|
||||||
|
## Step 3
|
||||||
|
|
||||||
|
| H | 结论 |
|
||||||
|
|---|------|
|
||||||
|
| H1 鉴权 | PASS — 须 JWT;仅已 Pin 的服务器可安装 |
|
||||||
|
| H2 吞错 | PASS — SSH/安装失败 422 + snackbar |
|
||||||
|
| H4 审计 | PASS — `watch_install_psutil` |
|
||||||
|
|
||||||
|
## Closure
|
||||||
|
|
||||||
|
H1–H4 PASS
|
||||||
|
|
||||||
|
## DoD
|
||||||
|
|
||||||
|
- [x] pytest test_psutil_install + test_watch_pins
|
||||||
|
- [x] npm run type-check
|
||||||
|
- [ ] 生产浏览器:psutil 错误时出现按钮且安装后探针恢复
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Changelog — 监测槽一键 SSH 安装 psutil
|
||||||
|
|
||||||
|
**日期**:2026-06-12
|
||||||
|
|
||||||
|
## 摘要
|
||||||
|
|
||||||
|
监测槽探针报「子机未安装 psutil」时,卡片内显示 **「安装 psutil(SSH)」** 按钮,通过 SSH 在子机系统 Python 安装 psutil(pip --user → apt/yum → pip)。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
SSH 监测探针依赖系统 `python3` 的 psutil;Agent venv 内的 psutil 无法被探针脚本使用。手动 SSH 安装成本高。
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
- 后端:`POST /api/watch/servers/{server_id}/install-psutil`(须已 Pin 该服务器)
|
||||||
|
- `server/utils/psutil_install.py`:远程安装脚本 + 验证
|
||||||
|
- 前端:`WatchSlotCard` 在 psutil 错误时显示按钮;`WatchSlotRow` 调用 API 并 refresh
|
||||||
|
- 审计:`watch_install_psutil`
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `server/utils/psutil_install.py`(新)
|
||||||
|
- `server/application/services/watch_service.py`
|
||||||
|
- `server/api/watch.py`
|
||||||
|
- `frontend/src/components/watch/WatchSlotCard.vue`
|
||||||
|
- `frontend/src/components/watch/WatchSlotRow.vue`
|
||||||
|
- `frontend/src/utils/watchFormat.ts`
|
||||||
|
- `tests/test_psutil_install.py`(新)
|
||||||
|
- `tests/test_watch_pins.py`
|
||||||
|
|
||||||
|
## 迁移 / 重启
|
||||||
|
|
||||||
|
- 无需迁移;需重启 API + 前端构建
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pytest tests/test_psutil_install.py tests/test_watch_pins.py -q
|
||||||
|
cd frontend && npm run type-check
|
||||||
|
```
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { WatchSlot } from '@/composables/useWatchPins'
|
import type { WatchSlot } from '@/composables/useWatchPins'
|
||||||
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError } from '@/utils/watchFormat'
|
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError } from '@/utils/watchFormat'
|
||||||
import WatchSparkline from '@/components/watch/WatchSparkline.vue'
|
import WatchSparkline from '@/components/watch/WatchSparkline.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
slot: WatchSlot
|
slot: WatchSlot
|
||||||
|
psutilInstallLoading?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -15,10 +16,14 @@ const emit = defineEmits<{
|
|||||||
pick: [slotIndex: number]
|
pick: [slotIndex: number]
|
||||||
monitoring: [slotIndex: number, enabled: boolean]
|
monitoring: [slotIndex: number, enabled: boolean]
|
||||||
ttl: [slotIndex: number, hours: 8 | 24]
|
ttl: [slotIndex: number, hours: 8 | 24]
|
||||||
|
installPsutil: [slotIndex: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
|
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
|
||||||
const slotTtl = computed(() => (props.slot.ttl_hours === 24 ? 24 : 8) as 8 | 24)
|
const slotTtl = computed(() => (props.slot.ttl_hours === 24 ? 24 : 8) as 8 | 24)
|
||||||
|
const showPsutilInstall = computed(() =>
|
||||||
|
monitoringOn.value && isPsutilMissingError(props.slot.metrics?.error, props.slot.metrics?.probe_status),
|
||||||
|
)
|
||||||
|
|
||||||
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
|
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
|
||||||
|
|
||||||
@@ -119,10 +124,23 @@ function pct(v: number | null | undefined) {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="slot.metrics?.error"
|
v-if="slot.metrics?.error"
|
||||||
class="text-caption text-error mt-1 text-truncate"
|
class="text-caption text-error mt-1"
|
||||||
:title="formatProbeError(slot.metrics.error, slot.metrics.probe_status)"
|
:title="formatProbeError(slot.metrics.error, slot.metrics.probe_status)"
|
||||||
>
|
>
|
||||||
{{ formatProbeError(slot.metrics.error, slot.metrics.probe_status) }}
|
<div class="text-truncate">
|
||||||
|
{{ formatProbeError(slot.metrics.error, slot.metrics.probe_status) }}
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
v-if="showPsutilInstall"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
class="mt-1"
|
||||||
|
:loading="props.psutilInstallLoading"
|
||||||
|
@click.stop="emit('installPsutil', slot.slot_index)"
|
||||||
|
>
|
||||||
|
安装 psutil(SSH)
|
||||||
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex align-center justify-space-between mt-2">
|
<div class="d-flex align-center justify-space-between mt-2">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import WatchSlotCard from '@/components/watch/WatchSlotCard.vue'
|
import WatchSlotCard from '@/components/watch/WatchSlotCard.vue'
|
||||||
import WatchProcessDrawer from '@/components/watch/WatchProcessDrawer.vue'
|
import WatchProcessDrawer from '@/components/watch/WatchProcessDrawer.vue'
|
||||||
@@ -7,6 +7,7 @@ import WatchSlotPickDialog from '@/components/watch/WatchSlotPickDialog.vue'
|
|||||||
import { useWatchPins, type WatchSlot } from '@/composables/useWatchPins'
|
import { useWatchPins, type WatchSlot } from '@/composables/useWatchPins'
|
||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
import { formatApiError } from '@/utils/apiError'
|
import { formatApiError } from '@/utils/apiError'
|
||||||
|
import { http } from '@/api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const snackbar = useSnackbar()
|
const snackbar = useSnackbar()
|
||||||
@@ -17,6 +18,7 @@ const processSlot = ref<WatchSlot | null>(null)
|
|||||||
const showPickDialog = ref(false)
|
const showPickDialog = ref(false)
|
||||||
const pickSlotIndex = ref<number | null>(null)
|
const pickSlotIndex = ref<number | null>(null)
|
||||||
const pickLoading = ref(false)
|
const pickLoading = ref(false)
|
||||||
|
const psutilLoadingSlot = ref<number | null>(null)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshPins()
|
refreshPins()
|
||||||
@@ -71,6 +73,24 @@ function onPickSlot(slotIndex: number) {
|
|||||||
showPickDialog.value = true
|
showPickDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onInstallPsutil(slotIndex: number) {
|
||||||
|
const slot = slots.value.find((s) => s.slot_index === slotIndex)
|
||||||
|
if (!slot?.server_id) return
|
||||||
|
psutilLoadingSlot.value = slotIndex
|
||||||
|
try {
|
||||||
|
const data = await http.post<{ message?: string }>(
|
||||||
|
`/watch/servers/${slot.server_id}/install-psutil`,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
snackbar(data.message || 'psutil 安装成功', 'success')
|
||||||
|
await refreshPins()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
snackbar(formatApiError(e, '安装 psutil 失败'), 'error')
|
||||||
|
} finally {
|
||||||
|
psutilLoadingSlot.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onPickServer(serverId: number) {
|
async function onPickServer(serverId: number) {
|
||||||
if (pickSlotIndex.value == null) return
|
if (pickSlotIndex.value == null) return
|
||||||
pickLoading.value = true
|
pickLoading.value = true
|
||||||
@@ -105,12 +125,14 @@ async function onPickServer(serverId: number) {
|
|||||||
<v-col v-for="slot in slots" :key="slot.slot_index" cols="12" sm="6" lg="3">
|
<v-col v-for="slot in slots" :key="slot.slot_index" cols="12" sm="6" lg="3">
|
||||||
<WatchSlotCard
|
<WatchSlotCard
|
||||||
:slot="slot"
|
:slot="slot"
|
||||||
|
:psutil-install-loading="psutilLoadingSlot === slot.slot_index"
|
||||||
@remove="onRemove"
|
@remove="onRemove"
|
||||||
@processes="openProcesses"
|
@processes="openProcesses"
|
||||||
@history="openHistory"
|
@history="openHistory"
|
||||||
@pick="onPickSlot"
|
@pick="onPickSlot"
|
||||||
@monitoring="onMonitoring"
|
@monitoring="onMonitoring"
|
||||||
@ttl="onTtl"
|
@ttl="onTtl"
|
||||||
|
@install-psutil="onInstallPsutil"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const PROBE_STATUS_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
const PROBE_ERROR_EXACT: Record<string, string> = {
|
const PROBE_ERROR_EXACT: Record<string, string> = {
|
||||||
parse_error: '探针返回数据无法解析',
|
parse_error: '探针返回数据无法解析',
|
||||||
'psutil missing': '子机未安装 psutil(请执行 pip install psutil)',
|
'psutil missing': '子机未安装 psutil',
|
||||||
ssh_probe_failed: 'SSH 探针脚本执行失败',
|
ssh_probe_failed: 'SSH 探针脚本执行失败',
|
||||||
'SSH watch probe failed': 'SSH 监测探针失败',
|
'SSH watch probe failed': 'SSH 监测探针失败',
|
||||||
'SSH probe failed': 'SSH 探针失败',
|
'SSH probe failed': 'SSH 探针失败',
|
||||||
@@ -94,6 +94,17 @@ export function formatProbeError(
|
|||||||
return `探针异常:${text}`
|
return `探针异常:${text}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when probe error indicates missing psutil on the remote host. */
|
||||||
|
export function isPsutilMissingError(
|
||||||
|
error: string | null | undefined,
|
||||||
|
status?: string | null,
|
||||||
|
): boolean {
|
||||||
|
const text = (error || '').toLowerCase()
|
||||||
|
if (text.includes('psutil missing')) return true
|
||||||
|
if (status?.toLowerCase().includes('parse_error') && text.includes('psutil')) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export function probeStatusColor(status: string | null | undefined): string {
|
export function probeStatusColor(status: string | null | undefined): string {
|
||||||
if (!status || status === 'ok') return 'success'
|
if (!status || status === 'ok') return 'success'
|
||||||
if (status.includes('timeout') || status.includes('auth')) return 'warning'
|
if (status.includes('timeout') || status.includes('auth')) return 'warning'
|
||||||
|
|||||||
@@ -211,6 +211,25 @@ async def get_watch_processes(
|
|||||||
return await svc.get_processes(server_id)
|
return await svc.get_processes(server_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/servers/{server_id}/install-psutil")
|
||||||
|
async def install_watch_psutil(
|
||||||
|
server_id: int,
|
||||||
|
request: Request,
|
||||||
|
admin: Admin = Depends(get_current_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Install psutil on the pinned server via SSH (for watch probe metrics)."""
|
||||||
|
svc = WatchService(db)
|
||||||
|
try:
|
||||||
|
return await svc.install_psutil_ssh(
|
||||||
|
admin=admin,
|
||||||
|
server_id=server_id,
|
||||||
|
ip_address=_client_ip(request),
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
def _parse_server_ids(server_ids: str) -> list[int]:
|
def _parse_server_ids(server_ids: str) -> list[int]:
|
||||||
ids: list[int] = []
|
ids: list[int] = []
|
||||||
for part in server_ids.split(","):
|
for part in server_ids.split(","):
|
||||||
|
|||||||
@@ -420,6 +420,45 @@ class WatchService:
|
|||||||
processes = await _load_processes(server_id)
|
processes = await _load_processes(server_id)
|
||||||
return {"server_id": server_id, "processes": processes or []}
|
return {"server_id": server_id, "processes": processes or []}
|
||||||
|
|
||||||
|
async def install_psutil_ssh(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
admin: Admin,
|
||||||
|
server_id: int,
|
||||||
|
ip_address: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
pinned = await self.pinned_server_map(admin.id)
|
||||||
|
if server_id not in pinned:
|
||||||
|
raise ValueError("该服务器不在当前监测槽中")
|
||||||
|
server = await self.server_repo.get_by_id(server_id)
|
||||||
|
if not server:
|
||||||
|
raise ValueError("服务器不存在")
|
||||||
|
|
||||||
|
from server.utils.psutil_install import install_psutil_via_ssh
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await install_psutil_via_ssh(server)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"SSH 连接失败: {e}") from e
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
msg = str(result.get("message") or "psutil 安装失败")
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
await self._audit(
|
||||||
|
admin=admin,
|
||||||
|
action="watch_install_psutil",
|
||||||
|
server=server,
|
||||||
|
detail=f"SSH 安装 psutil · {server.name or server_id}",
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
await self.db.commit()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"server_id": server_id,
|
||||||
|
"message": result.get("message") or "psutil 已就绪",
|
||||||
|
}
|
||||||
|
|
||||||
async def _audit(
|
async def _audit(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Install psutil on managed servers via SSH (system Python for watch probe)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from server.application.server_batch_common import sudo_wrap
|
||||||
|
from server.domain.models import Server
|
||||||
|
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.
|
||||||
|
PSUTIL_INSTALL_SHELL = r"""
|
||||||
|
set -e
|
||||||
|
verify_psutil() {
|
||||||
|
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
|
||||||
|
echo psutil_ok
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
verify_psutil || true
|
||||||
|
|
||||||
|
for py in python3 python3.12 python3.11 python3.10; do
|
||||||
|
command -v "$py" >/dev/null 2>&1 || continue
|
||||||
|
if "$py" -m pip install --user -q psutil 2>/dev/null; then
|
||||||
|
verify_psutil && exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-psutil \
|
||||||
|
&& verify_psutil && exit 0
|
||||||
|
fi
|
||||||
|
if command -v dnf >/dev/null 2>&1; then
|
||||||
|
dnf install -y -q python3-psutil && verify_psutil && exit 0
|
||||||
|
fi
|
||||||
|
if command -v yum >/dev/null 2>&1; then
|
||||||
|
yum install -y -q python3-psutil && verify_psutil && exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for py in python3 python3.12 python3.11 python3.10; do
|
||||||
|
command -v "$py" >/dev/null 2>&1 || continue
|
||||||
|
if "$py" -m pip install -q psutil 2>/dev/null; then
|
||||||
|
verify_psutil && exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "ERROR: psutil install failed — pip/apt unavailable or permission denied" >&2
|
||||||
|
exit 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_psutil_install_cmd(*, ssh_user: str) -> str:
|
||||||
|
return sudo_wrap(PSUTIL_INSTALL_SHELL.strip(), ssh_user)
|
||||||
|
|
||||||
|
|
||||||
|
async def install_psutil_via_ssh(server: Server, *, timeout: int = 90) -> dict[str, str | int]:
|
||||||
|
"""Run remote install; returns {exit_code, stdout, stderr, already_installed?}."""
|
||||||
|
ssh_user = (server.username or "root").strip()
|
||||||
|
cmd = build_psutil_install_cmd(ssh_user=ssh_user)
|
||||||
|
result = await exec_ssh_command(server, cmd, timeout=timeout)
|
||||||
|
stdout = (result.get("stdout") or "").strip()
|
||||||
|
stderr = (result.get("stderr") or "").strip()
|
||||||
|
raw_exit = result.get("exit_code")
|
||||||
|
exit_code = int(raw_exit) if raw_exit is not None else 1
|
||||||
|
if exit_code == 0 and "psutil_ok" in stdout:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"exit_code": 0,
|
||||||
|
"stdout": stdout[:2000],
|
||||||
|
"stderr": stderr[:500],
|
||||||
|
"message": "psutil 已就绪",
|
||||||
|
}
|
||||||
|
detail = stderr or stdout or f"exit {exit_code}"
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"stdout": stdout[:2000],
|
||||||
|
"stderr": stderr[:500],
|
||||||
|
"message": detail[:300],
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
_EXACT: dict[str, str] = {
|
_EXACT: dict[str, str] = {
|
||||||
"parse_error": "探针返回数据无法解析",
|
"parse_error": "探针返回数据无法解析",
|
||||||
"psutil missing": "子机未安装 psutil(请执行 pip install psutil)",
|
"psutil missing": "子机未安装 psutil",
|
||||||
"ssh_probe_failed": "SSH 探针脚本执行失败",
|
"ssh_probe_failed": "SSH 探针脚本执行失败",
|
||||||
"SSH watch probe failed": "SSH 监测探针失败",
|
"SSH watch probe failed": "SSH 监测探针失败",
|
||||||
"SSH probe failed": "SSH 探针失败",
|
"SSH probe failed": "SSH 探针失败",
|
||||||
@@ -50,7 +50,7 @@ def watch_probe_error_zh(error: str | None, *, status: str | None = None) -> str
|
|||||||
if "permission denied" in lower or "authentication failed" in lower:
|
if "permission denied" in lower or "authentication failed" in lower:
|
||||||
return f"SSH 认证失败:{text}"
|
return f"SSH 认证失败:{text}"
|
||||||
if "psutil missing" in lower:
|
if "psutil missing" in lower:
|
||||||
return "子机未安装 psutil(请执行 pip install psutil)"
|
return "子机未安装 psutil"
|
||||||
if "ssh_probe_failed" in lower or "ssh watch probe failed" in lower:
|
if "ssh_probe_failed" in lower or "ssh watch probe failed" in lower:
|
||||||
return "SSH 探针脚本执行失败"
|
return "SSH 探针脚本执行失败"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Tests for SSH psutil install helper."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from server.utils.psutil_install import build_psutil_install_cmd, install_psutil_via_ssh
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_psutil_install_cmd_root():
|
||||||
|
cmd = build_psutil_install_cmd(ssh_user="root")
|
||||||
|
assert "pip install --user -q psutil" in cmd
|
||||||
|
assert "python3-psutil" in cmd
|
||||||
|
assert "verify_psutil" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_psutil_install_cmd_non_root_wraps_sudo():
|
||||||
|
cmd = build_psutil_install_cmd(ssh_user="deploy")
|
||||||
|
assert "sudoers.d/nexus-agent" in cmd
|
||||||
|
assert "pip install --user -q psutil" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_install_psutil_via_ssh_success():
|
||||||
|
server = type("S", (), {"username": "root", "domain": "h.example.com", "port": 22})()
|
||||||
|
with patch(
|
||||||
|
"server.utils.psutil_install.exec_ssh_command",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value={"exit_code": 0, "stdout": "psutil_ok\n", "stderr": ""},
|
||||||
|
):
|
||||||
|
result = await install_psutil_via_ssh(server)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["message"] == "psutil 已就绪"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_install_psutil_via_ssh_failure():
|
||||||
|
server = type("S", (), {"username": "root", "domain": "h.example.com", "port": 22})()
|
||||||
|
with patch(
|
||||||
|
"server.utils.psutil_install.exec_ssh_command",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value={"exit_code": 1, "stdout": "", "stderr": "permission denied"},
|
||||||
|
):
|
||||||
|
result = await install_psutil_via_ssh(server)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "permission denied" in result["message"]
|
||||||
@@ -206,6 +206,46 @@ async def test_watch_pin_ttl_expiry_pin_server_no_duplicate(db_session, test_adm
|
|||||||
assert result["slots"][0]["monitoring_enabled"] is True
|
assert result["slots"][0]["monitoring_enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_watch_install_psutil_requires_pin(db_session, test_admin, mock_watch_redis):
|
||||||
|
from server.application.services.watch_service import WatchService
|
||||||
|
|
||||||
|
server = Server(name="watch-psutil", domain="wp.example.com", port=22, agent_version="1.0")
|
||||||
|
db_session.add(server)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
svc = WatchService(db_session)
|
||||||
|
admin = test_admin["admin"]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="不在当前监测槽"):
|
||||||
|
await svc.install_psutil_ssh(admin=admin, server_id=server.id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_watch_install_psutil_success(db_session, test_admin, mock_watch_redis, monkeypatch):
|
||||||
|
from server.application.services.watch_service import WatchService
|
||||||
|
|
||||||
|
server = Server(name="watch-psutil2", domain="wp2.example.com", port=22, agent_version="1.0")
|
||||||
|
db_session.add(server)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
svc = WatchService(db_session)
|
||||||
|
admin = test_admin["admin"]
|
||||||
|
await svc.pin_server(admin=admin, server_id=server.id)
|
||||||
|
|
||||||
|
async def _fake_install(_server):
|
||||||
|
return {"success": True, "message": "psutil 已就绪"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"server.utils.psutil_install.install_psutil_via_ssh",
|
||||||
|
_fake_install,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await svc.install_psutil_ssh(admin=admin, server_id=server.id)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["message"] == "psutil 已就绪"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_watch_pin_ttl_expiry_pauses_not_deletes(db_session, test_admin, mock_watch_redis):
|
async def test_watch_pin_ttl_expiry_pauses_not_deletes(db_session, test_admin, mock_watch_redis):
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from server.utils.watch_probe_errors import watch_probe_error_zh
|
|||||||
|
|
||||||
def test_parse_error_zh():
|
def test_parse_error_zh():
|
||||||
assert watch_probe_error_zh("parse_error") == "探针返回数据无法解析"
|
assert watch_probe_error_zh("parse_error") == "探针返回数据无法解析"
|
||||||
assert watch_probe_error_zh("psutil missing") == "子机未安装 psutil(请执行 pip install psutil)"
|
assert watch_probe_error_zh("psutil missing") == "子机未安装 psutil"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_error_with_detail():
|
def test_parse_error_with_detail():
|
||||||
|
|||||||
Reference in New Issue
Block a user