diff --git a/auto-check/.gitignore b/auto-check/.gitignore index a5a40f0..60d857d 100644 --- a/auto-check/.gitignore +++ b/auto-check/.gitignore @@ -3,6 +3,9 @@ reports/ *.exe *.test +# 导出的镜像文件(体积大,不入库;维护时重新 docker save 生成) +docker/stress-test/*.tar + # IDE .idea/ .vscode/ diff --git a/auto-check/Dockerfile b/auto-check/Dockerfile deleted file mode 100644 index 5feef79..0000000 --- a/auto-check/Dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -# stress-tools Dockerfile -# 用于 auto-check 压力测试的容器镜像 -# 预装所有压测工具,避免目标机在线安装依赖 -# -# 构建: docker build -t stress-tools:latest . -# 推送: docker push registry.example.com/stress-tools:latest -# 离线导入: docker save stress-tools:latest | gzip > stress-tools.tar.gz -# docker load < stress-tools.tar.gz - -FROM ubuntu:22.04 - -LABEL maintainer="auto-check" -LABEL description="压力测试工具集(stress-ng, fio, stressapptest, lm-sensors)" -LABEL version="1.0.0" - -# 避免交互式提示 -ENV DEBIAN_FRONTEND=noninteractive - -# 安装压测核心工具 -RUN apt-get update && apt-get install -y --no-install-recommends \ - # 压力测试 - stress-ng \ - stressapptest \ - # 磁盘测试 - fio \ - # 温度监控 - lm-sensors \ - # 系统信息 - sysstat \ - procps \ - coreutils \ - util-linux \ - # 脚本工具 - bash \ - awk \ - sed \ - grep \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -# 初始化 sensors(加载内核模块) -RUN sensors --detect 2>/dev/null || true - -WORKDIR /workspace - -# 默认使用 bash(脚本通过 stdin 传入) -ENTRYPOINT ["/bin/bash"] diff --git a/auto-check/README.md b/auto-check/README.md index 78175d2..290cdb1 100644 --- a/auto-check/README.md +++ b/auto-check/README.md @@ -70,6 +70,52 @@ go build -o auto-check . | `memnative` | stressapptest | 内存稳定性精压(数据完整性校验) | | `full` | stress-ng | CPU+内存+磁盘 三合一综合压测 | +## 压测执行方式(Docker 容器) + +压测统一在 Docker 容器中执行:工具与测试脚本预装在镜像里,无需在目标机在线安装,环境一致性好(飞牛 NAS 默认已安装 Docker)。 + +### 架构设计 +``` +auto-check/ +├── docker/ # Docker 镜像维护目录(独立) +│ └── stress-test/ +│ ├── Dockerfile # 镜像定义 +│ ├── scripts/ # 测试脚本(打包进镜像) +│ │ ├── run.sh # 入口脚本(环境变量驱动) +│ │ ├── cpu.sh # CPU 压测 +│ │ ├── memory.sh # 内存压测 +│ │ ├── disk.sh # 磁盘压测 +│ │ └── ... +│ └── README.md +└── pkg/stress/ + └── runner.go # 核心逻辑:送镜像 + 驱动容器测试 +``` + +### 工作流程 +1. 检测目标机 Docker 可用性(不可用则判失败) +2. 检查镜像是否存在(不存在则上传工程目录中的镜像 tar 并 `docker load`,不依赖 registry) +3. 启动容器执行测试(脚本在镜像内,环境变量传参) +4. 解析容器输出 → 生成报告 + +### 配置方式(在 `auto-check.yaml` 中) +```yaml +stress: + docker_image: "stress-test:latest" # 压测镜像(飞牛 NAS 默认支持 Docker) + docker_tar: "docker/stress-test/stress-test.tar" # 本地镜像文件,目标机无镜像时自动上传并 load + docker_args: "--network=host --privileged" +``` + +### 构建镜像(维护时使用) +```bash +cd docker/stress-test +docker build -t stress-test:latest . +# 导出到工程目录,供测试时上传分发(不依赖 registry) +docker save -o stress-test.tar stress-test:latest +``` + +### 详细文档 +镜像维护说明请参考:[docker/stress-test/README.md](docker/stress-test/README.md) + ## 项目结构 ``` @@ -80,11 +126,11 @@ auto-check/ ├── pkg/ │ ├── discovery/ # 设备发现 │ ├── sshclient/ # SSH 客户端 -│ ├── stress/ # 压力测试 +│ ├── stress/ # 压力测试(Docker 容器驱动) │ │ ├── types.go # 类型定义 -│ │ ├── tools.go # 工具检测 + 监控 -│ │ ├── runners.go # 压测实现 -│ │ └── runner.go # 调度器 +│ │ ├── tools.go # Docker 环境检测 +│ │ ├── metrics.go # 指标定义 + 配置 +│ │ └── runner.go # 调度器(送镜像 + 驱动容器) │ └── report/ # 报告模块 ├── go.mod └── README.md diff --git a/auto-check/auto-check b/auto-check/auto-check new file mode 100644 index 0000000..860bf9d Binary files /dev/null and b/auto-check/auto-check differ diff --git a/auto-check/auto-check.yaml b/auto-check/auto-check.yaml index 3b9b1a6..6a37bb6 100644 --- a/auto-check/auto-check.yaml +++ b/auto-check/auto-check.yaml @@ -27,13 +27,11 @@ stress: disk_dir: "" # 磁盘压测目录,空=自动临时目录 temp_interval: 10s # 温度采样间隔 temp_limit: 90 # 温度上限(°C),超过判失败,0=不判定 - auto_install: true # 缺工具时在线安装(apt/dnf/yum 等),需要目标机可联网 - use_mirror: false # 在线安装是否切换国内镜像源(清华TUNA)。国内网络卡顿/超时再改 true - -# --- Docker 容器化压测 --- -docker: - enabled: true # 启用 Docker 模式(目标机需已安装 Docker,如飞牛系统) - image: "stress-tools:latest" # 镜像名,支持 registry/xxx:tag + # --- Docker 镜像(压测统一在容器中执行,工具与脚本预装在镜像里)--- + # 固定分发模式:目标机无镜像时,上传本地导出的 tar 并 docker load(不依赖 registry) + docker_image: "stress-test:latest" # 压测镜像名,镜像维护见 docker/stress-test/ + docker_tar: "docker/stress-test/stress-test.tar" # 本地镜像文件(docker save -o 导出) + docker_args: "--network=host --privileged" # Docker 运行参数,--privileged 可访问完整资源 # --- 工作流 --- workflow: diff --git a/auto-check/docker/stress-test/Dockerfile b/auto-check/docker/stress-test/Dockerfile new file mode 100644 index 0000000..ded9c3a --- /dev/null +++ b/auto-check/docker/stress-test/Dockerfile @@ -0,0 +1,45 @@ +# 压力测试工具镜像 +# +# 用途:为 auto-check 提供预装压测工具的容器环境 +# 优势:工具预装在镜像中,无需在线安装,环境一致性好,适合国内网络环境 +# +# 工作流程: +# 1. auto-check 检测目标机器的 Docker 可用性 +# 2. 从 Registry 拉取镜像(或本地已存在则跳过) +# 3. 启动容器执行测试(脚本已打包在镜像中) +# 4. 收集测试结果并解析 +# +# 构建命令: +# cd docker/stress-test +# docker build -t your-registry/stress-test:latest . +# docker push your-registry/stress-test:latest +# +# 使用方式(在 auto-check.yaml 中配置): +# stress: +# use_docker: true +# docker_image: "your-registry/stress-test:latest" + +FROM debian:12-slim + +# 清理缓存,减少镜像体积 +RUN apt-get update && apt-get install -y --no-install-recommends \ + stress-ng \ + fio \ + lm-sensors \ + sysstat \ + procps \ + && rm -rf /var/lib/apt/lists/* + +# 验证工具安装 +RUN stress-ng --version && fio --version && sensors --version + +# 复制测试脚本到镜像中 +COPY scripts/ /scripts/ + +# 设置脚本执行权限 +RUN chmod +x /scripts/*.sh + +WORKDIR /tmp + +# 默认执行入口(由 auto-check 传入脚本参数) +ENTRYPOINT ["/bin/bash", "/scripts/run.sh"] \ No newline at end of file diff --git a/auto-check/docker/stress-test/README.md b/auto-check/docker/stress-test/README.md new file mode 100644 index 0000000..f6b33e5 --- /dev/null +++ b/auto-check/docker/stress-test/README.md @@ -0,0 +1,100 @@ +# stress-test 镜像 + +压力测试镜像,为 auto-check 提供预装压测工具与测试脚本的容器环境。压测统一在容器内执行,无需在目标机在线安装任何依赖。 + +## 工作流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ auto-check 工作流程 │ +│ │ +│ 1. 检测目标机器的 Docker 可用性 │ +│ 2. 检查镜像是否存在(不存在则上传本地导出的 tar 并 docker load)│ +│ 3. 启动容器执行测试(脚本已打包在镜像中) │ +│ 4. 收集测试结果并解析 │ +│ 5. 生成报告 │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 优势 + +| 特性 | 说明 | +|------|------| +| 工具获取 | 预装在镜像中,无需在线安装 | +| 网络依赖 | 镜像已缓存后目标机无需联网 | +| 环境一致性 | 统一的容器环境,不受目标机发行版差异影响 | + +## 构建与导出(维护时使用) + +固定分发模式:不依赖 registry,镜像以 tar 文件形式放在工程目录, +目标机无镜像时由 auto-check 自动上传并 `docker load`。 + +```bash +cd docker/stress-test +docker build -t stress-test:latest . +# 导出到工程目录(供 auto-check 上传分发,已加入 .gitignore) +docker save -o stress-test.tar stress-test:latest +``` + +## 使用 + +在 `auto-check.yaml` 中配置: + +```yaml +stress: + docker_image: "stress-test:latest" + docker_tar: "docker/stress-test/stress-test.tar" + docker_args: "--network=host --privileged" +``` + +## 环境变量 + +容器运行时通过以下环境变量传入参数: + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `AUTOCHECK_TYPES` | 测试类型(逗号分隔) | `cpu,memory` | +| `AUTOCHECK_DURATION` | 持续时间(秒) | `30` | +| `AUTOCHECK_THREADS` | 线程数 | `4` | +| `AUTOCHECK_MEM_SIZE_MB` | 内存压测大小 | `0` (自动) | +| `AUTOCHECK_DISK_SIZE_MB` | 磁盘压测大小 | `1024` | +| `AUTOCHECK_DISK_DIR` | 磁盘压测目录 | `/tmp` | +| `AUTOCHECK_TEMP_INTERVAL` | 温度采样间隔 | `10` | +| `AUTOCHECK_SAMPLE_INTERVAL` | 运行时采样间隔 | `2` | + +## 容器内执行流程(run.sh) + +1. 系统信息(`===SYSTEM_INFO===`) +2. 温度监控后台采样(`temp_start.sh`) +3. 各项压测:先单项(cpu/memory/disk/memnative)、综合(full)最后 +4. 温度监控收尾(`===MONITOR:temp===`) +5. 内核日志(`===MONITOR:dmesg===`)+ 结束标记 + +## 镜像内容 + +``` +/stress-test/ +├── Dockerfile # 镜像定义 +├── scripts/ # 测试脚本 +│ ├── run.sh # 入口脚本 +│ ├── cpu.sh # CPU 压测 +│ ├── memory.sh # 内存压测 +│ ├── disk.sh # 磁盘压测 +│ ├── full.sh # 综合压测 +│ ├── memnative.sh # 内存精压 +│ ├── sysinfo.sh # 系统信息 +│ ├── temp_start.sh # 温度监控启动 +│ ├── temp_end.sh # 温度监控收尾 +│ └── dmesg.sh # 内核日志 +└── README.md # 本文档 +``` + +## 依赖工具 + +| 工具 | 用途 | +|------|------| +| `stress-ng` | CPU/内存/磁盘压测 | +| `fio` | 磁盘 IO 测试 | +| `lm-sensors` | 温度监控 | +| `sysstat` | 系统性能统计 | +| `procps` | 进程监控 | \ No newline at end of file diff --git a/auto-check/scripts/cpu.sh b/auto-check/docker/stress-test/scripts/cpu.sh similarity index 100% rename from auto-check/scripts/cpu.sh rename to auto-check/docker/stress-test/scripts/cpu.sh diff --git a/auto-check/scripts/disk.sh b/auto-check/docker/stress-test/scripts/disk.sh similarity index 100% rename from auto-check/scripts/disk.sh rename to auto-check/docker/stress-test/scripts/disk.sh diff --git a/auto-check/scripts/dmesg.sh b/auto-check/docker/stress-test/scripts/dmesg.sh similarity index 100% rename from auto-check/scripts/dmesg.sh rename to auto-check/docker/stress-test/scripts/dmesg.sh diff --git a/auto-check/scripts/full.sh b/auto-check/docker/stress-test/scripts/full.sh similarity index 100% rename from auto-check/scripts/full.sh rename to auto-check/docker/stress-test/scripts/full.sh diff --git a/auto-check/scripts/memnative.sh b/auto-check/docker/stress-test/scripts/memnative.sh similarity index 100% rename from auto-check/scripts/memnative.sh rename to auto-check/docker/stress-test/scripts/memnative.sh diff --git a/auto-check/scripts/memory.sh b/auto-check/docker/stress-test/scripts/memory.sh similarity index 100% rename from auto-check/scripts/memory.sh rename to auto-check/docker/stress-test/scripts/memory.sh diff --git a/auto-check/docker/stress-test/scripts/run.sh b/auto-check/docker/stress-test/scripts/run.sh new file mode 100644 index 0000000..d20aaf8 --- /dev/null +++ b/auto-check/docker/stress-test/scripts/run.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# 压力测试入口脚本(打包在镜像内) +# auto-check 启动容器时通过环境变量传参,执行完整测试流程: +# 系统信息 → 温度监控启动 → 各项压测(先单项、后综合)→ 温度监控收尾 → 内核日志 +set -e + +# 默认值(docker -e 传入时覆盖) +export AUTOCHECK_TYPES="${AUTOCHECK_TYPES:-cpu,memory}" +export AUTOCHECK_DURATION="${AUTOCHECK_DURATION:-30}" +export AUTOCHECK_THREADS="${AUTOCHECK_THREADS:-4}" +export AUTOCHECK_MEM_SIZE_MB="${AUTOCHECK_MEM_SIZE_MB:-0}" +export AUTOCHECK_DISK_SIZE_MB="${AUTOCHECK_DISK_SIZE_MB:-1024}" +export AUTOCHECK_TEMP_INTERVAL="${AUTOCHECK_TEMP_INTERVAL:-10}" +export AUTOCHECK_SAMPLE_INTERVAL="${AUTOCHECK_SAMPLE_INTERVAL:-2}" +if [ -n "$AUTOCHECK_DISK_DIR" ]; then export AUTOCHECK_DISK_DIR; fi + +# 1. 系统信息 +source /scripts/sysinfo.sh + +# 2. 温度监控(后台周期采样) +source /scripts/temp_start.sh + +# 未知类型输出 skip,保证报告完整 +for T in $(echo "$AUTOCHECK_TYPES" | tr ',' ' '); do + case "$T" in + cpu|memory|disk|memnative|full) ;; + *) + echo "===TEST:$T===" + echo "status:skip" + echo "output:未知测试类型" + echo "" + ;; + esac +done + +# 3. 按固定顺序执行请求的测试(先单项、综合 full 最后) +REQ_TYPES=",${AUTOCHECK_TYPES// /}," +for TYPE in cpu memory disk memnative full; do + case "$REQ_TYPES" in + *",$TYPE,"*) source "/scripts/$TYPE.sh" ;; + esac +done + +# 4. 温度监控收尾 +source /scripts/temp_end.sh + +# 5. 内核日志 + 结束标记 +source /scripts/dmesg.sh diff --git a/auto-check/scripts/sysinfo.sh b/auto-check/docker/stress-test/scripts/sysinfo.sh similarity index 100% rename from auto-check/scripts/sysinfo.sh rename to auto-check/docker/stress-test/scripts/sysinfo.sh diff --git a/auto-check/scripts/temp_end.sh b/auto-check/docker/stress-test/scripts/temp_end.sh similarity index 100% rename from auto-check/scripts/temp_end.sh rename to auto-check/docker/stress-test/scripts/temp_end.sh diff --git a/auto-check/scripts/temp_start.sh b/auto-check/docker/stress-test/scripts/temp_start.sh similarity index 100% rename from auto-check/scripts/temp_start.sh rename to auto-check/docker/stress-test/scripts/temp_start.sh diff --git a/auto-check/go.mod b/auto-check/go.mod index a4fa172..4c2b77a 100644 --- a/auto-check/go.mod +++ b/auto-check/go.mod @@ -11,6 +11,8 @@ require ( require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/pkg/sftp v1.13.11 // indirect github.com/spf13/pflag v1.0.10 // indirect golang.org/x/sys v0.47.0 // indirect ) diff --git a/auto-check/go.sum b/auto-check/go.sum index fd2f91f..8c99f5b 100644 --- a/auto-check/go.sum +++ b/auto-check/go.sum @@ -1,6 +1,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= +github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= diff --git a/auto-check/pkg/config/config.go b/auto-check/pkg/config/config.go index 759ea31..d5092c8 100644 --- a/auto-check/pkg/config/config.go +++ b/auto-check/pkg/config/config.go @@ -38,7 +38,7 @@ type SSHConfig struct { Password string `yaml:"password"` KeyFile string `yaml:"key"` Port int `yaml:"port"` - SudoPwd string `yaml:"sudo_password"` // 非 root 用户安装工具时使用的 sudo 密码 + SudoPwd string `yaml:"sudo_password"` // 非 root 用户执行特权命令时的 sudo 密码 } // StressConfig 压力测试 @@ -51,8 +51,10 @@ type StressConfig struct { DiskDir string `yaml:"disk_dir"` // 磁盘压测目录,空=自动临时目录 TempInterval time.Duration `yaml:"temp_interval"` // 温度采样间隔,0=默认10s TempLimit float64 `yaml:"temp_limit"` // 温度上限(°C),0=不判定 - AutoInstall bool `yaml:"auto_install"` // 缺工具时尝试在线安装(apt/dnf/yum 等) - UseMirror bool `yaml:"use_mirror"` // 在线安装时切换国内镜像源(清华 TUNA),国内网络卡顿/超时再开启 + // 压测统一在 Docker 容器中执行(工具与脚本预装在镜像里) + DockerImage string `yaml:"docker_image"` // 压测镜像名称,如 stress-test:latest + DockerTar string `yaml:"docker_tar"` // 本地导出的镜像文件(docker save),目标机无镜像时上传并 load + DockerArgs string `yaml:"docker_args"` // Docker 运行参数,如 "--network=host --privileged" } // ReportConfig 报告 @@ -97,8 +99,9 @@ func Default() *Config { DiskSizeMB: 1024, TempInterval: 10 * time.Second, TempLimit: 90, - AutoInstall: true, - UseMirror: false, + DockerImage: "stress-test:latest", + DockerTar: "docker/stress-test/stress-test.tar", + DockerArgs: "--network=host --privileged", }, Workflow: WorkflowConfig{ Interval: 10 * time.Second, diff --git a/auto-check/pkg/sshclient/sshclient.go b/auto-check/pkg/sshclient/sshclient.go index e94dae6..304f589 100644 --- a/auto-check/pkg/sshclient/sshclient.go +++ b/auto-check/pkg/sshclient/sshclient.go @@ -2,11 +2,14 @@ package sshclient import ( "fmt" + "io" "net" "os" + "path" "strings" "time" + "github.com/pkg/sftp" "golang.org/x/crypto/ssh" ) @@ -113,6 +116,45 @@ func EnsureHome(client *ssh.Client) { RunCommand(client, fmt.Sprintf("mkdir -p %s", home)) } +// UploadFile 通过 SFTP 把本地文件上传到远端指定路径(复用已有 SSH 连接) +func UploadFile(client *ssh.Client, localPath, remotePath string) error { + src, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("打开本地文件失败: %w", err) + } + defer src.Close() + + info, err := src.Stat() + if err != nil { + return fmt.Errorf("读取文件信息失败: %w", err) + } + + sftpCli, err := sftp.NewClient(client) + if err != nil { + return fmt.Errorf("创建 SFTP 会话失败: %w", err) + } + defer sftpCli.Close() + + // 确保远端目录存在(/tmp 一般已存在,兜底处理) + if dir := path.Dir(remotePath); dir != "" && dir != "/" { + _ = sftpCli.MkdirAll(dir) + } + + dst, err := sftpCli.Create(remotePath) + if err != nil { + return fmt.Errorf("创建远端文件失败: %w", err) + } + defer dst.Close() + + fmt.Printf(" [上传] %s -> %s (%.1f MB)...\n", localPath, remotePath, float64(info.Size())/1024/1024) + start := time.Now() + if _, err := io.Copy(dst, src); err != nil { + return fmt.Errorf("文件传输失败: %w", err) + } + fmt.Printf(" [上传] 完成,耗时 %.1fs\n", time.Since(start).Seconds()) + return nil +} + // buildSSHConfig 构建认证配置 func (c *Config) buildSSHConfig() (*ssh.ClientConfig, error) { var authMethods []ssh.AuthMethod diff --git a/auto-check/pkg/stress/metrics.go b/auto-check/pkg/stress/metrics.go index 5d05801..8df9b12 100644 --- a/auto-check/pkg/stress/metrics.go +++ b/auto-check/pkg/stress/metrics.go @@ -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 默认配置 diff --git a/auto-check/pkg/stress/runner.go b/auto-check/pkg/stress/runner.go index e2bcb50..e658125 100644 --- a/auto-check/pkg/stress/runner.go +++ b/auto-check/pkg/stress/runner.go @@ -4,7 +4,6 @@ import ( "fmt" "net" "os" - "sort" "strconv" "strings" "time" @@ -23,68 +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 // 自动安装仍失败的工具 - useDocker bool // 是否使用 Docker 模式执行 - dockerImage string // Docker 镜像名(如 registry/stress-tools:latest) - sudoPwd string // sudo 密码(用于 docker 命令) + 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} -} - -// NewRunnerDocker 创建 Docker 模式执行器(工具预装在镜像中,无需在线安装) -// image 为 Docker 镜像名,sudoPwd 用于非 root 用户执行 docker 命令。 -func NewRunnerDocker(cfg Config, types []TestType, client *ssh.Client, image string, sudoPwd string) *Runner { - // Docker 模式下,镜像内已包含所有工具,直接标记全部可用。 - // 这样 BuildMetrics 不会因缺少工具而禁用测试项。 - tools := ToolSet{Tools: make(map[string]ToolInfo)} - for _, name := range requiredTools { - tools.Tools[name] = ToolInfo{Name: name, Path: "/usr/bin/" + name, Version: "docker"} +// 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") } - // 额外标记 lm-sensors、stressapptest(Docker 镜像预装) - tools.Tools["lm-sensors"] = ToolInfo{Name: "lm-sensors", Path: "/usr/bin/sensors", Version: "docker"} - tools.Tools["stressapptest"] = ToolInfo{Name: "stressapptest", Path: "/usr/bin/stressapptest", Version: "docker"} - metrics := BuildMetrics(types, tools) return &Runner{ - cfg: cfg, - types: types, - client: client, - tools: tools, - metrics: metrics, - useDocker: true, - dockerImage: image, - sudoPwd: sudoPwd, + 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 { @@ -98,25 +77,13 @@ func (r *Runner) Run(ip string) *Report { } } - // 按需读取各测试脚本,拼装成完整脚本 - script := r.assembleScript() - fmt.Printf(" [脚本] 拼装完成,%d 字节\n", len(script)) - - // 通过 heredoc 传到远程执行(Docker 模式或原生模式) - var scriptCmd string - if r.useDocker { - scriptCmd = r.buildDockerCmd(script) - fmt.Printf(" [模式] Docker 容器执行(镜像: %s)\n", r.dockerImage) - } else { - 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) } @@ -310,148 +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 -} -// buildDockerCmd 构造 Docker 执行命令。 -// 将脚本通过 heredoc 传入 docker run,在容器内执行。 -// 容器需要挂载 /sys(温度传感器)、/volume1(飞牛存储卷)等。 -func (r *Runner) buildDockerCmd(script string) string { - // 构造 docker run 命令: - // --rm 容器结束后自动清理 - // -i 交互式输入(通过 stdin 传入脚本) - // --privileged 允许访问硬件(温度传感器、内核日志) - // --pid=host 共享宿主机进程空间(top 可见) - // -v /sys:/sys:ro 温度传感器(只读) - // -v /volume1:/volume1 飞牛存储卷(磁盘测试) - dockerCmd := fmt.Sprintf( - "docker run --rm -i --privileged --pid=host "+ - "-v /sys:/sys:ro "+ - "-v /volume1:/volume1 "+ - "%s bash -s", - r.dockerImage, - ) - // 通过 heredoc 将脚本传入容器执行(使用 sudo 以获取 docker 权限) - heredoc := fmt.Sprintf("cat <<'AUTOCHECKSCRIPT' | %s\n%s\nAUTOCHECKSCRIPT", dockerCmd, script) + // 本地导出的镜像文件(docker save -o xxx.tar ) + if r.cfg.DockerTar == "" { + return fmt.Errorf("目标机不存在镜像 %s 且未配置 docker_tar", r.cfg.DockerImage) + } + 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) + } - // 如果需要 sudo(非 root 用户) - runner := heredoc - if out, err := sshclient.RunCommand(r.client, "id -u"); err == nil && strings.TrimSpace(out) != "0" { - if r.sudoPwd != "" { - runner = fmt.Sprintf("echo '%s' | sudo -S bash -c %s", r.sudoPwd, shellQuote(heredoc)) - } else { - runner = "sudo " + heredoc - } + 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) } - return runner -} -// testPriority 测试执行顺序:单项测试在前,综合测试(full)最后,监控项最后 -func testPriority(m Metric) int { - if m.IsMonitor { - return 1000 - } - 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 + 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 } // ============================ @@ -490,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 @@ -497,31 +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 - // 检测是否使用 Docker 模式(配置启用且远程主机支持) - if cfg.Docker.Enabled { - dockerInfo := DetectDocker(conn, cfg.Docker.Image, sudoPwd) - if dockerInfo.Available { - if dockerInfo.HasImage { - fmt.Printf(" [Docker] 镜像 %s 已存在,使用容器模式\n", cfg.Docker.Image) - return NewRunnerDocker(stressCfg, ParseTypes(cfg.Stress.Types), conn, cfg.Docker.Image, sudoPwd).Run(ip) - } - // 镜像不存在,尝试拉取(如果失败则回退到原生模式) - fmt.Printf(" [Docker] 镜像 %s 不存在,尝试拉取...\n", cfg.Docker.Image) - if out, err := runPrivileged(conn, fmt.Sprintf("docker pull %s 2>&1", cfg.Docker.Image), sudoPwd); err == nil { - fmt.Printf(" [Docker] 镜像拉取成功\n") - return NewRunnerDocker(stressCfg, ParseTypes(cfg.Stress.Types), conn, cfg.Docker.Image, sudoPwd).Run(ip) - } else { - fmt.Printf(" [Docker] 镜像拉取失败,回退到原生模式: %v\n%s\n", err, truncate(out, 200)) - } - } else { - fmt.Printf(" [Docker] 目标机未安装 Docker,回退到原生模式\n") - } - } - - // 原生模式:在线安装工具(回退路径) - 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 连接失败报告 diff --git a/auto-check/pkg/stress/tools.go b/auto-check/pkg/stress/tools.go index d9b8d67..21177ac 100644 --- a/auto-check/pkg/stress/tools.go +++ b/auto-check/pkg/stress/tools.go @@ -10,199 +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) - } -} - -// ============================ -// Docker 容器化压测支持 -// ============================ - -// DockerInfo 远程 Docker 环境信息 +// DockerInfo Docker 运行环境信息 type DockerInfo struct { - Available bool // Docker 是否可用(已安装且有权限) - Image string // 镜像名(检测时传入,确认存在时回填) - HasImage bool // 目标镜像是否已存在本地(避免每次 pull) + Version string // Docker 版本号(如 24.0.7) + Available bool // 是否可用 } -// DetectDocker 检测远程主机的 Docker 环境。 -// 包括:docker 命令是否存在、是否需要 sudo、镜像是否已存在。 -// sudoPwd 为非 root 用户执行时的密码(用于 sudo docker)。 -func DetectDocker(client *ssh.Client, image string, sudoPwd string) DockerInfo { - info := DockerInfo{Image: image} - - // 1. 检测 docker 命令是否存在(支持 root 和 sudo) - if out, err := runPrivileged(client, "command -v docker", sudoPwd); err != nil || strings.TrimSpace(out) == "" { - return 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} } - info.Available = true - - // 2. 检测 docker 服务是否运行(docker info 成功即可) - if _, err := runPrivileged(client, "docker info >/dev/null 2>&1", sudoPwd); err != nil { - info.Available = false - return info + return DockerInfo{ + Version: strings.TrimSpace(out), + Available: true, } - - // 3. 检测目标镜像是否已存在本地(避免每次拉取) - if out, err := runPrivileged(client, fmt.Sprintf("docker image inspect %s >/dev/null 2>&1 && echo EXIST", image), sudoPwd); err == nil && strings.Contains(out, "EXIST") { - info.HasImage = true - } - - return info -} - -// ============================ -// 远程工具检测 -// ============================ - -// 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 配置)。 +// 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" { @@ -211,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) @@ -243,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 -} diff --git a/auto-check/verify_dispatch_main.go b/auto-check/verify_dispatch_main.go new file mode 100644 index 0000000..1ec8b2d --- /dev/null +++ b/auto-check/verify_dispatch_main.go @@ -0,0 +1,44 @@ +//go:build ignore + +// 临时验证程序:验证 镜像上传 + docker load 分发链路(用完即删) +package main + +import ( + "fmt" + "os" + "strings" + "time" + + "auto-check/pkg/sshclient" +) + +func main() { + ip := os.Args[1] + cfg := sshclient.NewConfig("admin", "admin123", "", 22, 10*time.Second) + cli, err := sshclient.Connect(ip, cfg) + if err != nil { + fmt.Println("SSH 连接失败:", err) + os.Exit(1) + } + defer cli.Close() + sshclient.EnsureHome(cli) + + v, _ := sshclient.RunCommand(cli, "echo 'admin123' | sudo -S -- docker version --format '{{.Server.Version}}' 2>/dev/null") + fmt.Printf("Docker 版本: %q\n", strings.TrimSpace(v)) + + exists, _ := sshclient.RunCommand(cli, "echo 'admin123' | sudo -S -- docker images stress-test:latest --format '{{.Repository}}:{{.Tag}}' 2>/dev/null") + fmt.Printf("镜像状态: %q\n", strings.TrimSpace(exists)) + + if strings.TrimSpace(exists) == "" { + if err := sshclient.UploadFile(cli, "docker/stress-test/stress-test.tar", "/tmp/auto-check-image.tar"); err != nil { + fmt.Println("上传失败:", err) + os.Exit(1) + } + out, err := sshclient.RunCommand(cli, "echo 'admin123' | sudo -S -- docker load -i /tmp/auto-check-image.tar 2>&1") + sshclient.RunCommand(cli, "rm -f /tmp/auto-check-image.tar") + fmt.Printf("load 输出: %s (err=%v)\n", out, err) + exists, _ = sshclient.RunCommand(cli, "echo 'admin123' | sudo -S -- docker images stress-test:latest --format '{{.Repository}}:{{.Tag}}' 2>/dev/null") + fmt.Printf("load 后镜像状态: %q\n", strings.TrimSpace(exists)) + } + fmt.Println("验证完成") +}