Files
membank/auto-check/pkg/stress/tools.go
2026-08-20 08:09:01 +08:00

283 lines
9.3 KiB
Go
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.
package stress
import (
"fmt"
"strings"
"auto-check/pkg/sshclient"
"golang.org/x/crypto/ssh"
)
// ============================
// 国内镜像源
// ============================
// setupMirror 在安装前把对应包管理器的源切换为国内镜像(清华 TUNA
// useMirror=false 时直接跳过(先用系统自带源试装)。失败不致命,仅提示并继续用原源。
func setupMirror(client *ssh.Client, pm string, sudoPwd string, useMirror bool) {
if !useMirror {
return
}
const label = "清华 TUNA 镜像"
switch pm {
case "apt-get":
// 备份并替换为清华镜像Debian/Ubuntu 通用,按 lsb_release 选版本)
script := `set -e
OS_ID=$(. /etc/os-release 2>/dev/null; echo "$ID")
OS_VER=$(. /etc/os-release 2>/dev/null; echo "$VERSION_CODENAME")
[ -z "$OS_VER" ] && OS_VER=$(. /etc/os-release 2>/dev/null; echo "$VERSION_ID")
MIRROR="https://mirrors.tuna.tsinghua.edu.cn"
if [ "$OS_ID" = "ubuntu" ]; then
LIST="$MIRROR/ubuntu"
elif [ "$OS_ID" = "debian" ]; then
LIST="$MIRROR/debian"
elif [ "$OS_ID" = "raspbian" ]; then
LIST="$MIRROR/raspbian"
else
LIST="$MIRROR/debian"
fi
BAK=/etc/apt/sources.list.d/auto-check-mirror.bak
if [ ! -f "$BAK" ]; then
cp /etc/apt/sources.list "$BAK" 2>/dev/null || true
cp -r /etc/apt/sources.list.d "$BAK.d" 2>/dev/null || true
fi
: > /etc/apt/sources.list
echo "deb $LIST $OS_VER main contrib non-free" > /etc/apt/sources.list
echo "deb $LIST $OS_VER-updates main contrib non-free" >> /etc/apt/sources.list
echo "deb $LIST $OS_VER-backports main contrib non-free" >> /etc/apt/sources.list
echo "deb $LIST-security $OS_VER-security main contrib non-free" >> /etc/apt/sources.list
echo OK`
out, err := runPrivileged(client, "sh -c "+shellQuote(script), sudoPwd)
if err != nil || !strings.Contains(out, "OK") {
fmt.Printf(" [镜像] apt 切换国内源失败,将使用官方源: %v\n", err)
} else {
fmt.Printf(" [镜像] apt 已切换至%s\n", label)
}
case "apk":
runPrivileged(client, "sed -i 's#https\\?://[^/]*alpinelinux.org#https://mirrors.tuna.tsinghua.edu.cn/alpine#g' /etc/apk/repositories", sudoPwd)
fmt.Printf(" [镜像] apk 已切换至%s\n", label)
default:
// dnf/yum 等无通用一键切源命令,需手动配置 repo 文件,这里仅提示
fmt.Printf(" [镜像] %s 无内置切源逻辑,建议使用系统默认源或手动配置%s repo\n", pm, label)
}
}
// ============================
// 远程工具检测
// ============================
// requiredTools 压测核心依赖工具缺则按需在线安装。lm-sensors 依赖较多,
// 仅单文件二进制 stress-ng 与 fio 为必需sensors 缺失仅影响温度曲线,不阻断压测。
// 这是工具检测的唯一来源DetectTools/EnsureTools 都从这里取列表,避免两处不一致。
var requiredTools = []string{"stress-ng", "fio"}
// ToolInfo 远程工具信息
type ToolInfo struct {
Name string
Path string
Version string
}
// ToolSet 远程可用工具集合
type ToolSet struct {
Tools map[string]ToolInfo // name → info
}
// Has 工具是否可用
func (ts ToolSet) Has(name string) bool {
_, ok := ts.Tools[name]
return ok
}
// Get 获取工具信息
func (ts ToolSet) Get(name string) (ToolInfo, bool) {
t, ok := ts.Tools[name]
return t, ok
}
// DetectTools 检测远程所有相关工具是否可用
func DetectTools(client *ssh.Client) ToolSet {
ts := ToolSet{Tools: make(map[string]ToolInfo)}
for _, name := range requiredTools {
if info, ok := detectOne(client, name); ok {
ts.Tools[name] = info
}
}
return ts
}
// EnsureTools 检测工具;缺失且 autoInstall=true 时按发行版自动安装。
// sudoPwd 为非 root 用户执行安装时提供(为空则尝试免密 sudo。useMirror=true
// 时安装前把包管理器源切换为国内镜像(清华 TUNA。返回安装后的工具集合
// 以及最终仍缺失的工具名列表。
func EnsureTools(client *ssh.Client, names []string, autoInstall bool, sudoPwd string, useMirror bool) (ToolSet, []string) {
ts := DetectTools(client)
var missing []string
for _, n := range names {
if !ts.Has(n) {
missing = append(missing, n)
}
}
if len(missing) == 0 {
return ts, nil
}
var failed []string
if autoInstall {
pm := detectPkgManager(client)
if pm == "" {
return ts, missing // 无法识别包管理器,跳过安装
}
// 安装前先把源切到国内镜像(使用官方源在网络差的环境会超时)
setupMirror(client, pm, sudoPwd, useMirror)
// 仅 apt 系需要先刷新元数据
if pm == "apt-get" {
if out, err := runPrivileged(client, "timeout 180 apt-get update 2>&1", sudoPwd); err != nil || strings.Contains(out, "Err") || strings.Contains(out, "Failed") {
fmt.Printf(" [安装] apt-get update 异常(可能无可用源/无权限): %v\n%s\n", err, truncate(out, 400))
}
}
for _, n := range missing {
pkg := pkgName(pm, n)
if pkg == "" {
failed = append(failed, n)
continue
}
installCmd := pkgInstallCmd(pm, pkg)
// runPrivileged 统一处理 sudo 与引号,这里直接给裸安装命令(带 timeout 兜底)
out, err := runPrivileged(client, fmt.Sprintf("timeout 300 %s 2>&1", installCmd), sudoPwd)
if err != nil || !strings.Contains(out, "Setting up "+pkg) && !strings.Contains(out, "已安装") && !strings.Contains(out, "already newest") {
fmt.Printf(" [安装] %s 安装失败: %v\n%s\n", pkg, err, truncate(out, 500))
}
// 安装后重新检测该工具是否到位
if info, ok := detectOne(client, n); ok {
ts.Tools[n] = info
} else {
failed = append(failed, n)
}
}
if len(failed) == 0 {
return ts, nil
}
return ts, failed
}
return ts, missing
}
// runPrivileged 以特权执行命令root 直接执行;非 root 用 sudo
// 若提供了 sudo 密码则通过 stdin 喂入(无需 NOPASSWD 配置)。
func runPrivileged(client *ssh.Client, cmd, sudoPwd string) (string, error) {
// 已是 root 则直接执行
if out, err := sshclient.RunCommand(client, "id -u"); err == nil && strings.TrimSpace(out) == "0" {
return sshclient.RunCommand(client, cmd)
}
// 非 root尝试 sudo
if sudoPwd != "" {
// echo 密码 | sudo -S 执行;整条命令用双引号包裹,避免命令内部单引号嵌套冲突。
// 仅对命令内的双引号转义(安装命令均不含双引号,安全)。
quoted := strings.ReplaceAll(singleLine(cmd), `"`, `\"`)
wrapped := fmt.Sprintf("echo %s | sudo -S -- sh -c \"%s\"", shellQuote(sudoPwd), quoted)
return sshclient.RunCommand(client, wrapped)
}
// 无密码:先尝试免密 sudo (-n),失败则回退普通 sudo会提示需要密码
quoted := strings.ReplaceAll(singleLine(cmd), `"`, `\"`)
if out, err := sshclient.RunCommand(client, "sudo -n -- sh -c \""+quoted+"\""); err == nil {
return out, nil
}
return sshclient.RunCommand(client, "sudo -- sh -c \""+quoted+"\"")
}
// shellQuote 转义单引号,避免密码破坏命令结构
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// singleLine 去掉换行,保证能塞进 sh -c '...'
func singleLine(s string) string {
return strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\r", "")
}
// truncate 截断过长的输出,便于日志展示
func truncate(s string, n int) string {
// 去掉 ANSI/多余空行,只保留前 n 个字符
s = strings.TrimSpace(s)
if len([]rune(s)) > n {
return string([]rune(s)[:n]) + "...(truncated)"
}
return s
}
// detectPkgManager 识别远程发行版的包管理器
func detectPkgManager(client *ssh.Client) string {
for _, pm := range []string{"apt-get", "dnf", "yum", "apk", "zypper"} {
if _, err := sshclient.RunCommand(client, fmt.Sprintf("command -v %s", pm)); err == nil {
return pm
}
}
return ""
}
// pkgName 工具名 → 对应发行版的包名
func pkgName(pm, tool string) string {
m := map[string]string{
"stress-ng": "stress-ng",
"fio": "fio",
"lm-sensors": "lm-sensors",
"stressapptest": "stressapptest",
"iperf3": "iperf3",
}
if pm == "dnf" || pm == "yum" {
// RHEL 系 lm-sensors 包名为 lm_sensors
if tool == "lm-sensors" {
return "lm_sensors"
}
}
if pm == "zypper" {
if tool == "lm-sensors" {
return "sensors"
}
}
if v, ok := m[tool]; ok {
return v
}
return tool
}
// pkgInstallCmd 生成安装命令
func pkgInstallCmd(pm, pkg string) string {
switch pm {
case "apt-get":
return fmt.Sprintf("apt-get install -y %s", pkg)
case "dnf":
return fmt.Sprintf("dnf install -y %s", pkg)
case "yum":
return fmt.Sprintf("yum install -y %s", pkg)
case "apk":
return fmt.Sprintf("apk add %s", pkg)
case "zypper":
return fmt.Sprintf("zypper install -y %s", pkg)
}
return ""
}
// detectOne 检测单个工具(查系统 PATH缺失由 EnsureTools 在线安装补全)
func detectOne(client *ssh.Client, name string) (ToolInfo, bool) {
path := ""
if out, err := sshclient.RunCommand(client, "which "+name+" 2>/dev/null"); err == nil {
path = strings.TrimSpace(out)
}
if path == "" {
return ToolInfo{}, false
}
// 取版本lm-sensors 仅支持 -v其余工具均支持 --version
verFlag := "--version"
if name == "lm-sensors" {
verFlag = "-v"
}
v, _ := sshclient.RunCommand(client, path+" "+verFlag+" 2>&1 | head -1")
version := strings.TrimSpace(v)
return ToolInfo{Name: name, Path: path, Version: version}, true
}