docker
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
package stress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -18,8 +17,10 @@ type Metric struct {
|
||||
DisableReason string // 禁用时展示的原因
|
||||
}
|
||||
|
||||
// BuildMetrics 根据配置和可用工具,生成要执行的指标列表
|
||||
func BuildMetrics(types []TestType, tools ToolSet) []Metric {
|
||||
// BuildMetrics 根据测试类型生成要执行的指标列表。
|
||||
// 压测工具全部预装在 Docker 镜像中(stress-ng/fio/stressapptest/lm-sensors),
|
||||
// 无需在宿主机检测工具可用性。
|
||||
func BuildMetrics(types []TestType) []Metric {
|
||||
var metrics []Metric
|
||||
|
||||
for _, t := range types {
|
||||
@@ -30,15 +31,7 @@ func BuildMetrics(types []TestType, tools ToolSet) []Metric {
|
||||
case TestMemory:
|
||||
m.Tool = "stress-ng"
|
||||
case TestDiskIO:
|
||||
// disk 优先 fio;无 fio 可降级 stress-ng
|
||||
switch {
|
||||
case tools.Has("fio"):
|
||||
m.Tool = "fio"
|
||||
case tools.Has("stress-ng"):
|
||||
m.Tool = "stress-ng"
|
||||
default:
|
||||
m.Tool = "fio"
|
||||
}
|
||||
m.Tool = "fio"
|
||||
case TestMemNative:
|
||||
m.Tool = "stressapptest"
|
||||
case TestFull:
|
||||
@@ -47,25 +40,11 @@ func BuildMetrics(types []TestType, tools ToolSet) []Metric {
|
||||
m.Enabled = false
|
||||
m.DisableReason = "未知测试类型"
|
||||
}
|
||||
|
||||
// 根据工具可用性决定是否禁用,并记录原因
|
||||
switch t {
|
||||
case TestDiskIO:
|
||||
if !tools.Has("fio") && !tools.Has("stress-ng") {
|
||||
m.Enabled = false
|
||||
m.DisableReason = "缺少 fio 或 stress-ng"
|
||||
}
|
||||
default:
|
||||
if m.Tool != "" && !tools.Has(m.Tool) {
|
||||
m.Enabled = false
|
||||
m.DisableReason = fmt.Sprintf("缺少 %s", m.Tool)
|
||||
}
|
||||
}
|
||||
metrics = append(metrics, m)
|
||||
}
|
||||
|
||||
// 自动附加监控指标
|
||||
metrics = append(metrics, Metric{Name: "temp", Tool: "lm-sensors", Enabled: tools.Has("lm-sensors"), IsMonitor: true})
|
||||
// 自动附加监控指标(容器内执行)
|
||||
metrics = append(metrics, Metric{Name: "temp", Tool: "lm-sensors", Enabled: true, IsMonitor: true})
|
||||
metrics = append(metrics, Metric{Name: "dmesg", Tool: "", Enabled: true, IsMonitor: true})
|
||||
|
||||
return metrics
|
||||
@@ -77,6 +56,7 @@ func BuildMetrics(types []TestType, tools ToolSet) []Metric {
|
||||
|
||||
// Config 压力测试配置
|
||||
type Config struct {
|
||||
Types string // 测试类型(逗号分隔),如 "cpu,memory,disk"
|
||||
Duration time.Duration
|
||||
Threads int
|
||||
MemSizeMB int // 0=自动(可用60%)
|
||||
@@ -84,7 +64,10 @@ type Config struct {
|
||||
DiskDir string // 空=自动临时目录
|
||||
TempLogInt time.Duration
|
||||
TempLimit float64 // 温度上限(°C),0=不判定
|
||||
UseMirror bool // 在线安装时切换国内镜像源(清华TUNA)
|
||||
// 压测统一在 Docker 容器中执行(工具预装在镜像里)
|
||||
DockerImage string // 压测镜像名称,如 stress-test:latest
|
||||
DockerTar string // 本地导出的镜像文件(docker save),目标机无镜像时上传并 load
|
||||
DockerArgs string // Docker 运行参数,如 "--network=host --privileged"
|
||||
}
|
||||
|
||||
// DefaultConfig 默认配置
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -23,40 +22,48 @@ var Results = make(map[string]*Report)
|
||||
// 执行器
|
||||
// ============================
|
||||
|
||||
// Runner 压力测试执行器
|
||||
// Runner 压力测试执行器(统一在 Docker 容器中执行,工具预装在镜像里)
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
types []TestType
|
||||
client *ssh.Client
|
||||
tools ToolSet
|
||||
metrics []Metric
|
||||
installFailed []string // 自动安装仍失败的工具
|
||||
cfg Config
|
||||
client *ssh.Client
|
||||
metrics []Metric
|
||||
sudoPwd string // 非 root 用户(如 fnOS admin)执行 docker 命令时的 sudo 密码
|
||||
dockerOK bool // 目标机 Docker 是否可用
|
||||
}
|
||||
|
||||
// NewRunner 创建执行器(检查/在线安装 → 构建指标 → 生成脚本 → 执行)
|
||||
func NewRunner(cfg Config, types []TestType, client *ssh.Client, autoInstall bool, sudoPwd string) *Runner {
|
||||
// 1. 检测工具,缺失时按发行版在线安装(可配置国内镜像源加速)
|
||||
tools, failed := EnsureTools(client, requiredTools, autoInstall, sudoPwd, cfg.UseMirror)
|
||||
metrics := BuildMetrics(types, tools)
|
||||
return &Runner{cfg: cfg, types: types, client: client, tools: tools, metrics: metrics, installFailed: failed}
|
||||
// NewRunner 创建执行器(检测 Docker → 确保镜像 → 启动容器执行测试)
|
||||
func NewRunner(cfg Config, types []TestType, client *ssh.Client, sudoPwd string) *Runner {
|
||||
dockerInfo := IsDockerAvailable(client, sudoPwd)
|
||||
if dockerInfo.Available {
|
||||
fmt.Printf(" [Docker] 检测到 Docker 版本 %s,压测将在容器中执行\n", dockerInfo.Version)
|
||||
} else {
|
||||
fmt.Printf(" [Docker] 目标机未检测到 Docker,无法执行压测(飞牛默认已安装,请检查 Docker 服务状态)\n")
|
||||
}
|
||||
return &Runner{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
metrics: BuildMetrics(types),
|
||||
sudoPwd: sudoPwd,
|
||||
dockerOK: dockerInfo.Available,
|
||||
}
|
||||
}
|
||||
|
||||
// Run 执行压力测试(生成脚本 → 远程执行 → 解析结果)
|
||||
// Run 执行压力测试(启动容器 → 解析结果 → 生成报告)
|
||||
func (r *Runner) Run(ip string) *Report {
|
||||
report := &Report{IP: ip, StartTime: time.Now()}
|
||||
fmt.Printf("\n════════ [%s] 压力测试开始 ════════\n", ip)
|
||||
|
||||
// 显示可用工具
|
||||
fmt.Printf(" 工具: ")
|
||||
for name, info := range r.tools.Tools {
|
||||
fmt.Printf("%s(%s) ", name, info.Version)
|
||||
}
|
||||
fmt.Println()
|
||||
if len(r.installFailed) > 0 {
|
||||
fmt.Printf(" [安装] 以下工具自动安装失败,相关测试将跳过: %v\n", r.installFailed)
|
||||
// Docker 是唯一执行模式,不可用时直接判失败
|
||||
if !r.dockerOK {
|
||||
report.AddResult(Result{Type: "docker", Status: StatusFail, Error: "目标机 Docker 不可用"})
|
||||
report.EndTime = time.Now()
|
||||
report.Duration = report.EndTime.Sub(report.StartTime)
|
||||
return report
|
||||
}
|
||||
|
||||
// 显示将执行的指标及被跳过的原因
|
||||
fmt.Printf(" [模式] Docker 容器 (%s)\n", r.cfg.DockerImage)
|
||||
|
||||
// 显示将执行的指标及被跳过的原因(未知类型)
|
||||
fmt.Printf(" 指标: ")
|
||||
for _, m := range r.metrics {
|
||||
if m.Enabled {
|
||||
@@ -70,19 +77,13 @@ func (r *Runner) Run(ip string) *Report {
|
||||
}
|
||||
}
|
||||
|
||||
// 按需读取各测试脚本,拼装成完整脚本
|
||||
script := r.assembleScript()
|
||||
fmt.Printf(" [脚本] 拼装完成,%d 字节\n", len(script))
|
||||
|
||||
// 通过 heredoc 传到远程执行
|
||||
scriptCmd := fmt.Sprintf("cat <<'AUTOCHECKSCRIPT' > /tmp/auto-check.sh\n%s\nAUTOCHECKSCRIPT\nchmod +x /tmp/auto-check.sh && bash /tmp/auto-check.sh 2>&1", script)
|
||||
output, err := sshclient.RunCommand(r.client, scriptCmd)
|
||||
|
||||
// 启动容器执行测试(脚本已打包在镜像中,环境变量传参)
|
||||
output, err := r.runInDocker()
|
||||
if err != nil {
|
||||
fmt.Printf(" [执行] 脚本执行出错: %v\n", err)
|
||||
fmt.Printf(" [执行] 容器执行出错: %v\n", err)
|
||||
}
|
||||
|
||||
// 解析结果
|
||||
// 解析容器输出(与宿主机模式输出格式相同)
|
||||
if output != "" {
|
||||
r.parseResults(report, output)
|
||||
}
|
||||
@@ -276,115 +277,82 @@ func (r *Runner) parseResults(report *Report, output string) {
|
||||
flush()
|
||||
}
|
||||
|
||||
// assembleScript 根据 metrics 读取对应的独立脚本文件,拼装成完整脚本
|
||||
func (r *Runner) assembleScript() string {
|
||||
var sb strings.Builder
|
||||
// ============================
|
||||
// Docker 容器执行(工具与脚本均预装在镜像中)
|
||||
// ============================
|
||||
|
||||
// shebang + 环境变量导出
|
||||
sb.WriteString("#!/bin/bash\n")
|
||||
sb.WriteString("set -e\n\n")
|
||||
|
||||
// 导出参数变量
|
||||
fmt.Fprintf(&sb, "export AUTOCHECK_DURATION=%d\n", int(r.cfg.Duration.Seconds()))
|
||||
fmt.Fprintf(&sb, "export AUTOCHECK_THREADS=%d\n", r.cfg.Threads)
|
||||
fmt.Fprintf(&sb, "export AUTOCHECK_DISK_SIZE_MB=%d\n", r.cfg.DiskSizeMB)
|
||||
fmt.Fprintf(&sb, "export AUTOCHECK_MEM_SIZE_MB=%d\n", r.cfg.MemSizeMB)
|
||||
fmt.Fprintf(&sb, "export AUTOCHECK_TEMP_INTERVAL=%d\n", int(r.cfg.TempLogInt.Seconds()))
|
||||
sb.WriteString("export AUTOCHECK_SAMPLE_INTERVAL=2\n")
|
||||
if r.cfg.DiskDir != "" {
|
||||
fmt.Fprintf(&sb, "export AUTOCHECK_DISK_DIR=%s\n", r.cfg.DiskDir)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// 系统信息
|
||||
sb.Write(r.readScript("sysinfo"))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// 温度监控启动
|
||||
hasTemp := false
|
||||
for _, m := range r.metrics {
|
||||
if m.Name == "temp" && m.Enabled {
|
||||
hasTemp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasTemp {
|
||||
sb.Write(r.readScript("temp_start"))
|
||||
sb.WriteString("\n")
|
||||
// runInDocker 在 Docker 容器中执行压测(工具与脚本预装在镜像中)
|
||||
// 固定分发模式:确保目标机有镜像(无则上传本地 tar + docker load)→ 启动容器 → 解析结果
|
||||
func (r *Runner) runInDocker() (string, error) {
|
||||
// 1. 确保目标机存在压测镜像(不存在则上传本地导出的 tar 并 load)
|
||||
if err := r.ensureImage(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 按"先单项、后综合"排序测试脚本
|
||||
ordered := make([]Metric, len(r.metrics))
|
||||
copy(ordered, r.metrics)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return testPriority(ordered[i]) < testPriority(ordered[j])
|
||||
})
|
||||
// 2. 构建 Docker 运行命令(通过环境变量传入参数)
|
||||
containerName := fmt.Sprintf("auto-check-%d", time.Now().UnixNano())
|
||||
dockerRunCmd := fmt.Sprintf("docker run --rm --name %s "+
|
||||
"-e AUTOCHECK_TYPES=%s "+
|
||||
"-e AUTOCHECK_DURATION=%d "+
|
||||
"-e AUTOCHECK_THREADS=%d "+
|
||||
"-e AUTOCHECK_MEM_SIZE_MB=%d "+
|
||||
"-e AUTOCHECK_DISK_SIZE_MB=%d "+
|
||||
"-e AUTOCHECK_DISK_DIR=%s "+
|
||||
"-e AUTOCHECK_TEMP_INTERVAL=%d "+
|
||||
"-e AUTOCHECK_SAMPLE_INTERVAL=2 "+
|
||||
"%s %s",
|
||||
containerName,
|
||||
r.cfg.Types,
|
||||
int(r.cfg.Duration.Seconds()),
|
||||
r.cfg.Threads,
|
||||
r.cfg.MemSizeMB,
|
||||
r.cfg.DiskSizeMB,
|
||||
r.cfg.DiskDir,
|
||||
int(r.cfg.TempLogInt.Seconds()),
|
||||
r.cfg.DockerArgs,
|
||||
r.cfg.DockerImage)
|
||||
|
||||
// 逐个测试脚本
|
||||
for _, m := range ordered {
|
||||
if m.IsMonitor {
|
||||
continue
|
||||
}
|
||||
if !m.Enabled {
|
||||
// 缺少依赖时仍输出一个 skip 结果,让报告完整展示原因
|
||||
fmt.Fprintf(&sb, "echo '===TEST:%s==='\n", m.Name)
|
||||
sb.WriteString("echo 'status:skip'\n")
|
||||
fmt.Fprintf(&sb, "echo 'output:%s'\n", m.DisableReason)
|
||||
sb.WriteString("echo ''\n")
|
||||
continue
|
||||
}
|
||||
data := r.readScript(m.Name)
|
||||
if data == nil {
|
||||
fmt.Fprintf(&sb, "echo '===TEST:%s==='\n", m.Name)
|
||||
sb.WriteString("echo 'status:skip'\n")
|
||||
fmt.Fprintf(&sb, "echo 'output:脚本文件 scripts/%s.sh 不存在'\n", m.Name)
|
||||
sb.WriteString("echo ''\n")
|
||||
continue
|
||||
}
|
||||
sb.Write(data)
|
||||
sb.WriteString("\n")
|
||||
fmt.Printf(" [Docker] 启动容器: %s\n", containerName)
|
||||
output, err := runPrivileged(r.client, dockerRunCmd, r.sudoPwd)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 温度监控收尾
|
||||
if hasTemp {
|
||||
sb.Write(r.readScript("temp_end"))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// dmesg + 结束标记
|
||||
sb.Write(r.readScript("dmesg"))
|
||||
|
||||
return sb.String()
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// readScript 读取 scripts/ 目录下的脚本文件
|
||||
func (r *Runner) readScript(name string) []byte {
|
||||
data, err := os.ReadFile(fmt.Sprintf("scripts/%s.sh", name))
|
||||
if err != nil {
|
||||
// ensureImage 确保目标机存在压测镜像(固定分发模式,不依赖 registry):
|
||||
// 镜像已存在则直接使用;否则把工程目录中导出的镜像 tar 通过 SFTP
|
||||
// 上传到目标机,再 docker load 载入。
|
||||
func (r *Runner) ensureImage() error {
|
||||
imageExists, _ := runPrivileged(r.client, fmt.Sprintf("docker images %s --format '{{.Repository}}:{{.Tag}}'", r.cfg.DockerImage), r.sudoPwd)
|
||||
if strings.TrimSpace(imageExists) != "" {
|
||||
fmt.Printf(" [Docker] 镜像已存在: %s\n", r.cfg.DockerImage)
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// testPriority 测试执行顺序:单项测试在前,综合测试(full)最后,监控项最后
|
||||
func testPriority(m Metric) int {
|
||||
if m.IsMonitor {
|
||||
return 1000
|
||||
// 本地导出的镜像文件(docker save -o xxx.tar <image>)
|
||||
if r.cfg.DockerTar == "" {
|
||||
return fmt.Errorf("目标机不存在镜像 %s 且未配置 docker_tar", r.cfg.DockerImage)
|
||||
}
|
||||
switch TestType(m.Name) {
|
||||
case TestCPU:
|
||||
return 0
|
||||
case TestMemory:
|
||||
return 1
|
||||
case TestDiskIO:
|
||||
return 2
|
||||
case TestMemNative:
|
||||
return 3
|
||||
case TestFull:
|
||||
return 100
|
||||
default:
|
||||
return 50
|
||||
if _, err := os.Stat(r.cfg.DockerTar); err != nil {
|
||||
return fmt.Errorf("本地镜像文件不存在: %s(请先执行 docker save -o %s %s)",
|
||||
r.cfg.DockerTar, r.cfg.DockerTar, r.cfg.DockerImage)
|
||||
}
|
||||
|
||||
fmt.Printf(" [Docker] 目标机镜像不存在,开始上传...\n")
|
||||
remoteTar := "/tmp/auto-check-image.tar"
|
||||
if err := sshclient.UploadFile(r.client, r.cfg.DockerTar, remoteTar); err != nil {
|
||||
return fmt.Errorf("镜像上传失败: %w", err)
|
||||
}
|
||||
|
||||
loadOut, err := runPrivileged(r.client, fmt.Sprintf("docker load -i %s 2>&1", remoteTar), r.sudoPwd)
|
||||
sshclient.RunCommand(r.client, "rm -f "+remoteTar) // 清理远端临时 tar(忽略失败)
|
||||
if err != nil || strings.Contains(loadOut, "Error") {
|
||||
return fmt.Errorf("docker load 失败: %s", truncate(loadOut, 300))
|
||||
}
|
||||
fmt.Printf(" [Docker] 镜像载入成功: %s\n", r.cfg.DockerImage)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================
|
||||
@@ -423,6 +391,7 @@ func TestDevice(ip string, cfg config.Config) *Report {
|
||||
|
||||
// 由默认配置 + yaml 映射构造(避免硬编码,便于后续扩展字段)
|
||||
stressCfg := DefaultConfig()
|
||||
stressCfg.Types = cfg.Stress.Types
|
||||
stressCfg.Duration = cfg.Stress.Duration
|
||||
stressCfg.Threads = cfg.Stress.Threads
|
||||
stressCfg.MemSizeMB = cfg.Stress.MemSizeMB
|
||||
@@ -430,9 +399,12 @@ func TestDevice(ip string, cfg config.Config) *Report {
|
||||
stressCfg.DiskDir = cfg.Stress.DiskDir
|
||||
stressCfg.TempLogInt = cfg.Stress.TempInterval
|
||||
stressCfg.TempLimit = cfg.Stress.TempLimit
|
||||
stressCfg.UseMirror = cfg.Stress.UseMirror
|
||||
// Docker 镜像配置(工具与脚本预装在镜像中,本地导出的 tar 上传到目标机后 load)
|
||||
stressCfg.DockerImage = cfg.Stress.DockerImage
|
||||
stressCfg.DockerTar = cfg.Stress.DockerTar
|
||||
stressCfg.DockerArgs = cfg.Stress.DockerArgs
|
||||
|
||||
return NewRunner(stressCfg, ParseTypes(cfg.Stress.Types), conn, cfg.Stress.AutoInstall, sudoPwd).Run(ip)
|
||||
return NewRunner(stressCfg, ParseTypes(cfg.Stress.Types), conn, sudoPwd).Run(ip)
|
||||
}
|
||||
|
||||
// NewSSHFailReport 构造 SSH 连接失败报告
|
||||
|
||||
@@ -10,162 +10,31 @@ import (
|
||||
)
|
||||
|
||||
// ============================
|
||||
// 国内镜像源
|
||||
// Docker 环境检测与特权执行(压测统一在容器中执行,工具预装在镜像里)
|
||||
// ============================
|
||||
|
||||
// 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)
|
||||
}
|
||||
// DockerInfo Docker 运行环境信息
|
||||
type DockerInfo struct {
|
||||
Version string // Docker 版本号(如 24.0.7)
|
||||
Available bool // 是否可用
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 远程工具检测
|
||||
// ============================
|
||||
|
||||
// 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
|
||||
}
|
||||
// IsDockerAvailable 检测远程 Docker 是否可用(飞牛 NAS 默认已安装)。
|
||||
// 非 root 用户(如 fnOS admin)无 docker.sock 权限,需通过 sudo 执行。
|
||||
func IsDockerAvailable(client *ssh.Client, sudoPwd string) DockerInfo {
|
||||
out, err := runPrivileged(client, "docker version --format '{{.Server.Version}}' 2>/dev/null", sudoPwd)
|
||||
if err != nil || strings.TrimSpace(out) == "" {
|
||||
return DockerInfo{Available: false}
|
||||
}
|
||||
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)
|
||||
}
|
||||
return DockerInfo{
|
||||
Version: strings.TrimSpace(out),
|
||||
Available: true,
|
||||
}
|
||||
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 配置)。
|
||||
// fnOS 的 admin 用户无 docker.sock 权限,docker 命令均需走此路径。
|
||||
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" {
|
||||
@@ -174,7 +43,7 @@ func runPrivileged(client *ssh.Client, cmd, sudoPwd string) (string, error) {
|
||||
// 非 root:尝试 sudo
|
||||
if sudoPwd != "" {
|
||||
// echo 密码 | sudo -S 执行;整条命令用双引号包裹,避免命令内部单引号嵌套冲突。
|
||||
// 仅对命令内的双引号转义(安装命令均不含双引号,安全)。
|
||||
// 仅对命令内的双引号转义(docker 命令均不含双引号,安全)。
|
||||
quoted := strings.ReplaceAll(singleLine(cmd), `"`, `\"`)
|
||||
wrapped := fmt.Sprintf("echo %s | sudo -S -- sh -c \"%s\"", shellQuote(sudoPwd), quoted)
|
||||
return sshclient.RunCommand(client, wrapped)
|
||||
@@ -206,77 +75,3 @@ func truncate(s string, n int) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user