新增 auto-check 工具:网段扫描+SSH登录+压力测试永久循环工作流
- pkg/discovery: TCP端口探测存活主机,ARP表解析MAC作为设备唯一标识 - pkg/sshclient: SSH连接(密码/密钥认证)与远程命令执行 - pkg/stress: stress-ng/stressapptest 压测(cpu/memory/disk/memnative/full)+温度与dmesg监控 - pkg/report: 检测报告生成(文本/JSON) - pkg/workflow: 永久循环工作流(间隔可配),MAC唯一标识设备,增量测试+状态变化打印 - pkg/config: YAML分类配置(scan/ssh/stress/report/workflow) - cmd: cobra入口,仅 --config 指定配置文件
This commit is contained in:
8
auto-check/.gitignore
vendored
Normal file
8
auto-check/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# 运行产物
|
||||
reports/
|
||||
*.exe
|
||||
*.test
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
90
auto-check/README.md
Normal file
90
auto-check/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# auto-check
|
||||
|
||||
自动化网段扫描 + SSH 登录 + 远程压力测试工具。
|
||||
|
||||
基于 [cobra](https://github.com/spf13/cobra) 命令行框架。
|
||||
|
||||
## 功能
|
||||
|
||||
| 子命令 | 功能 | 包 |
|
||||
|--------|------|-----|
|
||||
| `run` | 完整流程:发现 → SSH → 压测 | 全部 |
|
||||
| `scan` | 仅扫描网段,发现存活设备 | `pkg/discovery` |
|
||||
| `stress` | 对指定 IP 执行压力测试 | `pkg/stress` + `pkg/sshclient` |
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
go build -o auto-check .
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
```bash
|
||||
# 查看帮助
|
||||
./auto-check --help
|
||||
|
||||
# 仅扫描网段
|
||||
./auto-check scan --cidr 192.168.1.0/24
|
||||
|
||||
# 对指定主机执行压力测试
|
||||
./auto-check stress --ips 192.168.1.100,192.168.1.101 --user root --password secret --stress cpu,memory,disk
|
||||
|
||||
# 完整流程(扫描 → SSH → 压测)
|
||||
./auto-check run --cidr 192.168.1.0/24 --user root --password secret --stress cpu,memory,disk
|
||||
```
|
||||
|
||||
## 全局标志
|
||||
|
||||
| 标志 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--cidr` | `192.168.1.0/24` | 目标网段 (CIDR) |
|
||||
| `--user` | `root` | SSH 用户名 |
|
||||
| `--password` | _(空)_ | SSH 密码 |
|
||||
| `--key` | _(空)_ | SSH 私钥路径 |
|
||||
| `--port` | `22` | SSH 端口 |
|
||||
| `--timeout` | `3s` | 超时 |
|
||||
| `--concurrency` | `50` | 并发扫描数 |
|
||||
| `--report` | _(自动生成)_ | 报告输出路径 |
|
||||
|
||||
## 子命令标志
|
||||
|
||||
### `run` / `stress`
|
||||
| 标志 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--stress` | `cpu,memory` | 压测类型: `cpu`, `memory`, `disk`, `memnative`, `full` |
|
||||
| `--duration` | `30s` | 持续时间 |
|
||||
| `--threads` | `4` | 线程数 |
|
||||
| `--ips` | _(必填,仅stress)_ | 目标 IP (逗号分隔) |
|
||||
|
||||
## 压力测试类型
|
||||
|
||||
| 类型 | 工具 | 说明 |
|
||||
|------|------|------|
|
||||
| `cpu` | stress-ng | CPU 全方法压测 |
|
||||
| `memory` | stress-ng | 内存压测 |
|
||||
| `disk` | stress-ng | 磁盘IO混合压测 |
|
||||
| `memnative` | stressapptest | 内存稳定性精压(数据完整性校验) |
|
||||
| `full` | stress-ng | CPU+内存+磁盘 三合一综合压测 |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
auto-check/
|
||||
├── main.go
|
||||
├── cmd/
|
||||
│ └── root.go # 所有子命令
|
||||
├── pkg/
|
||||
│ ├── discovery/ # 设备发现
|
||||
│ ├── sshclient/ # SSH 客户端
|
||||
│ ├── stress/ # 压力测试
|
||||
│ │ ├── types.go # 类型定义
|
||||
│ │ ├── tools.go # 工具检测 + 监控
|
||||
│ │ ├── runners.go # 压测实现
|
||||
│ │ └── runner.go # 调度器
|
||||
│ └── report/ # 报告模块
|
||||
├── go.mod
|
||||
└── README.md
|
||||
```
|
||||
|
||||
> ⚠️ 请在授权设备上使用,未授权扫描/测试可能违反法律法规。
|
||||
29
auto-check/auto-check.yaml
Normal file
29
auto-check/auto-check.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
# auto-check 配置文件
|
||||
# 所有参数仅从本文件加载,按分类组织
|
||||
|
||||
# --- 网段扫描 ---
|
||||
scan:
|
||||
cidr: "192.168.1.0/24"
|
||||
timeout: 3s
|
||||
concurrency: 50
|
||||
|
||||
# --- SSH 认证 ---
|
||||
ssh:
|
||||
user: "root"
|
||||
password: ""
|
||||
key: ""
|
||||
port: 22
|
||||
|
||||
# --- 压力测试 ---
|
||||
stress:
|
||||
types: "cpu,memory"
|
||||
duration: 30s
|
||||
threads: 4
|
||||
|
||||
# --- 工作流 ---
|
||||
workflow:
|
||||
interval: 10s
|
||||
|
||||
# --- 报告 ---
|
||||
report:
|
||||
path: "reports"
|
||||
35
auto-check/cmd/root.go
Normal file
35
auto-check/cmd/root.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
configFile string // 配置文件路径
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "auto-check",
|
||||
Short: "自动化设备发现 + SSH 登录 + 远程压力测试工具",
|
||||
Long: `auto-check 是一个自动化运维工具,支持:
|
||||
1. 网段扫描发现存活设备
|
||||
2. 自动 SSH 登录验证
|
||||
3. 远程执行压力测试(CPU/内存/磁盘/网络)
|
||||
|
||||
配置仅从配置文件加载(默认 ./auto-check.yaml)`,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "配置文件路径 (默认: ./auto-check.yaml)")
|
||||
}
|
||||
|
||||
// Execute 入口:加载配置并启动
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
33
auto-check/cmd/run.go
Normal file
33
auto-check/cmd/run.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"auto-check/pkg/config"
|
||||
"auto-check/pkg/workflow"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var runCmd = &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "启动永久循环工作流:扫描 → 增量压测 → 状态打印",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// 从配置文件加载配置
|
||||
cfg, err := config.Load(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" [配置] 加载完成: 网段=%s 间隔=%v 压测=%s\n",
|
||||
cfg.Scan.CIDR, cfg.Workflow.Interval, cfg.Stress.Types)
|
||||
|
||||
// 启动工作流(永久循环)
|
||||
ctx := workflow.NewContext()
|
||||
ctx.Config = *cfg
|
||||
return workflow.Loop(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(runCmd)
|
||||
}
|
||||
15
auto-check/go.mod
Normal file
15
auto-check/go.mod
Normal file
@@ -0,0 +1,15 @@
|
||||
module auto-check
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/crypto v0.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
19
auto-check/go.sum
Normal file
19
auto-check/go.sum
Normal file
@@ -0,0 +1,19 @@
|
||||
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/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=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
7
auto-check/main.go
Normal file
7
auto-check/main.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "auto-check/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
119
auto-check/pkg/config/config.go
Normal file
119
auto-check/pkg/config/config.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ============================
|
||||
// Config — 分类配置
|
||||
// ============================
|
||||
|
||||
// Config 全局运行配置(仅从配置文件加载)
|
||||
type Config struct {
|
||||
Scan ScanConfig `yaml:"scan"`
|
||||
SSH SSHConfig `yaml:"ssh"`
|
||||
Stress StressConfig `yaml:"stress"`
|
||||
Report ReportConfig `yaml:"report"`
|
||||
Workflow WorkflowConfig `yaml:"workflow"`
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 分类配置项
|
||||
// ============================
|
||||
|
||||
// ScanConfig 网段扫描
|
||||
type ScanConfig struct {
|
||||
CIDR string `yaml:"cidr"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
}
|
||||
|
||||
// SSHConfig SSH 认证
|
||||
type SSHConfig struct {
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
KeyFile string `yaml:"key"`
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
// StressConfig 压力测试
|
||||
type StressConfig struct {
|
||||
Types string `yaml:"types"`
|
||||
Duration time.Duration `yaml:"duration"`
|
||||
Threads int `yaml:"threads"`
|
||||
}
|
||||
|
||||
// ReportConfig 报告
|
||||
type ReportConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
// WorkflowConfig 工作流
|
||||
type WorkflowConfig struct {
|
||||
Interval time.Duration `yaml:"interval"` // 轮询间隔
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 加载
|
||||
// ============================
|
||||
|
||||
// Default 内置默认配置
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
Scan: ScanConfig{
|
||||
CIDR: "192.168.1.0/24",
|
||||
Timeout: 3 * time.Second,
|
||||
Concurrency: 50,
|
||||
},
|
||||
SSH: SSHConfig{
|
||||
User: "root",
|
||||
Port: 22,
|
||||
},
|
||||
Stress: StressConfig{
|
||||
Types: "cpu,memory",
|
||||
Duration: 30 * time.Second,
|
||||
Threads: 4,
|
||||
},
|
||||
Workflow: WorkflowConfig{
|
||||
Interval: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load 从文件加载配置(覆盖默认值)
|
||||
// path 为空时尝试加载 ./auto-check.yaml
|
||||
// 文件不存在时返回默认配置
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := Default()
|
||||
|
||||
if path == "" {
|
||||
path = "auto-check.yaml"
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil // 无配置文件,使用默认值
|
||||
}
|
||||
return nil, fmt.Errorf("读取配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadFromString 从字符串加载(用于测试)
|
||||
func LoadFromString(s string) (*Config, error) {
|
||||
cfg := Default()
|
||||
if err := yaml.Unmarshal([]byte(s), cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析配置失败: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
205
auto-check/pkg/discovery/discovery.go
Normal file
205
auto-check/pkg/discovery/discovery.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Host 存活主机信息
|
||||
type Host struct {
|
||||
IP net.IP
|
||||
MAC string // 物理地址(设备唯一标识)
|
||||
Alive bool
|
||||
OpenPort int
|
||||
}
|
||||
|
||||
// Scanner 网段扫描器
|
||||
type Scanner struct {
|
||||
CIDR string
|
||||
Port int
|
||||
Timeout time.Duration
|
||||
Concurrency int
|
||||
}
|
||||
|
||||
// NewScanner 创建扫描器
|
||||
func NewScanner(cidr string, port int, timeout time.Duration, concurrency int) *Scanner {
|
||||
return &Scanner{
|
||||
CIDR: cidr,
|
||||
Port: port,
|
||||
Timeout: timeout,
|
||||
Concurrency: concurrency,
|
||||
}
|
||||
}
|
||||
|
||||
// Scan 扫描网段,返回存活主机列表
|
||||
func (s *Scanner) Scan() []Host {
|
||||
ip, ipnet, err := net.ParseCIDR(s.CIDR)
|
||||
if err != nil {
|
||||
fmt.Printf("[扫描] 网段解析失败: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var ips []net.IP
|
||||
for ip := ip.Mask(ipnet.Mask); ipnet.Contains(ip); inc(ip) {
|
||||
dst := make(net.IP, len(ip))
|
||||
copy(dst, ip)
|
||||
ips = append(ips, dst)
|
||||
}
|
||||
// 排除网络地址和广播地址
|
||||
if len(ips) > 2 {
|
||||
ips = ips[1 : len(ips)-1]
|
||||
}
|
||||
|
||||
fmt.Printf("[扫描] 网段 %s,共 %d 个IP,开始探测...\n", s.CIDR, len(ips))
|
||||
|
||||
// 并发探测
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
var results []Host
|
||||
|
||||
sem := make(chan struct{}, s.Concurrency)
|
||||
for _, ip := range ips {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(target net.IP) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
h := s.probe(target)
|
||||
if h.Alive {
|
||||
// 获取 MAC 地址(TCP 探测已触发 ARP 解析)
|
||||
h.MAC = getMAC(target.String())
|
||||
mu.Lock()
|
||||
results = append(results, h)
|
||||
mu.Unlock()
|
||||
desc := "无MAC"
|
||||
if h.MAC != "" {
|
||||
desc = h.MAC
|
||||
}
|
||||
fmt.Printf(" [+] %s 存活 (MAC: %s, 端口 %d 开放)\n", target, desc, h.OpenPort)
|
||||
}
|
||||
}(ip)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
fmt.Printf("[扫描] 完成,发现 %d 台存活主机\n", len(results))
|
||||
return results
|
||||
}
|
||||
|
||||
// probe 探测单个 IP
|
||||
func (s *Scanner) probe(ip net.IP) Host {
|
||||
h := Host{IP: ip}
|
||||
|
||||
// TCP 端口探测(连接成功会触发本地 ARP 解析)
|
||||
addr := net.JoinHostPort(ip.String(), fmt.Sprintf("%d", s.Port))
|
||||
conn, err := net.DialTimeout("tcp", addr, s.Timeout)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
h.Alive = true
|
||||
h.OpenPort = s.Port
|
||||
return h
|
||||
}
|
||||
|
||||
// 备选:尝试常见端口
|
||||
for _, p := range []int{22, 80, 443, 8080, 3306} {
|
||||
addr = net.JoinHostPort(ip.String(), fmt.Sprintf("%d", p))
|
||||
conn, err = net.DialTimeout("tcp", addr, s.Timeout)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
h.Alive = true
|
||||
h.OpenPort = p
|
||||
return h
|
||||
}
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// getMAC 从 ARP 表获取 IP 对应的 MAC 地址(跨平台)
|
||||
// 返回空字符串表示无法获取
|
||||
func getMAC(ip string) string {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return macFromProcNetArp(ip)
|
||||
case "windows":
|
||||
return macFromWindowsArp(ip)
|
||||
case "darwin":
|
||||
return macFromUnixArp(ip)
|
||||
default:
|
||||
return macFromUnixArp(ip)
|
||||
}
|
||||
}
|
||||
|
||||
// macFromProcNetArp 解析 Linux /proc/net/arp
|
||||
func macFromProcNetArp(ip string) string {
|
||||
data, err := os.ReadFile("/proc/net/arp")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n")[1:] {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 4 && fields[0] == ip {
|
||||
mac := fields[3]
|
||||
if mac != "" && mac != "00:00:00:00:00:00" {
|
||||
return mac
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// macFromWindowsArp 解析 Windows `arp -a` 输出
|
||||
func macFromWindowsArp(ip string) string {
|
||||
out, err := exec.Command("arp", "-a").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
// Windows 格式: IP MAC Type
|
||||
if len(fields) >= 2 && fields[0] == ip {
|
||||
mac := strings.ReplaceAll(fields[1], "-", ":")
|
||||
if mac != "" && mac != "00:00:00:00:00:00" {
|
||||
return strings.ToLower(mac)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// macFromUnixArp 解析 macOS/Linux `arp -n` 输出
|
||||
func macFromUnixArp(ip string) string {
|
||||
out, err := exec.Command("arp", "-n", ip).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
// macOS 格式: ? (IP) at MAC on en0 ifscope [ethernet]
|
||||
for i, f := range fields {
|
||||
if f == "("+ip+")" && i+2 < len(fields) {
|
||||
mac := fields[i+2]
|
||||
if mac != "" && mac != "ff:ff:ff:ff:ff:ff" {
|
||||
return strings.ToLower(mac)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// inc IP 递增
|
||||
func inc(ip net.IP) {
|
||||
for j := len(ip) - 1; j >= 0; j-- {
|
||||
ip[j]++
|
||||
if ip[j] > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
262
auto-check/pkg/report/report.go
Normal file
262
auto-check/pkg/report/report.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PhaseResult 单阶段结果
|
||||
type PhaseResult struct {
|
||||
Phase string `json:"phase" yaml:"phase"`
|
||||
Status string `json:"status" yaml:"status"` // success / partial / failed
|
||||
Hosts []HostResult `json:"hosts" yaml:"hosts"`
|
||||
Summary string `json:"summary" yaml:"summary"`
|
||||
}
|
||||
|
||||
// HostResult 单台主机结果
|
||||
type HostResult struct {
|
||||
IP string `json:"ip" yaml:"ip"`
|
||||
Alive bool `json:"alive" yaml:"alive"`
|
||||
OpenPort int `json:"open_port,omitempty" yaml:"open_port,omitempty"`
|
||||
SSHLogin bool `json:"ssh_login" yaml:"ssh_login"`
|
||||
SSHErr string `json:"ssh_error,omitempty" yaml:"ssh_error,omitempty"`
|
||||
Stress []StressResult `json:"stress,omitempty" yaml:"stress,omitempty"`
|
||||
}
|
||||
|
||||
// StressResult 单项压力测试结果
|
||||
type StressResult struct {
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Status string `json:"status" yaml:"status"` // pass / fail / skip
|
||||
Duration string `json:"duration" yaml:"duration"`
|
||||
Output string `json:"output,omitempty" yaml:"output,omitempty"`
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Report 检测报告
|
||||
type Report struct {
|
||||
StartTime time.Time `json:"start_time" yaml:"start_time"`
|
||||
EndTime time.Time `json:"end_time" yaml:"end_time"`
|
||||
Duration string `json:"duration" yaml:"duration"`
|
||||
Config ConfigSummary `json:"config" yaml:"config"`
|
||||
Phases []PhaseResult `json:"phases" yaml:"phases"`
|
||||
}
|
||||
|
||||
// ConfigSummary 配置摘要(脱敏)
|
||||
type ConfigSummary struct {
|
||||
CIDR string `json:"cidr" yaml:"cidr"`
|
||||
Port int `json:"port" yaml:"port"`
|
||||
User string `json:"user" yaml:"user"`
|
||||
Concurrency int `json:"concurrency" yaml:"concurrency"`
|
||||
StressTypes string `json:"stress_types,omitempty" yaml:"stress_types,omitempty"`
|
||||
}
|
||||
|
||||
// Builder 报告构建器
|
||||
type Builder struct {
|
||||
report *Report
|
||||
phase *PhaseResult
|
||||
}
|
||||
|
||||
// NewBuilder 创建报告构建器
|
||||
func NewBuilder(cfg ConfigSummary) *Builder {
|
||||
return &Builder{
|
||||
report: &Report{
|
||||
StartTime: time.Now(),
|
||||
Config: cfg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// StartPhase 开始新阶段
|
||||
func (b *Builder) StartPhase(name string) {
|
||||
b.phase = &PhaseResult{
|
||||
Phase: name,
|
||||
}
|
||||
}
|
||||
|
||||
// AddHost 添加主机结果到当前阶段
|
||||
func (b *Builder) AddHost(h HostResult) {
|
||||
if b.phase != nil {
|
||||
b.phase.Hosts = append(b.phase.Hosts, h)
|
||||
}
|
||||
}
|
||||
|
||||
// AddStressResult 为指定 IP 添加压力测试结果
|
||||
func (b *Builder) AddStressResult(ip string, sr StressResult) {
|
||||
if b.phase == nil {
|
||||
return
|
||||
}
|
||||
for i := range b.phase.Hosts {
|
||||
if b.phase.Hosts[i].IP == ip {
|
||||
b.phase.Hosts[i].Stress = append(b.phase.Hosts[i].Stress, sr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EndPhase 结束当前阶段,设置状态和摘要
|
||||
func (b *Builder) EndPhase(summary string) {
|
||||
if b.phase == nil {
|
||||
return
|
||||
}
|
||||
b.phase.Summary = summary
|
||||
|
||||
total := len(b.phase.Hosts)
|
||||
success := 0
|
||||
for _, h := range b.phase.Hosts {
|
||||
switch b.phase.Phase {
|
||||
case "设备发现":
|
||||
if h.Alive {
|
||||
success++
|
||||
}
|
||||
case "SSH 登录":
|
||||
if h.SSHLogin {
|
||||
success++
|
||||
}
|
||||
case "压力测试":
|
||||
if len(h.Stress) > 0 {
|
||||
success++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case success == total:
|
||||
b.phase.Status = "success"
|
||||
case success > 0:
|
||||
b.phase.Status = "partial"
|
||||
default:
|
||||
b.phase.Status = "failed"
|
||||
}
|
||||
|
||||
b.report.Phases = append(b.report.Phases, *b.phase)
|
||||
b.phase = nil
|
||||
}
|
||||
|
||||
// Build 生成最终报告
|
||||
func (b *Builder) Build() *Report {
|
||||
b.report.EndTime = time.Now()
|
||||
b.report.Duration = b.report.EndTime.Sub(b.report.StartTime).Round(time.Millisecond).String()
|
||||
return b.report
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 输出格式化
|
||||
// ============================
|
||||
|
||||
// ToText 输出纯文本报告
|
||||
func (r *Report) ToText() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("╔══════════════════════════════════════════════╗\n")
|
||||
sb.WriteString("║ auto-check 检测报告 ║\n")
|
||||
sb.WriteString("╚══════════════════════════════════════════════╝\n\n")
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" 生成时间: %s\n", r.StartTime.Format("2006-01-02 15:04:05")))
|
||||
sb.WriteString(fmt.Sprintf(" 总耗时: %s\n\n", r.Duration))
|
||||
|
||||
// 配置摘要
|
||||
sb.WriteString("── 配置摘要 ──\n")
|
||||
sb.WriteString(fmt.Sprintf(" 网段: %s\n", r.Config.CIDR))
|
||||
sb.WriteString(fmt.Sprintf(" SSH端口: %d\n", r.Config.Port))
|
||||
sb.WriteString(fmt.Sprintf(" SSH用户: %s\n", r.Config.User))
|
||||
sb.WriteString(fmt.Sprintf(" 并发数: %d\n", r.Config.Concurrency))
|
||||
if r.Config.StressTypes != "" {
|
||||
sb.WriteString(fmt.Sprintf(" 压测类型: %s\n", r.Config.StressTypes))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// 各阶段结果
|
||||
for _, phase := range r.Phases {
|
||||
statusIcon := "✅"
|
||||
switch phase.Status {
|
||||
case "partial":
|
||||
statusIcon = "⚠️"
|
||||
case "failed":
|
||||
statusIcon = "❌"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("══ %s %s ══\n", phase.Phase, statusIcon))
|
||||
sb.WriteString(fmt.Sprintf(" %s\n\n", phase.Summary))
|
||||
|
||||
if phase.Phase == "设备发现" || phase.Phase == "SSH 登录" {
|
||||
for _, h := range phase.Hosts {
|
||||
if phase.Phase == "设备发现" && !h.Alive {
|
||||
continue
|
||||
}
|
||||
if phase.Phase == "SSH 登录" && !h.SSHLogin {
|
||||
sb.WriteString(fmt.Sprintf(" ✗ %-15s %s\n", h.IP, h.SSHErr))
|
||||
continue
|
||||
}
|
||||
icon := "✓"
|
||||
if phase.Phase == "SSH 登录" {
|
||||
icon = "✓"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s %-15s\n", icon, h.IP))
|
||||
}
|
||||
}
|
||||
|
||||
if phase.Phase == "压力测试" {
|
||||
for _, h := range phase.Hosts {
|
||||
if len(h.Stress) == 0 {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" ┌─ %s\n", h.IP))
|
||||
for _, s := range h.Stress {
|
||||
icon := "✓"
|
||||
if s.Status != "pass" {
|
||||
icon = "✗"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" │ %s %-10s 耗时: %s\n", icon, s.Type, s.Duration))
|
||||
if s.Error != "" {
|
||||
sb.WriteString(fmt.Sprintf(" │ 错误: %s\n", s.Error))
|
||||
}
|
||||
}
|
||||
sb.WriteString(" └──────────────\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ToJSON 输出 JSON 报告
|
||||
func (r *Report) ToJSON() (string, error) {
|
||||
data, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// SaveFile 保存报告到文件
|
||||
func (r *Report) SaveFile(path string, format string) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
var content string
|
||||
switch format {
|
||||
case "json":
|
||||
var err error
|
||||
content, err = r.ToJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
content = r.ToText()
|
||||
}
|
||||
|
||||
return os.WriteFile(path, []byte(content), 0644)
|
||||
}
|
||||
|
||||
// DefaultReportPath 默认报告路径
|
||||
func DefaultReportPath() string {
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
return filepath.Join(".", "reports", fmt.Sprintf("report-%s.txt", timestamp))
|
||||
}
|
||||
93
auto-check/pkg/sshclient/sshclient.go
Normal file
93
auto-check/pkg/sshclient/sshclient.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package sshclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Client SSH 客户端封装
|
||||
type Client struct {
|
||||
User string
|
||||
Password string
|
||||
KeyFile string
|
||||
Port int
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewClient 创建 SSH 客户端
|
||||
func NewClient(user, password, keyFile string, port int, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
User: user,
|
||||
Password: password,
|
||||
KeyFile: keyFile,
|
||||
Port: port,
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect 尝试 SSH 连接,返回 SSH client
|
||||
func (c *Client) Connect(ip string) (*ssh.Client, error) {
|
||||
config, err := c.buildConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH 配置构建失败: %w", err)
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", ip, c.Port)
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH 连接 %s 失败: %w", addr, err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// RunCommand 在远程主机执行命令,返回输出
|
||||
func (c *Client) RunCommand(client *ssh.Client, command string) (string, error) {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建会话失败: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
out, err := session.CombinedOutput(command)
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("命令执行失败: %w, 输出: %s", err, string(out))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// buildConfig 构建 SSH 认证配置
|
||||
func (c *Client) buildConfig() (*ssh.ClientConfig, error) {
|
||||
var authMethods []ssh.AuthMethod
|
||||
|
||||
// 密码认证
|
||||
if c.Password != "" {
|
||||
authMethods = append(authMethods, ssh.Password(c.Password))
|
||||
}
|
||||
|
||||
// 密钥认证
|
||||
if c.KeyFile != "" {
|
||||
key, err := os.ReadFile(c.KeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取密钥文件失败: %w", err)
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析私钥失败: %w", err)
|
||||
}
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
}
|
||||
|
||||
if len(authMethods) == 0 {
|
||||
return nil, fmt.Errorf("未提供认证方式(密码或密钥)")
|
||||
}
|
||||
|
||||
return &ssh.ClientConfig{
|
||||
User: c.User,
|
||||
Auth: authMethods,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: c.Timeout,
|
||||
}, nil
|
||||
}
|
||||
141
auto-check/pkg/stress/runner.go
Normal file
141
auto-check/pkg/stress/runner.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package stress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Runner 压力测试调度器
|
||||
type Runner struct {
|
||||
Config Config
|
||||
Types []TestType
|
||||
Executor Executor
|
||||
}
|
||||
|
||||
// NewRunner 创建调度器
|
||||
func NewRunner(cfg Config, types []TestType, executor Executor) *Runner {
|
||||
return &Runner{
|
||||
Config: cfg,
|
||||
Types: types,
|
||||
Executor: executor,
|
||||
}
|
||||
}
|
||||
|
||||
// Run 执行压力测试(单台主机)
|
||||
func (r *Runner) Run(client *ssh.Client, ip string) *Report {
|
||||
report := &Report{IP: ip, StartTime: time.Now()}
|
||||
fmt.Printf("\n════════ [%s] 压力测试开始 ════════\n", ip)
|
||||
|
||||
// 系统信息探测
|
||||
info := ProbeSystem(client, r.Executor)
|
||||
fmt.Printf(" 主机: %s | CPU: %s (%s核) | 内存: %s | 内核: %s\n",
|
||||
info.Hostname, info.CPUModel, info.CPUCores, info.MemTotal, info.KernelVer)
|
||||
|
||||
// 启动温度监控
|
||||
tempMon := NewTempMonitor(client, r.Executor, r.Config.TempLogInt)
|
||||
tempMon.Start()
|
||||
|
||||
// 启动 dmesg 监控
|
||||
dmesgMon := NewDmesgMonitor(client, r.Executor)
|
||||
dmesgMon.Start()
|
||||
|
||||
// 逐项执行测试
|
||||
for _, t := range r.Types {
|
||||
fmt.Printf("\n ── 开始: %s ──\n", t)
|
||||
var res Result
|
||||
|
||||
switch t {
|
||||
case TestCPU:
|
||||
res = CPUStress(client, r.Executor, r.Config)
|
||||
case TestMemory:
|
||||
res = MemoryStress(client, r.Executor, r.Config)
|
||||
case TestDiskIO:
|
||||
res = DiskIOStress(client, r.Executor, r.Config)
|
||||
case TestMemNative:
|
||||
res = MemNativeStress(client, r.Executor, r.Config)
|
||||
case TestFull:
|
||||
res = FullStress(client, r.Executor, r.Config)
|
||||
default:
|
||||
res = Result{Type: t, Status: "skip", Error: fmt.Sprintf("未知测试类型: %s", t)}
|
||||
}
|
||||
|
||||
report.AddResult(res)
|
||||
fmt.Printf(" ── 完成: %s [%s] %s ──\n", t, res.Status, res.Duration.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// 停止监控,收集结果
|
||||
tempLogs := tempMon.Stop()
|
||||
dmesgErrors := dmesgMon.Stop()
|
||||
|
||||
// 添加温度报告
|
||||
if len(tempLogs) > 0 {
|
||||
maxTemp := ""
|
||||
for _, line := range tempLogs {
|
||||
// 提取温度值
|
||||
parts := strings.Fields(line)
|
||||
for _, p := range parts {
|
||||
if strings.HasSuffix(p, "°C") || strings.HasSuffix(p, "C") {
|
||||
maxTemp = p
|
||||
}
|
||||
}
|
||||
}
|
||||
tempSummary := fmt.Sprintf("采样 %d 次", len(tempLogs))
|
||||
if maxTemp != "" {
|
||||
tempSummary += ", 最高温度: " + maxTemp
|
||||
}
|
||||
status := "pass"
|
||||
if strings.Contains(strings.ToLower(strings.Join(tempLogs, " ")), "throttl") {
|
||||
status = "fail"
|
||||
tempSummary += " [检测到降频!]"
|
||||
}
|
||||
report.AddResult(Result{
|
||||
Type: MonitorTemp,
|
||||
Status: status,
|
||||
Output: tempSummary,
|
||||
Duration: time.Since(report.StartTime),
|
||||
})
|
||||
}
|
||||
|
||||
// 添加 dmesg 报告
|
||||
if len(dmesgErrors) > 0 {
|
||||
report.AddResult(Result{
|
||||
Type: MonitorDmesg,
|
||||
Status: "fail",
|
||||
Output: fmt.Sprintf("检测到 %d 条硬件相关报错:\n%s", len(dmesgErrors), strings.Join(dmesgErrors[:min(10, len(dmesgErrors))], "\n")),
|
||||
Duration: time.Since(report.StartTime),
|
||||
})
|
||||
} else {
|
||||
report.AddResult(Result{
|
||||
Type: MonitorDmesg,
|
||||
Status: "pass",
|
||||
Output: "无硬件相关内核报错",
|
||||
Duration: time.Since(report.StartTime),
|
||||
})
|
||||
}
|
||||
|
||||
report.EndTime = time.Now()
|
||||
report.Duration = report.EndTime.Sub(report.StartTime)
|
||||
|
||||
fmt.Printf("\n════════ [%s] 压力测试完成 ════════\n", ip)
|
||||
fmt.Println(report.ToText())
|
||||
return report
|
||||
}
|
||||
|
||||
// RunAll 对多台主机执行压力测试
|
||||
func (r *Runner) RunAll(clients map[string]*ssh.Client) []*Report {
|
||||
var reports []*Report
|
||||
for ip, client := range clients {
|
||||
reports = append(reports, r.Run(client, ip))
|
||||
}
|
||||
return reports
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
168
auto-check/pkg/stress/runners.go
Normal file
168
auto-check/pkg/stress/runners.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package stress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// truncate 截断过长输出
|
||||
func truncate(s string, maxLen int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > maxLen {
|
||||
return s[:maxLen] + "\n ... (输出已截断)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ============================
|
||||
// stress-ng 压测
|
||||
// ============================
|
||||
|
||||
// CPUStress stress-ng CPU 压力测试
|
||||
func CPUStress(client *ssh.Client, ex Executor, cfg Config) Result {
|
||||
start := time.Now()
|
||||
fmt.Printf(" [CPU] stress-ng CPU 压力 (%d线程, %v)...\n", cfg.Threads, cfg.Duration)
|
||||
|
||||
info, err := EnsureTool(client, ex, "stress-ng", "apt install stress-ng / opkg install stress-ng")
|
||||
if err != nil {
|
||||
return Result{Type: TestCPU, Status: "skip", Error: err.Error(), Duration: time.Since(start)}
|
||||
}
|
||||
fmt.Printf(" [CPU] 使用 %s (%s)\n", info.Path, info.Version)
|
||||
|
||||
cmd := fmt.Sprintf("%s --cpu %d --cpu-method all --timeout %v --metrics-brief --temp-path /tmp 2>&1",
|
||||
info.Path, cfg.Threads, cfg.Duration)
|
||||
out, err := exec(client, ex, cmd)
|
||||
duration := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
return Result{Type: TestCPU, Status: "error", Output: truncate(out, 600), Error: err.Error(), Duration: duration}
|
||||
}
|
||||
return Result{Type: TestCPU, Status: "pass", Output: truncate(out, 600), Duration: duration}
|
||||
}
|
||||
|
||||
// MemoryStress stress-ng 内存压力测试
|
||||
func MemoryStress(client *ssh.Client, ex Executor, cfg Config) Result {
|
||||
start := time.Now()
|
||||
fmt.Printf(" [Memory] stress-ng 内存压力 (%d线程, %v)...\n", cfg.Threads, cfg.Duration)
|
||||
|
||||
info, err := EnsureTool(client, ex, "stress-ng", "apt install stress-ng / opkg install stress-ng")
|
||||
if err != nil {
|
||||
return Result{Type: TestMemory, Status: "skip", Error: err.Error(), Duration: time.Since(start)}
|
||||
}
|
||||
|
||||
memWorkers := cfg.Threads
|
||||
if memWorkers > 4 {
|
||||
memWorkers = 4
|
||||
}
|
||||
cmd := fmt.Sprintf("%s --vm %d --vm-bytes 256M --vm-method all --timeout %v --metrics-brief 2>&1",
|
||||
info.Path, memWorkers, cfg.Duration)
|
||||
out, err := exec(client, ex, cmd)
|
||||
duration := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
return Result{Type: TestMemory, Status: "error", Output: truncate(out, 600), Error: err.Error(), Duration: duration}
|
||||
}
|
||||
return Result{Type: TestMemory, Status: "pass", Output: truncate(out, 600), Duration: duration}
|
||||
}
|
||||
|
||||
// DiskIOStress stress-ng 磁盘IO压力测试
|
||||
func DiskIOStress(client *ssh.Client, ex Executor, cfg Config) Result {
|
||||
start := time.Now()
|
||||
fmt.Printf(" [DiskIO] stress-ng 磁盘IO压力 (%v)...\n", cfg.Duration)
|
||||
|
||||
info, err := EnsureTool(client, ex, "stress-ng", "apt install stress-ng / opkg install stress-ng")
|
||||
if err != nil {
|
||||
return Result{Type: TestDiskIO, Status: "skip", Error: err.Error(), Duration: time.Since(start)}
|
||||
}
|
||||
|
||||
testDir := cfg.DiskDir
|
||||
if testDir == "" {
|
||||
testDir = "/tmp/stress-disk-test"
|
||||
}
|
||||
_, _ = exec(client, ex, fmt.Sprintf("mkdir -p %s", testDir))
|
||||
|
||||
cmd := fmt.Sprintf("%s --iomix 2 --iomix-bytes %dM --timeout %v --metrics-brief 2>&1",
|
||||
info.Path, cfg.DiskSizeMB, cfg.Duration)
|
||||
out, err := exec(client, ex, cmd)
|
||||
duration := time.Since(start)
|
||||
|
||||
_, _ = exec(client, ex, fmt.Sprintf("rm -rf %s", testDir))
|
||||
|
||||
if err != nil {
|
||||
return Result{Type: TestDiskIO, Status: "error", Output: truncate(out, 600), Error: err.Error(), Duration: duration}
|
||||
}
|
||||
return Result{Type: TestDiskIO, Status: "pass", Output: truncate(out, 600), Duration: duration}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// stressapptest 内存精压
|
||||
// ============================
|
||||
|
||||
// MemNativeStress stressapptest 内存稳定性精压
|
||||
func MemNativeStress(client *ssh.Client, ex Executor, cfg Config) Result {
|
||||
start := time.Now()
|
||||
fmt.Printf(" [MemNative] stressapptest 内存精压 (%v)...\n", cfg.Duration)
|
||||
|
||||
info, err := EnsureTool(client, ex, "stressapptest", "apt install stressapptest / opkg install stressapptest")
|
||||
if err != nil {
|
||||
return Result{Type: TestMemNative, Status: "skip", Error: err.Error(), Duration: time.Since(start)}
|
||||
}
|
||||
fmt.Printf(" [MemNative] 使用 %s\n", info.Path)
|
||||
|
||||
memSize := cfg.MemSizeMB
|
||||
if memSize <= 0 {
|
||||
out, _ := exec(client, ex, "free -m 2>/dev/null | awk '/Mem:/{print int($7*0.6)}'")
|
||||
out = strings.TrimSpace(out)
|
||||
if out != "" {
|
||||
fmt.Sscanf(out, "%d", &memSize)
|
||||
}
|
||||
if memSize <= 0 {
|
||||
memSize = 512
|
||||
}
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("%s -s %d -M %d -f 0 -v 2>&1",
|
||||
info.Path, int(cfg.Duration.Seconds()), memSize)
|
||||
out, err := exec(client, ex, cmd)
|
||||
duration := time.Since(start)
|
||||
|
||||
output := truncate(out, 800)
|
||||
if strings.Contains(strings.ToUpper(output), "PASS") {
|
||||
return Result{Type: TestMemNative, Status: "pass", Output: output, Duration: duration}
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(output), "FAIL") {
|
||||
return Result{Type: TestMemNative, Status: "fail", Output: output, Duration: duration}
|
||||
}
|
||||
if err != nil {
|
||||
return Result{Type: TestMemNative, Status: "error", Output: output, Error: err.Error(), Duration: duration}
|
||||
}
|
||||
return Result{Type: TestMemNative, Status: "pass", Output: output, Duration: duration}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 综合压测
|
||||
// ============================
|
||||
|
||||
// FullStress 三合一综合压测 (CPU + 内存 + 磁盘IO 同时进行)
|
||||
func FullStress(client *ssh.Client, ex Executor, cfg Config) Result {
|
||||
start := time.Now()
|
||||
fmt.Printf(" [Full] 三合一综合压测 (CPU %d线程 + 内存 + 磁盘IO, %v)...\n", cfg.Threads, cfg.Duration)
|
||||
|
||||
info, err := EnsureTool(client, ex, "stress-ng", "apt install stress-ng / opkg install stress-ng")
|
||||
if err != nil {
|
||||
return Result{Type: TestFull, Status: "skip", Error: err.Error(), Duration: time.Since(start)}
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("%s --cpu %d --vm 2 --vm-bytes 128M --iomix 1 --iomix-bytes 256M --timeout %v --metrics-brief 2>&1",
|
||||
info.Path, cfg.Threads, cfg.Duration)
|
||||
out, err := exec(client, ex, cmd)
|
||||
duration := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
return Result{Type: TestFull, Status: "error", Output: truncate(out, 600), Error: err.Error(), Duration: duration}
|
||||
}
|
||||
return Result{Type: TestFull, Status: "pass", Output: truncate(out, 600), Duration: duration}
|
||||
}
|
||||
269
auto-check/pkg/stress/tools.go
Normal file
269
auto-check/pkg/stress/tools.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package stress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// ============================
|
||||
// 远程命令执行接口
|
||||
// ============================
|
||||
|
||||
// Executor 远程命令执行器
|
||||
type Executor interface {
|
||||
RunCommand(client *ssh.Client, command string) (string, error)
|
||||
}
|
||||
|
||||
// exec 执行远程命令(带超时)
|
||||
func exec(client *ssh.Client, executor Executor, cmd string) (string, error) {
|
||||
return executor.RunCommand(client, cmd)
|
||||
}
|
||||
|
||||
// execf 格式化执行
|
||||
func execf(client *ssh.Client, executor Executor, format string, args ...interface{}) (string, error) {
|
||||
return exec(client, executor, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 工具检测
|
||||
// ============================
|
||||
|
||||
// ToolInfo 远程工具信息
|
||||
type ToolInfo struct {
|
||||
Available bool
|
||||
Path string
|
||||
Version string
|
||||
}
|
||||
|
||||
// DetectTool 检测远程工具是否可用
|
||||
func DetectTool(client *ssh.Client, executor Executor, name string) ToolInfo {
|
||||
// 检查路径
|
||||
out, err := execf(client, executor, "which %s 2>/dev/null", name)
|
||||
path := strings.TrimSpace(out)
|
||||
if err != nil || path == "" {
|
||||
return ToolInfo{Available: false}
|
||||
}
|
||||
|
||||
// 获取版本
|
||||
var version string
|
||||
switch name {
|
||||
case "stress-ng":
|
||||
out, _ = execf(client, executor, "%s --version 2>&1 | head -1", path)
|
||||
version = strings.TrimSpace(out)
|
||||
case "stressapptest":
|
||||
out, _ = execf(client, executor, "%s --help 2>&1 | head -1", path)
|
||||
version = strings.TrimSpace(out)
|
||||
case "lm-sensors":
|
||||
out, _ = execf(client, executor, "%s -v 2>&1 | head -1", path)
|
||||
version = strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
return ToolInfo{Available: true, Path: path, Version: version}
|
||||
}
|
||||
|
||||
// EnsureTool 检测工具,不可用则返回错误提示
|
||||
func EnsureTool(client *ssh.Client, executor Executor, name string, installHint string) (ToolInfo, error) {
|
||||
info := DetectTool(client, executor, name)
|
||||
if !info.Available {
|
||||
return info, fmt.Errorf("%s 未安装。安装方式: %s", name, installHint)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 环境探测
|
||||
// ============================
|
||||
|
||||
// SystemInfo 系统基础信息
|
||||
type SystemInfo struct {
|
||||
Hostname string
|
||||
CPUModel string
|
||||
CPUCores string
|
||||
MemTotal string
|
||||
KernelVer string
|
||||
DiskInfo string
|
||||
}
|
||||
|
||||
// ProbeSystem 探测远程系统基础信息
|
||||
func ProbeSystem(client *ssh.Client, executor Executor) SystemInfo {
|
||||
info := SystemInfo{}
|
||||
|
||||
out, _ := exec(client, executor, "hostname 2>/dev/null")
|
||||
info.Hostname = strings.TrimSpace(out)
|
||||
|
||||
out, _ = exec(client, executor, "lscpu 2>/dev/null | grep 'Model name' | sed 's/Model name:\\s*//'")
|
||||
info.CPUModel = strings.TrimSpace(out)
|
||||
|
||||
out, _ = exec(client, executor, "nproc 2>/dev/null")
|
||||
info.CPUCores = strings.TrimSpace(out)
|
||||
|
||||
out, _ = exec(client, executor, "free -h 2>/dev/null | awk '/Mem:/{print $2}'")
|
||||
info.MemTotal = strings.TrimSpace(out)
|
||||
|
||||
out, _ = exec(client, executor, "uname -r 2>/dev/null")
|
||||
info.KernelVer = strings.TrimSpace(out)
|
||||
|
||||
out, _ = exec(client, executor, "lsblk -d -o NAME,SIZE,TYPE 2>/dev/null | head -5")
|
||||
info.DiskInfo = strings.TrimSpace(out)
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 温度监控
|
||||
// ============================
|
||||
|
||||
// TempMonitor 温度监控器
|
||||
type TempMonitor struct {
|
||||
client *ssh.Client
|
||||
executor Executor
|
||||
interval time.Duration
|
||||
stopCh chan struct{}
|
||||
logs []string
|
||||
}
|
||||
|
||||
// NewTempMonitor 创建温度监控器
|
||||
func NewTempMonitor(client *ssh.Client, executor Executor, interval time.Duration) *TempMonitor {
|
||||
if interval <= 0 {
|
||||
interval = 10 * time.Second
|
||||
}
|
||||
return &TempMonitor{
|
||||
client: client,
|
||||
executor: executor,
|
||||
interval: interval,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 后台启动温度监控
|
||||
func (m *TempMonitor) Start() {
|
||||
go m.loop()
|
||||
}
|
||||
|
||||
// Stop 停止监控并返回所有采样
|
||||
func (m *TempMonitor) Stop() []string {
|
||||
close(m.stopCh)
|
||||
return m.logs
|
||||
}
|
||||
|
||||
func (m *TempMonitor) loop() {
|
||||
ticker := time.NewTicker(m.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 初始温度
|
||||
m.sample()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.sample()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *TempMonitor) sample() {
|
||||
// 尝试 sensors
|
||||
out, err := execf(m.client, m.executor, "sensors 2>/dev/null | grep -i 'temp\\|core\\|cpu' | head -10")
|
||||
if err == nil && strings.TrimSpace(out) != "" {
|
||||
ts := time.Now().Format("15:04:05")
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
m.logs = append(m.logs, fmt.Sprintf("[%s] %s", ts, line))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 回退: 读 sysfs
|
||||
out, _ = execf(m.client, m.executor, `cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | while read t; do echo "$((t/1000))°C"; done`)
|
||||
if strings.TrimSpace(out) != "" {
|
||||
ts := time.Now().Format("15:04:05")
|
||||
m.logs = append(m.logs, fmt.Sprintf("[%s] %s", ts, strings.TrimSpace(out)))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// dmesg 监控
|
||||
// ============================
|
||||
|
||||
// DmesgMonitor 内核日志监控
|
||||
type DmesgMonitor struct {
|
||||
client *ssh.Client
|
||||
executor Executor
|
||||
stopCh chan struct{}
|
||||
baseline string // 启动时的 dmesg 行数
|
||||
logs []string
|
||||
}
|
||||
|
||||
// NewDmesgMonitor 创建 dmesg 监控器
|
||||
func NewDmesgMonitor(client *ssh.Client, executor Executor) *DmesgMonitor {
|
||||
return &DmesgMonitor{
|
||||
client: client,
|
||||
executor: executor,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 记录基线,后台监控
|
||||
func (m *DmesgMonitor) Start() {
|
||||
out, _ := exec(m.client, m.executor, "dmesg 2>/dev/null | wc -l")
|
||||
m.baseline = strings.TrimSpace(out)
|
||||
go m.loop()
|
||||
}
|
||||
|
||||
// Stop 停止监控,返回新增的硬件报错
|
||||
func (m *DmesgMonitor) Stop() []string {
|
||||
close(m.stopCh)
|
||||
|
||||
// 获取新增的 dmesg 日志中的硬件错误
|
||||
out, _ := execf(m.client, m.executor,
|
||||
`dmesg --level=err,crit,alert,emerg 2>/dev/null | tail -30`)
|
||||
if strings.TrimSpace(out) != "" {
|
||||
m.logs = append(m.logs, strings.Split(out, "\n")...)
|
||||
}
|
||||
|
||||
// 过滤硬件相关关键词
|
||||
var hwErrors []string
|
||||
keywords := []string{"error", "fail", "fault", "warn", "critical", "oom", "panic", "hardware", "thermal", "throttl"}
|
||||
for _, line := range m.logs {
|
||||
lower := strings.ToLower(line)
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(lower, kw) {
|
||||
hwErrors = append(hwErrors, strings.TrimSpace(line))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return hwErrors
|
||||
}
|
||||
|
||||
func (m *DmesgMonitor) loop() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
// 周期性检查是否有新的硬件相关日志(轻量级)
|
||||
out, _ := execf(m.client, m.executor,
|
||||
`dmesg --level=err,crit 2>/dev/null | tail -3`)
|
||||
if strings.TrimSpace(out) != "" {
|
||||
ts := time.Now().Format("15:04:05")
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
m.logs = append(m.logs, fmt.Sprintf("[%s] %s", ts, line))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
139
auto-check/pkg/stress/types.go
Normal file
139
auto-check/pkg/stress/types.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package stress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================
|
||||
// 测试类型定义
|
||||
// ============================
|
||||
|
||||
// TestType 压力测试类型
|
||||
type TestType string
|
||||
|
||||
const (
|
||||
// 单项压测
|
||||
TestCPU TestType = "cpu" // stress-ng CPU 压力
|
||||
TestMemory TestType = "memory" // stress-ng 内存压力
|
||||
TestDiskIO TestType = "disk" // stress-ng 磁盘IO压力
|
||||
TestMemNative TestType = "memnative" // stressapptest 内存精压
|
||||
|
||||
// 综合压测
|
||||
TestFull TestType = "full" // CPU+内存+磁盘IO 三合一
|
||||
|
||||
// 监控(自动附加)
|
||||
MonitorTemp TestType = "temp" // lm-sensors 温度监控
|
||||
MonitorDmesg TestType = "dmesg" // dmesg 内核报错监控
|
||||
)
|
||||
|
||||
// ============================
|
||||
// 配置
|
||||
// ============================
|
||||
|
||||
// Config 压力测试配置
|
||||
type Config struct {
|
||||
Duration time.Duration // 总持续时间
|
||||
Threads int // CPU/内存线程数
|
||||
MemSizeMB int // 内存测试大小 (MB),0=自动(可用60%)
|
||||
DiskSizeMB int // 磁盘测试大小 (MB),0=默认1024
|
||||
DiskDir string // 磁盘测试目录,空=自动临时目录
|
||||
TempLogInt time.Duration // 温度采样间隔,0=10s
|
||||
}
|
||||
|
||||
// DefaultConfig 默认配置
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Duration: 30 * time.Second,
|
||||
Threads: 4,
|
||||
MemSizeMB: 0, // 自动
|
||||
DiskSizeMB: 1024,
|
||||
DiskDir: "",
|
||||
TempLogInt: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 单项测试结果
|
||||
// ============================
|
||||
|
||||
// Result 单项测试结果
|
||||
type Result struct {
|
||||
Type TestType
|
||||
Status string // "pass" / "fail" / "skip" / "error"
|
||||
Output string // 工具输出摘要
|
||||
Error string // 错误信息
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 完整测试报告
|
||||
// ============================
|
||||
|
||||
// Report 一次完整压测的报告
|
||||
type Report struct {
|
||||
IP string
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
Duration time.Duration
|
||||
Results []Result
|
||||
Passed int
|
||||
Failed int
|
||||
Skipped int
|
||||
Errors int
|
||||
}
|
||||
|
||||
// AddResult 添加测试结果
|
||||
func (r *Report) AddResult(res Result) {
|
||||
r.Results = append(r.Results, res)
|
||||
switch res.Status {
|
||||
case "pass":
|
||||
r.Passed++
|
||||
case "fail":
|
||||
r.Failed++
|
||||
case "skip":
|
||||
r.Skipped++
|
||||
case "error":
|
||||
r.Errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Summary 汇总信息
|
||||
func (r *Report) Summary() string {
|
||||
return fmt.Sprintf("共 %d 项: %d 通过, %d 失败, %d 跳过, %d 错误",
|
||||
len(r.Results), r.Passed, r.Failed, r.Skipped, r.Errors)
|
||||
}
|
||||
|
||||
// ToText 文本格式报告
|
||||
func (r *Report) ToText() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("══ [%s] 压力测试报告 ══\n", r.IP))
|
||||
sb.WriteString(fmt.Sprintf(" 耗时: %s\n\n", r.Duration.Round(time.Millisecond)))
|
||||
|
||||
for _, res := range r.Results {
|
||||
icon := "✓"
|
||||
switch res.Status {
|
||||
case "fail":
|
||||
icon = "✗"
|
||||
case "skip":
|
||||
icon = "⊘"
|
||||
case "error":
|
||||
icon = "⚠"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s %-12s %s\n", icon, res.Type, res.Status))
|
||||
if res.Output != "" {
|
||||
for _, line := range strings.Split(res.Output, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", line))
|
||||
}
|
||||
}
|
||||
}
|
||||
if res.Error != "" {
|
||||
sb.WriteString(fmt.Sprintf(" 错误: %s\n", res.Error))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\n %s\n", r.Summary()))
|
||||
return sb.String()
|
||||
}
|
||||
279
auto-check/pkg/workflow/loop.go
Normal file
279
auto-check/pkg/workflow/loop.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"auto-check/pkg/discovery"
|
||||
"auto-check/pkg/report"
|
||||
"auto-check/pkg/sshclient"
|
||||
"auto-check/pkg/stress"
|
||||
)
|
||||
|
||||
// ============================
|
||||
// 状态常量
|
||||
// ============================
|
||||
|
||||
const (
|
||||
StatusUntested = "untested" // 未测试
|
||||
StatusPass = "pass" // 通过
|
||||
StatusFail = "fail" // 失败
|
||||
)
|
||||
|
||||
// ============================
|
||||
// 设备(MAC 为唯一标识)
|
||||
// ============================
|
||||
|
||||
// Device 设备信息
|
||||
type Device struct {
|
||||
MAC string
|
||||
IP string
|
||||
Host discovery.Host
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 永久循环工作流
|
||||
// ============================
|
||||
|
||||
// Loop 永久循环:每间隔执行一轮
|
||||
// 每轮: 扫描设备 → 增量测试 → 打印状态变化
|
||||
func Loop(ctx *Context) error {
|
||||
interval := ctx.Config.Workflow.Interval
|
||||
if interval <= 0 {
|
||||
interval = 10 * time.Second
|
||||
}
|
||||
|
||||
fmt.Printf("══ 工作流启动(永久循环,间隔 %v,设备以 MAC 唯一标识)══\n\n", interval)
|
||||
|
||||
// 第一轮立即执行
|
||||
round(ctx)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Println("══ 工作流已停止 ══")
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
round(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// round 执行一轮
|
||||
func round(ctx *Context) {
|
||||
now := time.Now().Format("15:04:05")
|
||||
fmt.Printf("════ 轮次开始 [%s] ════\n", now)
|
||||
|
||||
// ── 1. 扫描设备,更新设备列表 map(MAC 为 key)──
|
||||
scanDevices(ctx)
|
||||
|
||||
// ── 2. 遍历设备: 结果为空或失败 → 开始测试,保存报告和结果 ──
|
||||
testPending(ctx)
|
||||
|
||||
// ── 3. 遍历打印: 状态变化才重新打印 ──
|
||||
printStatus(ctx)
|
||||
}
|
||||
|
||||
// scanDevices 扫描并更新设备列表
|
||||
// 设备身份 = MAC;IP 变化视为同一设备(更新 IP),IP 被复用则移除旧设备
|
||||
func scanDevices(ctx *Context) {
|
||||
scanner := discovery.NewScanner(
|
||||
ctx.Config.Scan.CIDR,
|
||||
ctx.Config.SSH.Port,
|
||||
ctx.Config.Scan.Timeout,
|
||||
ctx.Config.Scan.Concurrency,
|
||||
)
|
||||
hosts := scanner.Scan()
|
||||
|
||||
// 本轮扫描到的 IP → MAC 映射
|
||||
ipToMAC := make(map[string]string, len(hosts))
|
||||
for _, h := range hosts {
|
||||
ipToMAC[h.IP.String()] = h.MAC
|
||||
}
|
||||
|
||||
// 1. 处理 IP 被复用:旧设备的 IP 现在属于不同 MAC → 移除
|
||||
for mac, dev := range ctx.Devices {
|
||||
if newMAC, ok := ipToMAC[dev.IP]; ok && newMAC != mac {
|
||||
delete(ctx.Devices, mac)
|
||||
delete(ctx.Results, mac)
|
||||
delete(ctx.Printed, mac)
|
||||
fmt.Printf(" [-] IP 复用: %s 现属于 %s,移除旧设备 %s\n", dev.IP, newMAC, mac)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 合并扫描结果
|
||||
added, updated := 0, 0
|
||||
for _, h := range hosts {
|
||||
mac := h.MAC
|
||||
ip := h.IP.String()
|
||||
if mac == "" {
|
||||
// 无法获取 MAC:降级用 IP 做 key,标记为临时
|
||||
mac = "tmp:" + ip
|
||||
}
|
||||
|
||||
if dev, ok := ctx.Devices[mac]; ok {
|
||||
// 已存在:仅更新 IP(设备可能换了地址)
|
||||
if dev.IP != ip {
|
||||
dev.IP = ip
|
||||
dev.Host = h
|
||||
updated++
|
||||
}
|
||||
} else {
|
||||
// 新设备
|
||||
ctx.Devices[mac] = &Device{MAC: mac, IP: ip, Host: h}
|
||||
added++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" [扫描] 设备 %d 台(新增 %d,IP更新 %d)\n", len(ctx.Devices), added, updated)
|
||||
}
|
||||
|
||||
// testPending 对未测试或失败设备执行测试
|
||||
func testPending(ctx *Context) {
|
||||
for mac, dev := range ctx.Devices {
|
||||
// 跳过: 已有结果且通过
|
||||
if rpt, ok := ctx.Results[mac]; ok && rpt != nil && rpt.Failed == 0 && rpt.Errors == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 需要测试(结果为空 或 失败)
|
||||
fmt.Printf(" [测试] %s (%s) 开始压测...\n", dev.IP, dev.MAC)
|
||||
rpt := testDevice(ctx, dev.IP)
|
||||
ctx.Results[mac] = rpt
|
||||
|
||||
// 保存测试报告
|
||||
saveDeviceReport(dev, rpt, ctx.Config.Report.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// testDevice 对单台设备执行 SSH 登录 + 压力测试
|
||||
func testDevice(ctx *Context, ip string) *stress.Report {
|
||||
cfg := ctx.Config
|
||||
|
||||
// SSH 连接
|
||||
client := sshclient.NewClient(
|
||||
cfg.SSH.User, cfg.SSH.Password, cfg.SSH.KeyFile,
|
||||
cfg.SSH.Port, cfg.Scan.Timeout,
|
||||
)
|
||||
conn, err := client.Connect(ip)
|
||||
if err != nil {
|
||||
fmt.Printf(" [测试] %s SSH 连接失败: %v\n", ip, err)
|
||||
return &stress.Report{
|
||||
IP: ip,
|
||||
StartTime: time.Now(),
|
||||
Results: []stress.Result{{
|
||||
Type: "ssh",
|
||||
Status: "fail",
|
||||
Error: err.Error(),
|
||||
}},
|
||||
Failed: 1,
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 执行压力测试
|
||||
runner := stress.NewRunner(
|
||||
stress.Config{
|
||||
Duration: cfg.Stress.Duration,
|
||||
Threads: cfg.Stress.Threads,
|
||||
DiskSizeMB: 1024,
|
||||
TempLogInt: 10 * time.Second,
|
||||
},
|
||||
mapStressTypes(cfg.Stress.Types),
|
||||
client,
|
||||
)
|
||||
return runner.Run(conn, ip)
|
||||
}
|
||||
|
||||
// printStatus 打印状态变化(仅当首次或状态变化时)
|
||||
func printStatus(ctx *Context) {
|
||||
for mac, rpt := range ctx.Results {
|
||||
dev := ctx.Devices[mac]
|
||||
label := devLabel(dev)
|
||||
status := statusOf(rpt)
|
||||
// 上次状态为空 或 与当前不一致 → 打印并记录
|
||||
if prev, ok := ctx.Printed[mac]; !ok || prev != status {
|
||||
icon := map[string]string{
|
||||
StatusPass: "✓",
|
||||
StatusFail: "✗",
|
||||
}[status]
|
||||
fmt.Printf(" [状态] %s %s → %s\n", label, icon, status)
|
||||
ctx.Printed[mac] = status
|
||||
}
|
||||
}
|
||||
|
||||
// 未测试设备(无结果): 首次打印一次 untested
|
||||
for mac, dev := range ctx.Devices {
|
||||
if _, hasResult := ctx.Results[mac]; !hasResult {
|
||||
if _, printed := ctx.Printed[mac]; !printed {
|
||||
fmt.Printf(" [状态] %s ○ → %s\n", devLabel(dev), StatusUntested)
|
||||
ctx.Printed[mac] = StatusUntested
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// devLabel 设备显示名: MAC (IP)
|
||||
func devLabel(dev *Device) string {
|
||||
if dev == nil {
|
||||
return "?"
|
||||
}
|
||||
if strings.HasPrefix(dev.MAC, "tmp:") {
|
||||
return dev.IP + " (无MAC)"
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", dev.MAC, dev.IP)
|
||||
}
|
||||
|
||||
// mapStressTypes 逗号分隔字符串 → TestType 列表
|
||||
func mapStressTypes(s string) []stress.TestType {
|
||||
var types []stress.TestType
|
||||
for _, t := range strings.Split(s, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
types = append(types, stress.TestType(t))
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// statusOf 从测试报告得出状态
|
||||
func statusOf(rpt *stress.Report) string {
|
||||
if rpt == nil {
|
||||
return StatusUntested
|
||||
}
|
||||
if rpt.Failed > 0 || rpt.Errors > 0 {
|
||||
return StatusFail
|
||||
}
|
||||
return StatusPass
|
||||
}
|
||||
|
||||
// saveDeviceReport 保存单台设备的测试报告(文件名含 MAC 和 IP)
|
||||
func saveDeviceReport(dev *Device, rpt *stress.Report, dir string) {
|
||||
if dir == "" {
|
||||
dir = "reports"
|
||||
}
|
||||
ts := time.Now().Format("20060102-150405")
|
||||
name := strings.ReplaceAll(dev.MAC, ":", "")
|
||||
path := fmt.Sprintf("%s/report-%s-%s-%s.txt", strings.TrimRight(dir, "/"), name, dev.IP, ts)
|
||||
|
||||
r := report.NewBuilder(report.ConfigSummary{})
|
||||
r.StartPhase("压力测试")
|
||||
for _, res := range rpt.Results {
|
||||
r.AddStressResult(dev.IP, report.StressResult{
|
||||
Type: string(res.Type),
|
||||
Status: res.Status,
|
||||
Duration: res.Duration.String(),
|
||||
Error: res.Error,
|
||||
Output: res.Output,
|
||||
})
|
||||
}
|
||||
r.EndPhase(rpt.Summary())
|
||||
|
||||
if err := r.Build().SaveFile(path, "text"); err != nil {
|
||||
fmt.Printf(" [报告] %s 保存失败: %v\n", dev.IP, err)
|
||||
} else {
|
||||
fmt.Printf(" [报告] %s 已保存: %s\n", dev.IP, path)
|
||||
}
|
||||
}
|
||||
166
auto-check/pkg/workflow/workflow.go
Normal file
166
auto-check/pkg/workflow/workflow.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"auto-check/pkg/config"
|
||||
"auto-check/pkg/stress"
|
||||
)
|
||||
|
||||
// ============================
|
||||
// Context — 阶段间共享数据
|
||||
// ============================
|
||||
|
||||
// Context 工作流上下文,保存阶段间传递的数据
|
||||
type Context struct {
|
||||
base context.Context // 可取消上下文
|
||||
|
||||
// 配置(由入口设置)
|
||||
Config config.Config
|
||||
|
||||
// 循环模式下的状态数据(key 均为设备 MAC)
|
||||
Devices map[string]*Device // 设备列表(MAC → 设备)
|
||||
Results map[string]*stress.Report // 测试结果(nil/失败=需测试)
|
||||
Printed map[string]string // 上次打印的测试状态
|
||||
}
|
||||
|
||||
// NewContext 创建上下文
|
||||
func NewContext() *Context {
|
||||
return &Context{
|
||||
base: context.Background(),
|
||||
Devices: make(map[string]*Device),
|
||||
Results: make(map[string]*stress.Report),
|
||||
Printed: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// WithCancel 支持取消
|
||||
func (c *Context) WithCancel() (context.CancelFunc, error) {
|
||||
ctx, cancel := context.WithCancel(c.base)
|
||||
c.base = ctx
|
||||
return cancel, nil
|
||||
}
|
||||
|
||||
// Done 返回取消信号
|
||||
func (c *Context) Done() <-chan struct{} {
|
||||
return c.base.Done()
|
||||
}
|
||||
|
||||
// Err 返回取消原因
|
||||
func (c *Context) Err() error {
|
||||
return c.base.Err()
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Stage — 阶段接口
|
||||
// ============================
|
||||
|
||||
// Stage 工作流阶段
|
||||
type Stage interface {
|
||||
Name() string
|
||||
Execute(ctx *Context) error
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Workflow — 流水线调度器
|
||||
// ============================
|
||||
|
||||
// OnStageError 阶段失败处理策略
|
||||
type OnStageError int
|
||||
|
||||
const (
|
||||
// StopOnError 失败即中断整个工作流
|
||||
StopOnError OnStageError = iota
|
||||
// ContinueOnError 失败继续执行后续阶段
|
||||
ContinueOnError
|
||||
)
|
||||
|
||||
// StageResult 阶段执行结果
|
||||
type StageResult struct {
|
||||
Stage string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Duration time.Duration
|
||||
Err error
|
||||
}
|
||||
|
||||
// Workflow Pipeline 工作流
|
||||
type Workflow struct {
|
||||
stages []Stage
|
||||
onError OnStageError
|
||||
results []StageResult
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
// New 创建空工作流
|
||||
func New() *Workflow {
|
||||
return &Workflow{
|
||||
onError: StopOnError,
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnError 设置失败策略
|
||||
func (w *Workflow) SetOnError(policy OnStageError) *Workflow {
|
||||
w.onError = policy
|
||||
return w
|
||||
}
|
||||
|
||||
// AddStage 追加阶段(串行)
|
||||
func (w *Workflow) AddStage(stage Stage) *Workflow {
|
||||
w.stages = append(w.stages, stage)
|
||||
return w
|
||||
}
|
||||
|
||||
// Run 依次执行所有阶段
|
||||
func (w *Workflow) Run(ctx *Context) error {
|
||||
w.startTime = time.Now()
|
||||
fmt.Printf("══ 工作流启动 (%d 个阶段) ══\n\n", len(w.stages))
|
||||
|
||||
for i, stage := range w.stages {
|
||||
// 检查是否被取消
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Printf(" [!] 工作流已取消,停止于阶段 %d/%d\n", i+1, len(w.stages))
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
fmt.Printf("════ 阶段 %d/%d: %s ════\n", i+1, len(w.stages), stage.Name())
|
||||
start := time.Now()
|
||||
err := stage.Execute(ctx)
|
||||
duration := time.Since(start)
|
||||
|
||||
result := StageResult{
|
||||
Stage: stage.Name(),
|
||||
Start: start,
|
||||
End: time.Now(),
|
||||
Duration: duration,
|
||||
Err: err,
|
||||
}
|
||||
w.results = append(w.results, result)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf(" [✗] 阶段失败: %v\n", err)
|
||||
switch w.onError {
|
||||
case StopOnError:
|
||||
fmt.Printf("══ 工作流中止(策略: 失败即停止)══\n")
|
||||
return err
|
||||
case ContinueOnError:
|
||||
fmt.Printf(" [!] 继续执行后续阶段(策略: 失败继续)\n\n")
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" [✓] 阶段完成 (%v)\n\n", duration.Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("══ 工作流完成,总耗时 %v ══\n", time.Since(w.startTime).Round(time.Millisecond))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Results 阶段执行结果列表
|
||||
func (w *Workflow) Results() []StageResult {
|
||||
return w.results
|
||||
}
|
||||
Reference in New Issue
Block a user