diff --git a/auto-check/.gitignore b/auto-check/.gitignore new file mode 100644 index 0000000..a5a40f0 --- /dev/null +++ b/auto-check/.gitignore @@ -0,0 +1,8 @@ +# 运行产物 +reports/ +*.exe +*.test + +# IDE +.idea/ +.vscode/ diff --git a/auto-check/README.md b/auto-check/README.md new file mode 100644 index 0000000..1de1b7a --- /dev/null +++ b/auto-check/README.md @@ -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 +``` + +> ⚠️ 请在授权设备上使用,未授权扫描/测试可能违反法律法规。 diff --git a/auto-check/auto-check.yaml b/auto-check/auto-check.yaml new file mode 100644 index 0000000..c079b44 --- /dev/null +++ b/auto-check/auto-check.yaml @@ -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" diff --git a/auto-check/cmd/root.go b/auto-check/cmd/root.go new file mode 100644 index 0000000..817320b --- /dev/null +++ b/auto-check/cmd/root.go @@ -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) + } +} diff --git a/auto-check/cmd/run.go b/auto-check/cmd/run.go new file mode 100644 index 0000000..969ef69 --- /dev/null +++ b/auto-check/cmd/run.go @@ -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) +} diff --git a/auto-check/go.mod b/auto-check/go.mod new file mode 100644 index 0000000..c1e0deb --- /dev/null +++ b/auto-check/go.mod @@ -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 +) diff --git a/auto-check/go.sum b/auto-check/go.sum new file mode 100644 index 0000000..1311007 --- /dev/null +++ b/auto-check/go.sum @@ -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= diff --git a/auto-check/main.go b/auto-check/main.go new file mode 100644 index 0000000..9aa682f --- /dev/null +++ b/auto-check/main.go @@ -0,0 +1,7 @@ +package main + +import "auto-check/cmd" + +func main() { + cmd.Execute() +} diff --git a/auto-check/pkg/config/config.go b/auto-check/pkg/config/config.go new file mode 100644 index 0000000..c16a2f5 --- /dev/null +++ b/auto-check/pkg/config/config.go @@ -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 +} diff --git a/auto-check/pkg/discovery/discovery.go b/auto-check/pkg/discovery/discovery.go new file mode 100644 index 0000000..4b2be06 --- /dev/null +++ b/auto-check/pkg/discovery/discovery.go @@ -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 + } + } +} diff --git a/auto-check/pkg/report/report.go b/auto-check/pkg/report/report.go new file mode 100644 index 0000000..cddbaa6 --- /dev/null +++ b/auto-check/pkg/report/report.go @@ -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)) +} diff --git a/auto-check/pkg/sshclient/sshclient.go b/auto-check/pkg/sshclient/sshclient.go new file mode 100644 index 0000000..e0b6f1c --- /dev/null +++ b/auto-check/pkg/sshclient/sshclient.go @@ -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 +} diff --git a/auto-check/pkg/stress/runner.go b/auto-check/pkg/stress/runner.go new file mode 100644 index 0000000..e36d3d3 --- /dev/null +++ b/auto-check/pkg/stress/runner.go @@ -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 +} diff --git a/auto-check/pkg/stress/runners.go b/auto-check/pkg/stress/runners.go new file mode 100644 index 0000000..fd7248e --- /dev/null +++ b/auto-check/pkg/stress/runners.go @@ -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} +} diff --git a/auto-check/pkg/stress/tools.go b/auto-check/pkg/stress/tools.go new file mode 100644 index 0000000..096eecd --- /dev/null +++ b/auto-check/pkg/stress/tools.go @@ -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)) + } + } + } + } + } +} diff --git a/auto-check/pkg/stress/types.go b/auto-check/pkg/stress/types.go new file mode 100644 index 0000000..36a67f4 --- /dev/null +++ b/auto-check/pkg/stress/types.go @@ -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() +} diff --git a/auto-check/pkg/workflow/loop.go b/auto-check/pkg/workflow/loop.go new file mode 100644 index 0000000..1f7bb84 --- /dev/null +++ b/auto-check/pkg/workflow/loop.go @@ -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) + } +} diff --git a/auto-check/pkg/workflow/workflow.go b/auto-check/pkg/workflow/workflow.go new file mode 100644 index 0000000..46a5900 --- /dev/null +++ b/auto-check/pkg/workflow/workflow.go @@ -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 +}