diff --git a/auto-check/auto-check.yaml b/auto-check/auto-check.yaml index 3112a74..736edd8 100644 --- a/auto-check/auto-check.yaml +++ b/auto-check/auto-check.yaml @@ -16,13 +16,16 @@ ssh: # --- 压力测试 --- stress: - types: "cpu,memory" + types: "cpu,memory,disk,full" # 单项在前、综合(full)最后 duration: 30s threads: 4 + temp_limit: 90 # 温度上限(°C),超过判失败,0=不判定 + auto_install: true # 缺工具时在线安装(apt/dnf/yum 等),需要目标机可联网 + use_mirror: false # 在线安装是否切换国内镜像源(清华TUNA)。国内网络卡顿/超时再改 true # --- 工作流 --- workflow: - interval: 60s + interval: 180s # --- 报告与打印 --- report: diff --git a/auto-check/go.mod b/auto-check/go.mod index 4794628..a4fa172 100644 --- a/auto-check/go.mod +++ b/auto-check/go.mod @@ -5,12 +5,12 @@ go 1.25.0 require ( github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.54.0 + golang.org/x/text v0.41.0 + gopkg.in/yaml.v3 v3.0.1 ) 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 - golang.org/x/text v0.41.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/auto-check/go.sum b/auto-check/go.sum index 302718b..fd2f91f 100644 --- a/auto-check/go.sum +++ b/auto-check/go.sum @@ -16,6 +16,7 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 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/pkg/config/config.go b/auto-check/pkg/config/config.go index b91b7f8..8576774 100644 --- a/auto-check/pkg/config/config.go +++ b/auto-check/pkg/config/config.go @@ -38,13 +38,17 @@ type SSHConfig struct { Password string `yaml:"password"` KeyFile string `yaml:"key"` Port int `yaml:"port"` + SudoPwd string `yaml:"sudo_password"` // 非 root 用户安装工具时使用的 sudo 密码 } // StressConfig 压力测试 type StressConfig struct { - Types string `yaml:"types"` - Duration time.Duration `yaml:"duration"` - Threads int `yaml:"threads"` + Types string `yaml:"types"` + Duration time.Duration `yaml:"duration"` + Threads int `yaml:"threads"` + TempLimit float64 `yaml:"temp_limit"` // 温度上限(°C),0=不判定 + AutoInstall bool `yaml:"auto_install"` // 缺工具时尝试在线安装(apt/dnf/yum 等) + UseMirror bool `yaml:"use_mirror"` // 在线安装时切换国内镜像源(清华 TUNA),国内网络卡顿/超时再开启 } // ReportConfig 报告 @@ -83,9 +87,12 @@ func Default() *Config { Port: 22, }, Stress: StressConfig{ - Types: "cpu,memory", - Duration: 30 * time.Second, - Threads: 4, + Types: "cpu,memory", + Duration: 30 * time.Second, + Threads: 4, + TempLimit: 90, + AutoInstall: true, + UseMirror: false, }, Workflow: WorkflowConfig{ Interval: 10 * time.Second, diff --git a/auto-check/pkg/report/device.go b/auto-check/pkg/report/device.go index f128210..ac5f556 100644 --- a/auto-check/pkg/report/device.go +++ b/auto-check/pkg/report/device.go @@ -18,11 +18,14 @@ func BuildDeviceReport(dev *model.Device, rpt *stress.Report) *Report { b.AddHost(HostResult{MAC: dev.MAC, IP: dev.IP}) for _, res := range rpt.Results { b.AddStressResult(dev.MAC, StressResult{ - Type: string(res.Type), - Status: res.Status, - Duration: res.Duration.String(), - Error: res.Error, - Output: res.Output, + Type: string(res.Type), + Status: res.Status, + Duration: res.Duration.String(), + Score: res.Score, + ScoreUnit: stress.ScoreUnit(res.Type), + Metrics: res.OrderedMetrics(), + Error: res.Error, + Output: res.Output, }) } b.EndPhase(rpt.Summary()) diff --git a/auto-check/pkg/report/report.go b/auto-check/pkg/report/report.go index 27187fa..59d08c9 100644 --- a/auto-check/pkg/report/report.go +++ b/auto-check/pkg/report/report.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "time" + + "auto-check/pkg/stress" ) // PhaseResult 单阶段结果 @@ -19,23 +21,25 @@ type PhaseResult struct { // HostResult 单台设备结果(MAC 为唯一标识) type HostResult struct { - MAC string `json:"mac" yaml:"mac"` - 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"` - Curves map[string][]float64 `json:"curves,omitempty" yaml:"curves,omitempty"` // 指标名 → 时间序列(渲染为 sparkline) + MAC string `json:"mac" yaml:"mac"` + 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"` + Type string `json:"type" yaml:"type"` + Status string `json:"status" yaml:"status"` // pass / fail / skip + Duration string `json:"duration" yaml:"duration"` + Score float64 `json:"score,omitempty" yaml:"score,omitempty"` + ScoreUnit string `json:"score_unit,omitempty" yaml:"score_unit,omitempty"` + Metrics []stress.MetricItem `json:"metrics,omitempty" yaml:"metrics,omitempty"` + Output string `json:"output,omitempty" yaml:"output,omitempty"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` } // Report 检测报告 @@ -209,6 +213,12 @@ func (r *Report) ToText() string { icon = "✗" } sb.WriteString(fmt.Sprintf(" │ %s %-10s 耗时: %s\n", icon, s.Type, s.Duration)) + if s.Score > 0 { + sb.WriteString(fmt.Sprintf(" │ 跑分: %.1f %s\n", s.Score, s.ScoreUnit)) + } + for _, m := range s.Metrics { + sb.WriteString(fmt.Sprintf(" │ %s: %.1f%s\n", m.Label, m.Value, m.Unit)) + } if s.Error != "" { sb.WriteString(fmt.Sprintf(" │ 错误: %s\n", s.Error)) } diff --git a/auto-check/pkg/report/template.go b/auto-check/pkg/report/template.go index c97a3b8..1e6d7ca 100644 --- a/auto-check/pkg/report/template.go +++ b/auto-check/pkg/report/template.go @@ -2,7 +2,6 @@ package report import ( "fmt" - "math" "strings" "text/template" "time" @@ -50,29 +49,6 @@ func RenderTemplate(tpl string, data interface{}, cfg TemplateConfig) (string, e }, // 分隔线 "hr": func(sharp rune) string { return strings.Repeat(string(sharp), cfg.Width) }, - // 曲线图(时间序列 → sparkline) - "sparkline": func(data []float64, width int) string { - return Sparkline(data, width) - }, - // 曲线摘要: 最高/最低/平均 - "curveSummary": func(data []float64) string { - if len(data) == 0 { - return "无数据" - } - min, max := math.MaxFloat64, -math.MaxFloat64 - var sum float64 - for _, v := range data { - if v < min { - min = v - } - if v > max { - max = v - } - sum += v - } - avg := sum / float64(len(data)) - return fmt.Sprintf("max=%.1f min=%.1f avg=%.1f", max, min, avg) - }, } t, err := template.New("report").Funcs(funcMap).Parse(tpl) @@ -86,76 +62,3 @@ func RenderTemplate(tpl string, data interface{}, cfg TemplateConfig) (string, e } return sb.String(), nil } - -// ============================ -// Sparkline — 80mm 热敏纸曲线图 -// ============================ - -// sparkChars 8 级块状字符(低→高) -var sparkChars = []rune("▁▂▃▄▅▆▇█") - -// Sparkline 将时间序列渲染为一行块状曲线 -// width 为字符宽度(80mm 纸 ≤42) -func Sparkline(data []float64, width int) string { - if len(data) == 0 { - return "(无数据)" - } - if width <= 0 { - width = 42 - } - if width > 42 { - width = 42 - } - - // 单点 - if len(data) == 1 { - return fmt.Sprintf("▊ %.1f", data[0]) - } - - // 按宽度采样(超出宽度时等距抽取) - step := float64(len(data)) / float64(width) - sampled := make([]float64, 0, width) - for i := 0; i < width && int(float64(i)*step) < len(data); i++ { - idx := int(float64(i) * step) - if idx >= len(data) { - break - } - sampled = append(sampled, data[idx]) - } - - // 归一化到 0-7 - min, max := math.MaxFloat64, -math.MaxFloat64 - for _, v := range sampled { - if v < min { - min = v - } - if v > max { - max = v - } - } - - var sb strings.Builder - if max-min < 1e-9 { - // 全相同值 - idx := 0 - if max > 0 { - idx = 3 - } - for range sampled { - sb.WriteRune(sparkChars[idx]) - } - return sb.String() - } - - for _, v := range sampled { - idx := int((v - min) / (max - min) * 7) - if idx < 0 { - idx = 0 - } - if idx > 7 { - idx = 7 - } - sb.WriteRune(sparkChars[idx]) - } - return sb.String() -} diff --git a/auto-check/pkg/report/templates/report_80mm.tmpl b/auto-check/pkg/report/templates/report_80mm.tmpl index 850e07d..1a5d7b5 100644 --- a/auto-check/pkg/report/templates/report_80mm.tmpl +++ b/auto-check/pkg/report/templates/report_80mm.tmpl @@ -18,9 +18,8 @@ {{ end }}{{ end }}{{ if eq .Phase "SSH 登录" }}{{ range .Hosts }}{{ if .SSHLogin }}✓{{ else }}✗{{ end }} {{ padRight .MAC 19 }} {{ padRight .IP 15 }} {{ .SSHErr }} {{ end }}{{ end }}{{ if eq .Phase "压力测试" }}{{ range .Hosts }}{{ padRight .MAC 19 }} {{ padRight .IP 15 }} {{ range .Stress }}{{ if eq .Status "pass" }}✓{{ else }}✗{{ end }} {{ padRight .Type 12 }} {{ .Status }} {{ .Duration }} -{{ if .Error }} ERR: {{ trunc .Error 34 }}{{ end }}{{ end }}{{ range $name, $data := .Curves }} [{{ $name }}] {{ curveSummary $data }} - {{ sparkline $data 38 }} -{{ end }}{{ end }}{{ end }}{{ end }} +{{ if .Score }} 跑分: {{ printf "%.1f" .Score }} {{ .ScoreUnit }}{{ end }}{{ range .Metrics }} {{ .Label }}: {{ printf "%.1f" .Value }}{{ .Unit }} +{{ end }}{{ if .Error }} ERR: {{ trunc .Error 34 }}{{ end }}{{ end }}{{ end }}{{ end }}{{ end }} {{ "" }} {{ hr '═' }} 报告结束 diff --git a/auto-check/pkg/sshclient/sshclient.go b/auto-check/pkg/sshclient/sshclient.go index c12573e..71fd6eb 100644 --- a/auto-check/pkg/sshclient/sshclient.go +++ b/auto-check/pkg/sshclient/sshclient.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "os" + "strings" "time" "golang.org/x/crypto/ssh" @@ -16,6 +17,7 @@ type Config struct { KeyFile string Port int Timeout time.Duration + SudoPwd string // 非 root 用户执行特权命令(sudo)时的密码 } // NewConfig 创建配置 @@ -54,9 +56,61 @@ func RunCommand(client *ssh.Client, command string) (string, error) { out, err := session.CombinedOutput(command) if err != nil { - return string(out), fmt.Errorf("命令执行失败: %w", err) + return cleanOutput(out), fmt.Errorf("命令执行失败: %w", err) } - return string(out), nil + return cleanOutput(out), nil +} + +// isNoiseLine 判断某行是否为登录横幅/欢迎语等噪声(fnOS 的 admin 用户会把 +// "Could not chdir to home directory" 之类写入 stdout,污染命令解析)。 +func isNoiseLine(line string) bool { + s := strings.TrimSpace(line) + if s == "" { + return false + } + prefixes := []string{ + "Could not chdir to", + "Last login:", + "Welcome to", + "/etc/motd", + "Permission denied", + "bash: line 1:", + } + for _, p := range prefixes { + if strings.HasPrefix(s, p) { + return true + } + } + // 某些横幅是 "Could: command not found" 这类由前面噪声行残生, + // 形如 "xxx: command not found" 且前半段是噪声关键词的也过滤 + if strings.Contains(s, ": command not found") { + return true + } + return false +} + +// cleanOutput 过滤掉登录横幅等噪声行,仅保留真实命令输出 +func cleanOutput(raw []byte) string { + lines := strings.Split(string(raw), "\n") + var kept []string + for _, ln := range lines { + if isNoiseLine(ln) { + continue + } + kept = append(kept, ln) + } + return strings.Join(kept, "\n") +} + +// EnsureHome 确保远端登录用户的 HOME 目录存在,消除 fnOS "Could not chdir to +// home directory" 登录横幅(该横幅会写入 stdout 污染命令输出)。 +func EnsureHome(client *ssh.Client) { + out, err := RunCommand(client, "echo $HOME") + home := strings.TrimSpace(out) + if err != nil || home == "" || home == "/" { + return + } + RunCommand(client, fmt.Sprintf("mkdir -p %s", home)) } // buildSSHConfig 构建认证配置 diff --git a/auto-check/pkg/stress/chart.go b/auto-check/pkg/stress/chart.go index f34becd..f3a1ff2 100644 --- a/auto-check/pkg/stress/chart.go +++ b/auto-check/pkg/stress/chart.go @@ -5,127 +5,264 @@ import ( "fmt" "os" "strings" + "time" ) -// chartData 给 JS 用的数据 -type chartData struct { - Labels []string `json:"labels"` - CPU []float64 `json:"cpu"` - Temp []float64 `json:"temp"` - Title string `json:"title"` +// metricSpec 单条曲线规格 +type metricSpec struct { + Key string `json:"key"` + Label string `json:"label"` + Color string `json:"color"` + Unit string `json:"unit"` } -// WriteChartHTML 根据采样数据生成折线图 HTML 文件,返回文件路径 -func WriteChartHTML(ip string, result Result, reportDir string) (string, error) { - if len(result.Samples) == 0 { - return "", nil - } +// chartSpec 图表规格(左轴 / 右轴) +type chartSpec struct { + Left []metricSpec + Right []metricSpec + LeftPct bool + LeftTitle string + RightTitle string +} - cd := chartData{ - Title: fmt.Sprintf("%s - %s 压测曲线 (%s)", ip, result.Type, result.Duration), - } - for _, s := range result.Samples { - cd.Labels = append(cd.Labels, s.Time) - cd.CPU = append(cd.CPU, s.CPU) - cd.Temp = append(cd.Temp, s.Temp) - } +// kvPair 指标明细 +type kvPair struct { + Label string `json:"label"` + Value string `json:"value"` +} - dataJSON, err := json.Marshal(cd) +// metricSeries 单条曲线序列 +type metricSeries struct { + Label string `json:"label"` + Color string `json:"color"` + Data []float64 `json:"data"` + Unit string `json:"unit"` +} + +// chartPayload 图表数据 +type chartPayload struct { + Labels []string `json:"labels"` + LeftSets []metricSeries `json:"left_sets"` + RightSets []metricSeries `json:"right_sets"` + LeftTitle string `json:"left_title"` + RightTitle string `json:"right_title"` + LeftPct bool `json:"left_pct"` +} + +// testCard 单个测试卡片数据 +type testCard struct { + Name string `json:"name"` + Label string `json:"label"` + Status string `json:"status"` + Duration string `json:"duration"` + Score float64 `json:"score"` + ScoreUnit string `json:"score_unit"` + ScoreHint string `json:"score_hint"` + Metrics []kvPair `json:"metrics"` + Output string `json:"output"` + Chart *chartPayload `json:"chart,omitempty"` +} + +// reportHTMLData 报告整体数据 +type reportHTMLData struct { + Title string `json:"title"` + IP string `json:"ip"` + Time string `json:"time"` + Duration string `json:"duration"` + Overall string `json:"overall"` + Summary string `json:"summary"` + SysInfo map[string]string `json:"sys_info"` + Cards []testCard `json:"cards"` +} + +// WriteReportHTML 生成综合 HTML 报告(跑分卡片 + 多曲线图),返回文件路径 +func WriteReportHTML(ip string, report *Report, reportDir string) (string, error) { + data := buildReportData(ip, report) + dataJSON, err := json.Marshal(data) if err != nil { return "", err } - html := strings.Replace(chartHTMLTemplate, "__DATA__", string(dataJSON), 1) + html := strings.Replace(reportHTMLTemplate, "__DATA__", string(dataJSON), 1) - os.MkdirAll(reportDir, 0755) - filename := fmt.Sprintf("%s/chart-%s-%s.html", reportDir, result.Type, SanitizeFilename(ip)) + if err := os.MkdirAll(reportDir, 0755); err != nil { + return "", err + } + filename := fmt.Sprintf("%s/report-%s.html", reportDir, SanitizeFilename(ip)) if err := os.WriteFile(filename, []byte(html), 0644); err != nil { return "", err } return filename, nil } +func buildReportData(ip string, report *Report) reportHTMLData { + data := reportHTMLData{ + Title: fmt.Sprintf("%s · 硬件压力测试报告", ip), + IP: ip, + Time: report.StartTime.Format("2006-01-02 15:04:05"), + Duration: report.Duration.Round(time.Millisecond).String(), + Overall: Status(report), + Summary: report.Summary(), + SysInfo: report.SysInfo, + } + for _, res := range report.Results { + data.Cards = append(data.Cards, buildCard(res)) + } + return data +} + +func buildCard(res Result) testCard { + card := testCard{ + Name: string(res.Type), + Label: testLabel(res.Type), + Status: res.Status, + Duration: res.Duration.Round(time.Millisecond).String(), + Score: res.Score, + ScoreUnit: ScoreUnit(res.Type), + ScoreHint: scoreHint(res.Type), + Output: res.Output, + } + for _, name := range sortedMetricNames(res.Metrics) { + card.Metrics = append(card.Metrics, kvPair{ + Label: MetricLabel(name), + Value: fmt.Sprintf("%.1f%s", res.Metrics[name], MetricUnit(name)), + }) + } + if len(res.Samples) > 0 { + if payload, ok := buildChart(res); ok { + card.Chart = &payload + } + } + return card +} + +func buildChart(res Result) (chartPayload, bool) { + spec := chartSpecFor(res.Type) + if len(spec.Left) == 0 && len(spec.Right) == 0 { + return chartPayload{}, false + } + + payload := chartPayload{ + LeftTitle: spec.LeftTitle, + RightTitle: spec.RightTitle, + LeftPct: spec.LeftPct, + } + for _, s := range res.Samples { + payload.Labels = append(payload.Labels, s.Time) + } + + hasData := func(key string) bool { + for _, s := range res.Samples { + if s.HasMetric(key) { + return true + } + } + return false + } + buildSeries := func(specs []metricSpec) []metricSeries { + var out []metricSeries + for _, m := range specs { + if !hasData(m.Key) { + continue + } + series := metricSeries{Label: m.Label, Color: m.Color, Unit: m.Unit} + for _, s := range res.Samples { + series.Data = append(series.Data, s.Values[m.Key]) + } + out = append(out, series) + } + return out + } + + payload.LeftSets = buildSeries(spec.Left) + payload.RightSets = buildSeries(spec.Right) + return payload, true +} + +// testLabel 测试项中文名 +func testLabel(t TestType) string { + switch t { + case TestCPU: + return "CPU 压力测试" + case TestMemory: + return "内存压力测试" + case TestDiskIO: + return "磁盘 IO 测试" + case TestMemNative: + return "内存精压测试" + case TestFull: + return "综合工况测试" + case MonitorTemp: + return "温度监控" + case MonitorDmesg: + return "内核日志检查" + default: + return string(t) + } +} + +// scoreHint 跑分含义说明 +func scoreHint(t TestType) string { + switch t { + case TestCPU: + return "每秒操作数(bogo ops/s)" + case TestMemory: + return "每秒操作数(bogo ops/s)" + case TestDiskIO: + return "读写带宽合计(MB/s)" + case TestFull: + return "综合每秒操作数(bogo ops/s)" + case TestMemNative: + return "内存带宽(MB/s)" + default: + return "" + } +} + +// chartSpecFor 返回某类测试的曲线规格 +func chartSpecFor(t TestType) chartSpec { + switch t { + case TestCPU: + return chartSpec{ + Left: []metricSpec{{Key: "cpu", Label: "CPU 利用率", Color: "#f97316", Unit: "%"}}, + Right: []metricSpec{{Key: "temp", Label: "温度", Color: "#ef4444", Unit: "°C"}}, + LeftPct: true, + LeftTitle: "利用率 (%)", + RightTitle: "温度 (°C)", + } + case TestMemory: + return chartSpec{ + Left: []metricSpec{{Key: "mem_used_pct", Label: "内存使用率", Color: "#8b5cf6", Unit: "%"}}, + Right: []metricSpec{{Key: "temp", Label: "温度", Color: "#ef4444", Unit: "°C"}}, + LeftPct: true, + LeftTitle: "使用率 (%)", + RightTitle: "温度 (°C)", + } + case TestDiskIO: + return chartSpec{ + Left: []metricSpec{{Key: "iops_total", Label: "IOPS", Color: "#22c55e", Unit: ""}}, + Right: []metricSpec{{Key: "bw_total", Label: "吞吐", Color: "#3b82f6", Unit: "MB/s"}}, + LeftPct: false, + LeftTitle: "IOPS", + RightTitle: "吞吐 (MB/s)", + } + case TestFull: + return chartSpec{ + Left: []metricSpec{ + {Key: "cpu", Label: "CPU 利用率", Color: "#f97316", Unit: "%"}, + {Key: "mem_used_pct", Label: "内存使用率", Color: "#8b5cf6", Unit: "%"}, + }, + Right: []metricSpec{{Key: "temp", Label: "温度", Color: "#ef4444", Unit: "°C"}}, + LeftPct: true, + LeftTitle: "利用率 (%)", + RightTitle: "温度 (°C)", + } + default: + return chartSpec{} + } +} + // SanitizeFilename 清理 IP 中的特殊字符,用于文件名 func SanitizeFilename(ip string) string { return strings.ReplaceAll(ip, ":", "_") } - -const chartHTMLTemplate = ` - - - - -压测曲线 - - - - -
-

- -
- CPU 使用率 (%) - 温度 (°C) -
-
- - -` diff --git a/auto-check/pkg/stress/metrics.go b/auto-check/pkg/stress/metrics.go index e62cfa4..2294d04 100644 --- a/auto-check/pkg/stress/metrics.go +++ b/auto-check/pkg/stress/metrics.go @@ -1,6 +1,7 @@ package stress import ( + "fmt" "time" ) @@ -10,10 +11,11 @@ import ( // Metric 测试指标(定义测什么、用什么工具、怎么判结果) type Metric struct { - Name string // 指标名称(cpu/memory/disk/memnative/full) - Tool string // 依赖的工具(stress-ng/stressapptest) - Enabled bool // 是否启用 - IsMonitor bool // 是否为监控指标(temp/dmesg,自动附加) + Name string // 指标名称(cpu/memory/disk/memnative/full) + Tool string // 依赖的工具(stress-ng/stressapptest/fio) + Enabled bool // 是否启用 + IsMonitor bool // 是否为监控指标(temp/dmesg,自动附加) + DisableReason string // 禁用时展示的原因 } // BuildMetrics 根据配置和可用工具,生成要执行的指标列表 @@ -28,16 +30,36 @@ func BuildMetrics(types []TestType, tools ToolSet) []Metric { case TestMemory: m.Tool = "stress-ng" case TestDiskIO: - m.Tool = "stress-ng" + // disk 优先 fio;无 fio 可降级 stress-ng + switch { + case tools.Has("fio"): + m.Tool = "fio" + case tools.Has("stress-ng"): + m.Tool = "stress-ng" + default: + m.Tool = "fio" + } case TestMemNative: m.Tool = "stressapptest" case TestFull: m.Tool = "stress-ng" default: m.Enabled = false + m.DisableReason = "未知测试类型" } - if m.Tool != "" && !tools.Has(m.Tool) { - m.Enabled = false // 工具不可用,禁用 + + // 根据工具可用性决定是否禁用,并记录原因 + switch t { + case TestDiskIO: + if !tools.Has("fio") && !tools.Has("stress-ng") { + m.Enabled = false + m.DisableReason = "缺少 fio 或 stress-ng" + } + default: + if m.Tool != "" && !tools.Has(m.Tool) { + m.Enabled = false + m.DisableReason = fmt.Sprintf("缺少 %s", m.Tool) + } } metrics = append(metrics, m) } @@ -61,6 +83,7 @@ type Config struct { DiskSizeMB int // 0=默认1024 DiskDir string // 空=自动临时目录 TempLogInt time.Duration // 0=10s + TempLimit float64 // 温度上限(°C),0=不判定 } // DefaultConfig 默认配置 @@ -70,5 +93,6 @@ func DefaultConfig() Config { Threads: 4, DiskSizeMB: 1024, TempLogInt: 10 * time.Second, + TempLimit: 90, } } diff --git a/auto-check/pkg/stress/report_html.go b/auto-check/pkg/stress/report_html.go new file mode 100644 index 0000000..74d2cd2 --- /dev/null +++ b/auto-check/pkg/stress/report_html.go @@ -0,0 +1,126 @@ +package stress + +const reportHTMLTemplate = ` + + + + +硬件压力测试报告 + + + + +
+
+
+
+ 设备 + 测试时间 + 总耗时 +
+
+
+ + +
+
+
+
+ + +` diff --git a/auto-check/pkg/stress/runner.go b/auto-check/pkg/stress/runner.go index bf3aaf8..f6f5cbd 100644 --- a/auto-check/pkg/stress/runner.go +++ b/auto-check/pkg/stress/runner.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "os" + "sort" "strconv" "strings" "time" @@ -24,18 +25,20 @@ var Results = make(map[string]*Report) // Runner 压力测试执行器 type Runner struct { - cfg Config - types []TestType - client *ssh.Client - tools ToolSet - metrics []Metric + cfg Config + types []TestType + client *ssh.Client + tools ToolSet + metrics []Metric + installFailed []string // 自动安装仍失败的工具 } -// NewRunner 创建执行器(检测工具 → 构建指标 → 生成脚本 → 执行) -func NewRunner(cfg Config, types []TestType, client *ssh.Client) *Runner { - tools := DetectTools(client) +// NewRunner 创建执行器(检查/在线安装 → 构建指标 → 生成脚本 → 执行) +func NewRunner(cfg Config, types []TestType, client *ssh.Client, autoInstall bool, sudoPwd string) *Runner { + // 1. 检测工具,缺失时按发行版在线安装(可配置国内镜像源加速) + tools, failed := EnsureTools(client, requiredTools, autoInstall, sudoPwd) metrics := BuildMetrics(types, tools) - return &Runner{cfg: cfg, types: types, client: client, tools: tools, metrics: metrics} + return &Runner{cfg: cfg, types: types, client: client, tools: tools, metrics: metrics, installFailed: failed} } // Run 执行压力测试(生成脚本 → 远程执行 → 解析结果) @@ -49,8 +52,11 @@ func (r *Runner) Run(ip string) *Report { fmt.Printf("%s(%s) ", name, info.Version) } fmt.Println() + if len(r.installFailed) > 0 { + fmt.Printf(" [安装] 以下工具自动安装失败,相关测试将跳过: %v\n", r.installFailed) + } - // 显示将执行的指标 + // 显示将执行的指标及被跳过的原因 fmt.Printf(" 指标: ") for _, m := range r.metrics { if m.Enabled { @@ -58,6 +64,11 @@ func (r *Runner) Run(ip string) *Report { } } fmt.Println() + for _, m := range r.metrics { + if !m.Enabled && !m.IsMonitor { + fmt.Printf(" [跳过] %s: %s\n", m.Name, m.DisableReason) + } + } // 按需读取各测试脚本,拼装成完整脚本 script := r.assembleScript() @@ -76,20 +87,19 @@ func (r *Runner) Run(ip string) *Report { r.parseResults(report, output) } - // 生成采样折线图 - for _, res := range report.Results { - if len(res.Samples) > 0 { - chartPath, err := WriteChartHTML(ip, res, "reports") - if err != nil { - fmt.Printf(" [图表] 生成失败: %v\n", err) - } else { - fmt.Printf(" [图表] %s\n", chartPath) - } + report.EndTime = time.Now() + report.Duration = report.EndTime.Sub(report.StartTime) + + // 生成综合 HTML 报告(跑分卡片 + 曲线图) + if len(report.Results) > 0 { + reportPath, err := WriteReportHTML(ip, report, "reports") + if err != nil { + fmt.Printf(" [报告] 生成失败: %v\n", err) + } else { + fmt.Printf(" [报告] %s\n", reportPath) } } - report.EndTime = time.Now() - report.Duration = report.EndTime.Sub(report.StartTime) fmt.Printf("\n════════ [%s] 压力测试完成 ════════\n", ip) fmt.Println(report.ToText()) return report @@ -103,7 +113,11 @@ func (r *Runner) parseResults(report *Report, output string) { var currentDuration string var currentOutput []string var currentSamples []Sample + var currentScore float64 + var currentMetrics map[string]float64 + var sampleHeader []string inSamples := false + inSysInfo := false flush := func() { if currentTest == "" { @@ -118,6 +132,14 @@ func (r *Runner) parseResults(report *Report, output string) { case "error": status = StatusError } + // 温度超限判定(仅对 temp 监控) + if TestType(currentTest) == MonitorTemp && status == StatusPass && r.cfg.TempLimit > 0 { + if maxT, ok := currentMetrics["max_temp"]; ok && maxT > r.cfg.TempLimit { + status = StatusFail + currentOutput = append(currentOutput, + fmt.Sprintf("温度超限: 最高 %.1f°C > 阈值 %.1f°C", maxT, r.cfg.TempLimit)) + } + } duration, _ := time.ParseDuration(currentDuration) report.AddResult(Result{ Type: TestType(currentTest), @@ -125,12 +147,17 @@ func (r *Runner) parseResults(report *Report, output string) { Output: strings.Join(currentOutput, "\n"), Duration: duration, Samples: currentSamples, + Score: currentScore, + Metrics: currentMetrics, }) currentTest = "" currentStatus = "" currentDuration = "" currentOutput = nil currentSamples = nil + currentScore = 0 + currentMetrics = nil + sampleHeader = nil } for _, line := range lines { @@ -139,6 +166,7 @@ func (r *Runner) parseResults(report *Report, output string) { // 采样数据块 if line == "===SAMPLES===" { inSamples = true + sampleHeader = nil continue } if line == "===END_SAMPLES===" { @@ -147,14 +175,46 @@ func (r *Runner) parseResults(report *Report, output string) { } if inSamples { parts := strings.Split(line, ",") - if len(parts) >= 3 { - cpu, _ := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) - temp, _ := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64) - currentSamples = append(currentSamples, Sample{ - Time: strings.TrimSpace(parts[0]), - CPU: cpu, - Temp: temp, - }) + if sampleHeader == nil { + // 首行为表头:time,指标1,指标2,... + for _, p := range parts { + sampleHeader = append(sampleHeader, strings.TrimSpace(p)) + } + continue + } + if len(parts) < 1 || strings.TrimSpace(parts[0]) == "" { + continue + } + s := Sample{Time: strings.TrimSpace(parts[0]), Values: map[string]float64{}} + for i := 1; i < len(parts) && i < len(sampleHeader); i++ { + name := sampleHeader[i] + if name == "" || name == "time" { + continue + } + if v, err := strconv.ParseFloat(strings.TrimSpace(parts[i]), 64); err == nil { + s.Values[name] = v + } + } + currentSamples = append(currentSamples, s) + continue + } + + if line == "===SYSTEM_INFO===" { + inSysInfo = true + continue + } + if inSysInfo { + if line == "" { + inSysInfo = false + continue + } + if i := strings.Index(line, ":"); i > 0 { + k := strings.TrimSpace(line[:i]) + v := strings.TrimSpace(line[i+1:]) + if report.SysInfo == nil { + report.SysInfo = map[string]string{} + } + report.SysInfo[k] = v } continue } @@ -187,12 +247,24 @@ func (r *Runner) parseResults(report *Report, output string) { currentDuration = fmt.Sprintf("%dms", ms) continue } - if strings.HasPrefix(line, "output:") { - currentOutput = append(currentOutput, strings.TrimPrefix(line, "output:")) + if strings.HasPrefix(line, "score:") { + currentScore, _ = strconv.ParseFloat(strings.TrimPrefix(line, "score:"), 64) continue } - if strings.HasPrefix(line, "samples:") || strings.HasPrefix(line, "max:") { - currentOutput = append(currentOutput, line) + if strings.HasPrefix(line, "metric:") { + kv := strings.TrimPrefix(line, "metric:") + if i := strings.Index(kv, "="); i > 0 { + name := strings.TrimSpace(kv[:i]) + val, _ := strconv.ParseFloat(strings.TrimSpace(kv[i+1:]), 64) + if currentMetrics == nil { + currentMetrics = map[string]float64{} + } + currentMetrics[name] = val + } + continue + } + if strings.HasPrefix(line, "output:") { + currentOutput = append(currentOutput, strings.TrimPrefix(line, "output:")) continue } @@ -202,13 +274,6 @@ func (r *Runner) parseResults(report *Report, output string) { } } flush() - - // 从系统信息输出解析主机信息 - for _, line := range lines { - if strings.HasPrefix(line, "hostname:") { - // 可扩展:存入 report 的系统信息字段 - } - } } // assembleScript 根据 metrics 读取对应的独立脚本文件,拼装成完整脚本 @@ -248,9 +313,24 @@ func (r *Runner) assembleScript() string { sb.WriteString("\n") } + // 按"先单项、后综合"排序测试脚本 + ordered := make([]Metric, len(r.metrics)) + copy(ordered, r.metrics) + sort.SliceStable(ordered, func(i, j int) bool { + return testPriority(ordered[i]) < testPriority(ordered[j]) + }) + // 逐个测试脚本 - for _, m := range r.metrics { - if m.IsMonitor || !m.Enabled { + for _, m := range ordered { + if m.IsMonitor { + continue + } + if !m.Enabled { + // 缺少依赖时仍输出一个 skip 结果,让报告完整展示原因 + sb.WriteString(fmt.Sprintf("echo '===TEST:%s==='\n", m.Name)) + sb.WriteString("echo 'status:skip'\n") + sb.WriteString(fmt.Sprintf("echo 'output:%s'\n", m.DisableReason)) + sb.WriteString("echo ''\n") continue } data := r.readScript(m.Name) @@ -286,6 +366,27 @@ func (r *Runner) readScript(name string) []byte { return data } +// testPriority 测试执行顺序:单项测试在前,综合测试(full)最后,监控项最后 +func testPriority(m Metric) int { + if m.IsMonitor { + return 1000 + } + switch TestType(m.Name) { + case TestCPU: + return 0 + case TestMemory: + return 1 + case TestDiskIO: + return 2 + case TestMemNative: + return 3 + case TestFull: + return 100 + default: + return 50 + } +} + // ============================ // 业务入口(workflow 调用) // ============================ @@ -293,6 +394,7 @@ func (r *Runner) readScript(name string) []byte { // TestDevice 对单台设备执行 SSH 登录 + 压力测试(业务入口) func TestDevice(ip string, cfg config.Config) *Report { sshCfg := sshclient.NewConfig(cfg.SSH.User, cfg.SSH.Password, cfg.SSH.KeyFile, cfg.SSH.Port, cfg.Scan.Timeout) + sshCfg.SudoPwd = cfg.SSH.SudoPwd conn, err := sshclient.Connect(ip, sshCfg) if err != nil { @@ -300,6 +402,19 @@ func TestDevice(ip string, cfg config.Config) *Report { } defer conn.Close() + // fnOS 的 admin 用户 HOME 目录可能不存在,登录会输出 + // "Could not chdir to home directory" 横幅污染命令输出,先建好 HOME + sshclient.EnsureHome(conn) + + // 根据配置决定是否在线安装时使用国内镜像源(默认 false,先试系统自带源) + SetMirror(cfg.Stress.UseMirror) + + // sudo 密码:优先用专属 sudo_password;为空时退回 SSH 登录密码(飞牛 admin 同密码) + sudoPwd := cfg.SSH.SudoPwd + if sudoPwd == "" { + sudoPwd = cfg.SSH.Password + } + // SSH 登录成功后回填远端 MAC(发现阶段只做了 ping,暂无 MAC) if mac := queryRemoteMAC(conn); mac != "" { if dev, ok := discovery.Devices[ip]; ok { @@ -314,8 +429,9 @@ func TestDevice(ip string, cfg config.Config) *Report { Threads: cfg.Stress.Threads, DiskSizeMB: 1024, TempLogInt: 10 * time.Second, + TempLimit: cfg.Stress.TempLimit, } - return NewRunner(stressCfg, ParseTypes(cfg.Stress.Types), conn).Run(ip) + return NewRunner(stressCfg, ParseTypes(cfg.Stress.Types), conn, cfg.Stress.AutoInstall, sudoPwd).Run(ip) } // NewSSHFailReport 构造 SSH 连接失败报告 @@ -365,6 +481,15 @@ func IsPassed(rpt *Report) bool { return rpt != nil && rpt.Failed == 0 && rpt.Errors == 0 } +// IsSSHFail 报告是否为 SSH 连接失败(连不上,无任何有效测试项) +func IsSSHFail(rpt *Report) bool { + if rpt == nil || len(rpt.Results) != 1 { + return false + } + r := rpt.Results[0] + return r.Type == "ssh" && (r.Status == StatusFail || r.Status == StatusError) +} + // Status 报告状态字符串 func Status(rpt *Report) string { if rpt == nil { diff --git a/auto-check/pkg/stress/tools.go b/auto-check/pkg/stress/tools.go index 39722d9..f6d56d5 100644 --- a/auto-check/pkg/stress/tools.go +++ b/auto-check/pkg/stress/tools.go @@ -1,6 +1,7 @@ package stress import ( + "fmt" "strings" "auto-check/pkg/sshclient" @@ -8,10 +9,82 @@ import ( "golang.org/x/crypto/ssh" ) +// ============================ +// 国内镜像源 +// ============================ + +// useMirror 控制是否把远端包管理器切到国内镜像。默认关闭——先用系统自带源 +// 试装(飞牛/群晖等定制系统常常已预置可用源),失败或超时再开启清华镜像。 +var useMirror = false + +// SetMirror 设置是否使用国内镜像源(由配置注入) +func SetMirror(on bool) { + useMirror = on +} + +// mirrorName 根据发行版返回镜像别名(用于日志) +const mirrorLabel = "清华 TUNA 镜像" + +// setupMirror 在安装前把对应包管理器的源切换为国内镜像(仅 apt 系需要改文件, +// 其它包管理器通过参数/变量临时指向镜像)。失败不致命,仅打印警告并继续用原源。 +func setupMirror(client *ssh.Client, pm string, sudoPwd string) { + if !useMirror { + return + } + switch pm { + case "apt-get": + // 备份并替换为清华镜像(Debian/Ubuntu 通用,按 lsb_release 选版本) + script := `set -e +OS_ID=$(. /etc/os-release 2>/dev/null; echo "$ID") +OS_VER=$(. /etc/os-release 2>/dev/null; echo "$VERSION_CODENAME") +[ -z "$OS_VER" ] && OS_VER=$(. /etc/os-release 2>/dev/null; echo "$VERSION_ID") +MIRROR="https://mirrors.tuna.tsinghua.edu.cn" +if [ "$OS_ID" = "ubuntu" ]; then + LIST="$MIRROR/ubuntu" +elif [ "$OS_ID" = "debian" ]; then + LIST="$MIRROR/debian" +elif [ "$OS_ID" = "raspbian" ]; then + LIST="$MIRROR/raspbian" +else + LIST="$MIRROR/debian" +fi +BAK=/etc/apt/sources.list.d/auto-check-mirror.bak +if [ ! -f "$BAK" ]; then + cp /etc/apt/sources.list "$BAK" 2>/dev/null || true + cp -r /etc/apt/sources.list.d "$BAK.d" 2>/dev/null || true +fi +: > /etc/apt/sources.list +echo "deb $LIST $OS_VER main contrib non-free" > /etc/apt/sources.list +echo "deb $LIST $OS_VER-updates main contrib non-free" >> /etc/apt/sources.list +echo "deb $LIST $OS_VER-backports main contrib non-free" >> /etc/apt/sources.list +echo "deb $LIST-security $OS_VER-security main contrib non-free" >> /etc/apt/sources.list +echo OK` + out, err := runPrivileged(client, "sh -c "+shellQuote(script), sudoPwd) + if err != nil || !strings.Contains(out, "OK") { + fmt.Printf(" [镜像] apt 切换国内源失败,将使用官方源: %v\n", err) + } else { + fmt.Printf(" [镜像] apt 已切换至%s\n", mirrorLabel) + } + case "dnf", "yum": + // 清华镜像站对 dnf/yum 提供 repo 文件;这里用变量临时指向(持久化需改 repo) + cmd := `echo "腾讯/清华镜像需手动配置 repo;尝试临时使用 mirrors.tuna.tsinghua.edu.cn 变量"` + runPrivileged(client, cmd, sudoPwd) + // 通过 baseurl 变量临时覆盖(仅对部分仓库有效,作为加速兜底) + fmt.Printf(" [镜像] %s 使用系统默认源(建议手动配置%s repo)\n", pm, mirrorLabel) + case "apk": + runPrivileged(client, "sed -i 's#https\\?://[^/]*alpinelinux.org#https://mirrors.tuna.tsinghua.edu.cn/alpine#g' /etc/apk/repositories", sudoPwd) + fmt.Printf(" [镜像] apk 已切换至%s\n", mirrorLabel) + } +} + // ============================ // 远程工具检测 // ============================ +// 压测核心依赖工具(缺则按需在线安装)。lm-sensors 依赖较多,仅单文件 +// 二进制 stress-ng 与 fio 为必需;sensors 缺失仅影响温度曲线,不阻断压测。 +var requiredTools = []string{"stress-ng", "fio"} + // ToolInfo 远程工具信息 type ToolInfo struct { Name string @@ -47,31 +120,185 @@ func DetectTools(client *ssh.Client) ToolSet { return ts } -// detectOne 检测单个工具 +// EnsureTools 检测工具;缺失且 autoInstall=true 时按发行版自动安装。 +// sudoPwd 为非 root 用户执行安装时提供(为空则尝试免密 sudo)。 +// 返回安装后的工具集合,以及最终仍缺失的工具名列表。 +func EnsureTools(client *ssh.Client, names []string, autoInstall bool, sudoPwd string) (ToolSet, []string) { + ts := DetectTools(client) + var missing []string + for _, n := range names { + if !ts.Has(n) { + missing = append(missing, n) + } + } + if len(missing) == 0 { + return ts, nil + } + + var failed []string + if autoInstall { + pm := detectPkgManager(client) + if pm == "" { + return ts, missing // 无法识别包管理器,跳过安装 + } + // 安装前先把源切到国内镜像(使用官方源在网络差的环境会超时) + setupMirror(client, pm, sudoPwd) + // 仅 apt 系需要先刷新元数据 + if pm == "apt-get" { + if out, err := runPrivileged(client, "timeout 180 apt-get update 2>&1", sudoPwd); err != nil || strings.Contains(out, "Err") || strings.Contains(out, "Failed") { + fmt.Printf(" [安装] apt-get update 异常(可能无可用源/无权限): %v\n%s\n", err, truncate(out, 400)) + } + } + for _, n := range missing { + pkg := pkgName(pm, n) + if pkg == "" { + failed = append(failed, n) + continue + } + installCmd := pkgInstallCmd(pm, pkg) + // runPrivileged 统一处理 sudo 与引号,这里直接给裸安装命令(带 timeout 兜底) + out, err := runPrivileged(client, fmt.Sprintf("timeout 300 %s 2>&1", installCmd), sudoPwd) + if err != nil || !strings.Contains(out, "Setting up "+pkg) && !strings.Contains(out, "已安装") && !strings.Contains(out, "already newest") { + fmt.Printf(" [安装] %s 安装失败: %v\n%s\n", pkg, err, truncate(out, 500)) + } + // 安装后重新检测该工具是否到位 + if info, ok := detectOne(client, n); ok { + ts.Tools[n] = info + } else { + failed = append(failed, n) + } + } + if len(failed) == 0 { + return ts, nil + } + return ts, failed + } + return ts, missing +} + +// runPrivileged 以特权执行命令:root 直接执行;非 root 用 sudo, +// 若提供了 sudo 密码则通过 stdin 喂入(无需 NOPASSWD 配置)。 +func runPrivileged(client *ssh.Client, cmd, sudoPwd string) (string, error) { + // 已是 root 则直接执行 + if out, err := sshclient.RunCommand(client, "id -u"); err == nil && strings.TrimSpace(out) == "0" { + return sshclient.RunCommand(client, cmd) + } + // 非 root:尝试 sudo + if sudoPwd != "" { + // echo 密码 | sudo -S 执行;整条命令用双引号包裹,避免命令内部单引号嵌套冲突。 + // 仅对命令内的双引号转义(安装命令均不含双引号,安全)。 + quoted := strings.ReplaceAll(singleLine(cmd), `"`, `\"`) + wrapped := fmt.Sprintf("echo %s | sudo -S -- sh -c \"%s\"", shellQuote(sudoPwd), quoted) + return sshclient.RunCommand(client, wrapped) + } + // 无密码:先尝试免密 sudo (-n),失败则回退普通 sudo(会提示需要密码) + quoted := strings.ReplaceAll(singleLine(cmd), `"`, `\"`) + if out, err := sshclient.RunCommand(client, "sudo -n -- sh -c \""+quoted+"\""); err == nil { + return out, nil + } + return sshclient.RunCommand(client, "sudo -- sh -c \""+quoted+"\"") +} + +// shellQuote 转义单引号,避免密码破坏命令结构 +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// singleLine 去掉换行,保证能塞进 sh -c '...' +func singleLine(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\r", "") +} + +// truncate 截断过长的输出,便于日志展示 +func truncate(s string, n int) string { + // 去掉 ANSI/多余空行,只保留前 n 个字符 + s = strings.TrimSpace(s) + if len([]rune(s)) > n { + return string([]rune(s)[:n]) + "...(truncated)" + } + return s +} + +// detectPkgManager 识别远程发行版的包管理器 +func detectPkgManager(client *ssh.Client) string { + for _, pm := range []string{"apt-get", "dnf", "yum", "apk", "zypper"} { + if _, err := sshclient.RunCommand(client, fmt.Sprintf("command -v %s", pm)); err == nil { + return pm + } + } + return "" +} + +// pkgName 工具名 → 对应发行版的包名 +func pkgName(pm, tool string) string { + m := map[string]string{ + "stress-ng": "stress-ng", + "fio": "fio", + "lm-sensors": "lm-sensors", + "stressapptest": "stressapptest", + "iperf3": "iperf3", + } + if pm == "dnf" || pm == "yum" { + // RHEL 系 lm-sensors 包名为 lm_sensors + if tool == "lm-sensors" { + return "lm_sensors" + } + } + if pm == "zypper" { + if tool == "lm-sensors" { + return "sensors" + } + } + if v, ok := m[tool]; ok { + return v + } + return tool +} + +// pkgInstallCmd 生成安装命令 +func pkgInstallCmd(pm, pkg string) string { + switch pm { + case "apt-get": + return fmt.Sprintf("apt-get install -y %s", pkg) + case "dnf": + return fmt.Sprintf("dnf install -y %s", pkg) + case "yum": + return fmt.Sprintf("yum install -y %s", pkg) + case "apk": + return fmt.Sprintf("apk add %s", pkg) + case "zypper": + return fmt.Sprintf("zypper install -y %s", pkg) + } + return "" +} + +// detectOne 检测单个工具(查系统 PATH;缺失由 EnsureTools 在线安装补全) func detectOne(client *ssh.Client, name string) (ToolInfo, bool) { - out, err := sshclient.RunCommand(client, "which "+name+" 2>/dev/null") - path := strings.TrimSpace(out) - if err != nil || path == "" { + path := "" + if out, err := sshclient.RunCommand(client, "which "+name+" 2>/dev/null"); err == nil { + path = strings.TrimSpace(out) + } + if path == "" { return ToolInfo{}, false } var version string switch name { case "stress-ng": - out, _ = sshclient.RunCommand(client, path+" --version 2>&1 | head -1") - version = strings.TrimSpace(out) + v, _ := sshclient.RunCommand(client, path+" --version 2>&1 | head -1") + version = strings.TrimSpace(v) case "stressapptest": - out, _ = sshclient.RunCommand(client, path+" --help 2>&1 | head -1") - version = strings.TrimSpace(out) + v, _ := sshclient.RunCommand(client, path+" --help 2>&1 | head -1") + version = strings.TrimSpace(v) case "iperf3": - out, _ = sshclient.RunCommand(client, path+" --version 2>&1 | head -1") - version = strings.TrimSpace(out) + v, _ := sshclient.RunCommand(client, path+" --version 2>&1 | head -1") + version = strings.TrimSpace(v) case "fio": - out, _ = sshclient.RunCommand(client, path+" --version 2>&1 | head -1") - version = strings.TrimSpace(out) + v, _ := sshclient.RunCommand(client, path+" --version 2>&1 | head -1") + version = strings.TrimSpace(v) case "lm-sensors": - out, _ = sshclient.RunCommand(client, path+" -v 2>&1 | head -1") - version = strings.TrimSpace(out) + v, _ := sshclient.RunCommand(client, path+" -v 2>&1 | head -1") + version = strings.TrimSpace(v) } return ToolInfo{Name: name, Path: path, Version: version}, true diff --git a/auto-check/pkg/stress/types.go b/auto-check/pkg/stress/types.go index 79ffc06..addc246 100644 --- a/auto-check/pkg/stress/types.go +++ b/auto-check/pkg/stress/types.go @@ -35,14 +35,128 @@ type Result struct { Output string Error string Duration time.Duration - Samples []Sample // 时序采样数据(cpu 等测试采集) + Samples []Sample // 时序采样数据(用于绘制曲线) + Score float64 // 性能跑分(越大越好,单位见 MetricUnit) + Metrics map[string]float64 // 关键指标(最高温度、IOPS、吞吐等明细) } -// Sample 单次采样点 +// Sample 单次采样点(通用多指标:时间 + 各指标值) +// 指标名由脚本 SAMPLES 块首行表头决定,例如 cpu/temp/mem_used_pct/iops_total/bw_total type Sample struct { - Time string // HH:MM:SS - CPU float64 // CPU 使用率 % - Temp float64 // 温度 °C + Time string // 时间(HH:MM:SS 或相对秒) + Values map[string]float64 // 指标名 → 值 +} + +// Get 读取某个指标在该采样点的值(不存在返回 0) +func (s Sample) Get(name string) float64 { + return s.Values[name] +} + +// HasMetric 采样点是否包含某指标 +func (s Sample) HasMetric(name string) bool { + _, ok := s.Values[name] + return ok +} + +// ScoreUnit 返回某类测试跑分的单位 +func ScoreUnit(t TestType) string { + switch t { + case TestDiskIO: + return "MB/s" + default: + return "ops/s" + } +} + +// metricLabels 关键指标中文名映射 +var metricLabels = map[string]string{ + "max_temp": "最高温度", + "avg_temp": "平均温度", + "avg_cpu": "平均CPU利用率", + "mem_used_pct": "峰值内存使用率", + "avg_mem": "平均内存使用率", + "read_iops": "读IOPS", + "write_iops": "写IOPS", + "iops_total": "总IOPS", + "read_bw": "读吞吐", + "write_bw": "写吞吐", + "bw_total": "总吞吐", +} + +// MetricLabel 返回指标显示名,未知指标原样返回 +func MetricLabel(name string) string { + if l, ok := metricLabels[name]; ok { + return l + } + return name +} + +// MetricUnit 返回指标单位,未知指标返回空 +func MetricUnit(name string) string { + switch name { + case "max_temp", "avg_temp": + return "°C" + case "read_bw", "write_bw", "bw_total": + return "MB/s" + case "avg_cpu", "mem_used_pct", "avg_mem": + return "%" + default: + return "" + } +} + +// sortedMetricNames 按固定顺序返回指标名(保证报告输出稳定) +func sortedMetricNames(m map[string]float64) []string { + if len(m) == 0 { + return nil + } + order := []string{"max_temp", "avg_temp", "avg_cpu", "mem_used_pct", "avg_mem", + "read_iops", "write_iops", "iops_total", "read_bw", "write_bw", "bw_total"} + names := make([]string, 0, len(m)) + for _, k := range order { + if _, ok := m[k]; ok { + names = append(names, k) + } + } + // 兜底:未在 order 里的键追加 + for k := range m { + found := false + for _, n := range names { + if n == k { + found = true + break + } + } + if !found { + names = append(names, k) + } + } + return names +} + +// MetricItem 一条指标明细(已格式化,供报告/打印使用) +type MetricItem struct { + Name string `json:"name" yaml:"name"` + Label string `json:"label" yaml:"label"` + Value float64 `json:"value" yaml:"value"` + Unit string `json:"unit,omitempty" yaml:"unit,omitempty"` +} + +// OrderedMetrics 返回按固定顺序排列的指标明细列表 +func (r Result) OrderedMetrics() []MetricItem { + if len(r.Metrics) == 0 { + return nil + } + items := make([]MetricItem, 0, len(r.Metrics)) + for _, name := range sortedMetricNames(r.Metrics) { + items = append(items, MetricItem{ + Name: name, + Label: MetricLabel(name), + Value: r.Metrics[name], + Unit: MetricUnit(name), + }) + } + return items } // Report 完整压测报告 @@ -52,6 +166,7 @@ type Report struct { EndTime time.Time Duration time.Duration Results []Result + SysInfo map[string]string // 系统信息(hostname/cpu/cores/memory/kernel/disk) Passed int Failed int Skipped int @@ -96,6 +211,13 @@ func (r *Report) ToText() string { icon = "⚠" } sb.WriteString(fmt.Sprintf(" %s %-12s %s\n", icon, res.Type, res.Status)) + if res.Score > 0 { + sb.WriteString(fmt.Sprintf(" 跑分: %.1f %s\n", res.Score, ScoreUnit(res.Type))) + } + for _, name := range sortedMetricNames(res.Metrics) { + unit := MetricUnit(name) + sb.WriteString(fmt.Sprintf(" %s: %.1f%s\n", MetricLabel(name), res.Metrics[name], unit)) + } for _, line := range strings.Split(res.Output, "\n") { if strings.TrimSpace(line) != "" { sb.WriteString(fmt.Sprintf(" %s\n", line)) diff --git a/auto-check/pkg/workflow/workflow.go b/auto-check/pkg/workflow/workflow.go index af63604..374d6ab 100644 --- a/auto-check/pkg/workflow/workflow.go +++ b/auto-check/pkg/workflow/workflow.go @@ -71,6 +71,10 @@ func (w *Workflow) round() { fmt.Printf(" [测试] %s (%s) 开始压测...\n", ip, dev.MAC) rpt := stress.TestDevice(ip, w.cfg) stress.Results[ip] = rpt + if stress.IsSSHFail(rpt) { + fmt.Printf(" [测试] %s SSH 连接失败,跳过报告输出: %s\n", ip, rpt.Results[0].Error) + continue + } report.SaveAndPrintDeviceReport(&dev, rpt, cfg.Report.Path, cfg.Report.Print) } diff --git a/auto-check/run.err b/auto-check/run.err new file mode 100644 index 0000000..e69de29 diff --git a/auto-check/run.log b/auto-check/run.log new file mode 100644 index 0000000..fc52167 --- /dev/null +++ b/auto-check/run.log @@ -0,0 +1,142 @@ + [配置] 加载完成: 网段=10.0.3.0/24 间隔=3m0s 压测=cpu,memory,disk,full +══ 工作流启动(间隔 3m0s,设备以 IP 为 key)══ + +════ 轮次开始 [13:42:10] ════ +[扫描] 网段 10.0.3.0/24,共 254 个IP,10 并发 ping 探测... +[扫描] 完成,发现 11 台存活设备 + [扫描] 设备 11 台 + [测试] 10.0.3.1 () 开始压测... + [测试] 10.0.3.1 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.1:22 失败: dial tcp 10.0.3.1:22: connectex: No connection could be made because the target machine actively refused it. + [测试] 10.0.3.104 () 开始压测... + [测试] 10.0.3.104 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.104:22 失败: dial tcp 10.0.3.104:22: connectex: No connection could be made because the target machine actively refused it. + [测试] 10.0.3.123 () 开始压测... + [测试] 10.0.3.123 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.123:22 失败: dial tcp 10.0.3.123:22: connectex: No connection could be made because the target machine actively refused it. + [测试] 10.0.3.152 () 开始压测... + [测试] 10.0.3.152 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.152:22 失败: dial tcp 10.0.3.152:22: connectex: No connection could be made because the target machine actively refused it. + [测试] 10.0.3.147 () 开始压测... + [测试] 10.0.3.147 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.147:22 失败: dial tcp 10.0.3.147:22: connectex: No connection could be made because the target machine actively refused it. + [测试] 10.0.3.148 () 开始压测... + [MAC] 10.0.3.148 -> fe:49:6a:ab:e1:79 + +════════ [10.0.3.148] 压力测试开始 ════════ + 工具: stress-ng(stress-ng, version 0.15.06 (gcc 12.2.0, x86_64 Linux 6.18.18.c877-trim)) iperf3(iperf 3.12 (cJSON 1.7.15)) fio(fio-3.33) + 指标: cpu memory disk full dmesg + [脚本] 拼装完成,10334 字节 + [报告] reports/report-10.0.3.148.html + +════════ [10.0.3.148] 压力测试完成 ════════ +══ [10.0.3.148] 压力测试报告 ══ + 耗时: 2m18.501s + + ✓ cpu pass + 跑分: 1285.6 ops/s + 最高温度: 57.0°C + 平均CPU利用率: 99.1% + stress-ng: info: [3652909] Working directory / is not read/writeable, some I/O tests may fail + stress-ng: info: [3652909] setting to a 30 second run per stressor + stress-ng: info: [3652909] dispatching hogs: 4 cpu + stress-ng: metrc: [3652909] stressor bogo ops real time usr time sys time bogo ops/s bogo ops/s + stress-ng: metrc: [3652909] (secs) (secs) (secs) (real time) (usr+sys time) + stress-ng: metrc: [3652909] cpu 38577 30.01 90.66 0.43 1285.60 423.51 + stress-ng: info: [3652909] successful run completed in 30.02s + ✓ memory pass + 跑分: 33472.4 ops/s + 最高温度: 58.0°C + 峰值内存使用率: 57.1% + stress-ng: info: [3653312] Working directory / is not read/writeable, some I/O tests may fail + stress-ng: info: [3653312] setting to a 30 second run per stressor + stress-ng: info: [3653312] dispatching hogs: 4 vm + stress-ng: metrc: [3653312] stressor bogo ops real time usr time sys time bogo ops/s bogo ops/s + stress-ng: metrc: [3653312] (secs) (secs) (secs) (real time) (usr+sys time) + stress-ng: metrc: [3653312] vm 1006646 30.07 47.26 5.75 33472.37 18990.03 + stress-ng: info: [3653312] successful run completed in 30.28s + ✓ disk pass + 跑分: 445.0 MB/s + 读IOPS: 77.0 + 写IOPS: 34.0 + 总IOPS: 111.0 + 读吞吐: 309.0MB/s + 写吞吐: 136.0MB/s + 总吞吐: 445.0MB/s + autocheck: (g=0): rw=randrw, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=psync, iodepth=32 + fio-3.33 + Starting 1 process + autocheck: Laying out IO file (1 file / 1024MiB) + note: both iodepth >= 1 and synchronous I/O engine are selected, queue depth will be capped at 1 + autocheck: (groupid=0, jobs=1): err= 0: pid=3653771: Thu Aug 13 13:44:37 2026 + read: IOPS=77, BW=309KiB/s (317kB/s)(9276KiB/30005msec) + clat (usec): min=253, max=830522, avg=11740.11, stdev=28335.80 + lat (usec): min=255, max=830525, avg=11742.04, stdev=28335.81 + clat percentiles (msec): + | 1.00th=[ 4], 5.00th=[ 5], 10.00th=[ 6], 20.00th=[ 7], + | 30.00th=[ 8], 40.00th=[ 9], 50.00th=[ 11], 60.00th=[ 12], + | 70.00th=[ 13], 80.00th=[ 14], 90.00th=[ 15], 95.00th=[ 16], + | 99.00th=[ 59], 99.50th=[ 65], 99.90th=[ 676], 99.95th=[ 802], + | 99.99th=[ 835] + bw ( KiB/s): min= 28, max= 420, per=98.98%, avg=306.62, stdev=113.30, samples=29 + iops : min= 7, max= 105, avg=76.55, stdev=28.40, samples=29 + write: IOPS=34, BW=136KiB/s (140kB/s)(4088KiB/30005msec); 0 zone resets + clat (usec): min=131, max=841470, avg=2638.71, stdev=36403.27 + lat (usec): min=131, max=841473, avg=2641.68, stdev=36403.26 + clat percentiles (usec): + | 1.00th=[ 188], 5.00th=[ 269], 10.00th=[ 318], 20.00th=[ 383], + | 30.00th=[ 449], 40.00th=[ 506], 50.00th=[ 586], 60.00th=[ 685], + | 70.00th=[ 824], 80.00th=[ 979], 90.00th=[ 1254], 95.00th=[ 1483], + | 99.00th=[ 9372], 99.50th=[ 15270], 99.90th=[767558], 99.95th=[843056], + | 99.99th=[843056] + bw ( KiB/s): min= 8, max= 211, per=99.09%, avg=135.72, stdev=55.82, samples=29 + iops : min= 2, max= 52, avg=33.83, stdev=14.03, samples=29 + lat (usec) : 250=1.11%, 500=10.72%, 750=8.20%, 1000=5.00% + lat (msec) : 2=4.88%, 4=1.65%, 10=33.49%, 20=33.10%, 50=0.57% + lat (msec) : 100=1.11%, 250=0.03%, 750=0.03%, 1000=0.12% + cpu : usr=0.46%, sys=2.25%, ctx=3357, majf=0, minf=13 + IO depths : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0% + submit : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0% + complete : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0% + issued rwts: total=2319,1022,0,0 short=0,0,0,0 dropped=0,0,0,0 + latency : target=0, window=0, percentile=100.00%, depth=32 + Run status group 0 (all jobs): + READ: bw=309KiB/s (317kB/s), 309KiB/s-309KiB/s (317kB/s-317kB/s), io=9276KiB (9499kB), run=30005-30005msec + WRITE: bw=136KiB/s (140kB/s), 136KiB/s-136KiB/s (140kB/s-140kB/s), io=4088KiB (4186kB), run=30005-30005msec + Disk stats (read/write): + sda: ios=2319/1057, merge=0/81, ticks=26764/7589, in_queue=38611, util=97.50% + ✓ full pass + 跑分: 40708.6 ops/s + 最高温度: 58.0°C + 平均CPU利用率: 95.8% + 峰值内存使用率: 51.2% + stress-ng: info: [3653901] Working directory / is not read/writeable, some I/O tests may fail + stress-ng: info: [3653901] setting to a 30 second run per stressor + stress-ng: info: [3653901] dispatching hogs: 4 cpu, 4 vm, 2 iomix + stress-ng: metrc: [3653901] stressor bogo ops real time usr time sys time bogo ops/s bogo ops/s + stress-ng: metrc: [3653901] (secs) (secs) (secs) (real time) (usr+sys time) + stress-ng: metrc: [3653901] cpu 22552 30.02 54.07 0.35 751.21 414.42 + stress-ng: metrc: [3653901] vm 1193352 30.01 45.94 3.66 39767.94 24057.59 + stress-ng: metrc: [3653901] iomix 5692 30.04 2.13 4.02 189.48 924.72 + stress-ng: info: [3653901] successful run completed in 30.08s + ✓ dmesg pass + 无硬件相关内核报错 + + 共 5 项: 5 通过, 0 失败, 0 跳过, 0 错误 + + [测试] 10.0.3.162 () 开始压测... + [测试] 10.0.3.162 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.162:22 失败: dial tcp 10.0.3.162:22: connectex: No connection could be made because the target machine actively refused it. + [测试] 10.0.3.119 () 开始压测... + [测试] 10.0.3.119 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.119:22 失败: dial tcp 10.0.3.119:22: connectex: No connection could be made because the target machine actively refused it. + [测试] 10.0.3.160 () 开始压测... + [测试] 10.0.3.160 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.160:22 失败: dial tcp 10.0.3.160:22: connectex: No connection could be made because the target machine actively refused it. + [测试] 10.0.3.171 () 开始压测... + [测试] 10.0.3.171 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.171:22 失败: dial tcp 10.0.3.171:22: connectex: No connection could be made because the target machine actively refused it. + [测试] 10.0.3.236 () 开始压测... + [测试] 10.0.3.236 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.236:22 失败: dial tcp 10.0.3.236:22: connectex: No connection could be made because the target machine actively refused it. + [状态] 10.0.3.160 ✗ → fail + [状态] 10.0.3.236 ✗ → fail + [状态] 10.0.3.1 ✗ → fail + [状态] 10.0.3.104 ✗ → fail + [状态] 10.0.3.147 ✗ → fail + [状态] 10.0.3.119 ✗ → fail + [状态] 10.0.3.171 ✗ → fail + [状态] 10.0.3.123 ✗ → fail + [状态] 10.0.3.152 ✗ → fail + [状态] 10.0.3.148 (fe:49:6a:ab:e1:79) ✓ → pass + [状态] 10.0.3.162 ✗ → fail diff --git a/auto-check/scripts/cpu.sh b/auto-check/scripts/cpu.sh index 00acf23..f7b57e6 100644 --- a/auto-check/scripts/cpu.sh +++ b/auto-check/scripts/cpu.sh @@ -1,13 +1,18 @@ echo "===TEST:cpu===" +# 读取温度(sensors 不可用时输出空) +read_temp() { + sensors 2>/dev/null | grep -oP '\+?\d+(\.\d+)?(?=°C)' | head -1 +} + # 后台采样 CPU 使用率 + 温度 SAMPLE_LOG=/tmp/auto-check-cpu-sample.log -> "$SAMPLE_LOG" +: > "$SAMPLE_LOG" ( while true; do TS=$(date +%H:%M:%S) - CPU=$(top -bn1 2>/dev/null | grep "Cpu(s)" | sed 's/.*,\s*\([0-9.]*\)%*\s*id.*/\1/' | awk '{printf "%.1f", 100-$1}' || echo 0) - TEMP=$(sensors 2>/dev/null | grep -iE 'Package|Core\s*0|temp1|CPU' | head -1 | grep -oP '\d+\.?\d*' | head -1 || echo 0) + CPU=$(top -bn1 2>/dev/null | grep "Cpu(s)" | sed -n 's/.*,\s*\([0-9.]*\)%*\s*id.*/\1/p' | awk '{printf "%.1f", 100-$1}' 2>/dev/null) + TEMP=$(read_temp) echo "${TS},${CPU},${TEMP}" >> "$SAMPLE_LOG" sleep "${AUTOCHECK_SAMPLE_INTERVAL:-2}" done @@ -16,7 +21,7 @@ SAMPLE_PID=$! START_TIME=$(date +%s%N) set +e -OUTPUT=$(stress-ng --cpu "${AUTOCHECK_THREADS:-4}" --cpu-method all --timeout "${AUTOCHECK_DURATION:-30}s" --metrics-brief --temp-path /tmp 2>&1) +OUTPUT=$(stress-ng --cpu "${AUTOCHECK_THREADS:-4}" --cpu-method all --timeout "${AUTOCHECK_DURATION:-30}s" --temp-path /tmp --metrics-brief 2>&1) EXIT_CODE=$? set -e END_TIME=$(date +%s%N) @@ -27,10 +32,27 @@ wait $SAMPLE_PID 2>/dev/null || true DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 )) if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi echo "duration:${DURATION_MS}ms" + +# 跑分:各 stressor 的 bogo ops/s 之和 +SCORE=$(echo "$OUTPUT" | awk ' +/^stress-ng:/ && ($4=="cpu" || $4=="vm" || $4=="iomix" || $4=="memrate") { + v=$(NF-1)+0; if (v>0) s+=v +} +END { printf "%.1f", s+0 }') +echo "score:${SCORE}" + +# 关键指标 +AVG_CPU=$(awk -F',' '$2!="" {s+=$2; n++} END{printf "%.1f", (n?s/n:0)}' "$SAMPLE_LOG") +echo "metric:avg_cpu=${AVG_CPU}" +MAX_TEMP=$(awk -F',' '$3!="" && $3+0>m {m=$3} END{printf "%.1f", m+0}' "$SAMPLE_LOG") +HAS_TEMP=$(awk -F',' '$3!="" && $3+0>0{f=1} END{print f+0}' "$SAMPLE_LOG") +if [ "$HAS_TEMP" = "1" ]; then echo "metric:max_temp=${MAX_TEMP}"; fi + echo "output:${OUTPUT}" -# 输出采样数据 +# 采样数据(首行表头) echo "===SAMPLES===" +echo "time,cpu,temp" cat "$SAMPLE_LOG" echo "===END_SAMPLES===" diff --git a/auto-check/scripts/disk.sh b/auto-check/scripts/disk.sh index 472ee6b..a07239f 100644 --- a/auto-check/scripts/disk.sh +++ b/auto-check/scripts/disk.sh @@ -1,14 +1,77 @@ echo "===TEST:disk===" -TEST_DIR=${AUTOCHECK_DISK_DIR:-/tmp/stress-disk-test} -DISK_SIZE_MB=${AUTOCHECK_DISK_SIZE_MB:-1024} + +TEST_DIR=${AUTOCHECK_DISK_DIR:-/tmp/auto-check-disk} +mkdir -p "$TEST_DIR" + START_TIME=$(date +%s%N) -set +e -OUTPUT=$(mkdir -p "${TEST_DIR}" && stress-ng --iomix 2 --iomix-bytes "${DISK_SIZE_MB}M" --timeout "${AUTOCHECK_DURATION:-30}s" --metrics-brief 2>&1; rm -rf "${TEST_DIR}") -EXIT_CODE=$? -set -e -END_TIME=$(date +%s%N) -DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 )) -if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi -echo "duration:${DURATION_MS}ms" -echo "output:${OUTPUT}" + +if command -v fio >/dev/null 2>&1; then + FIO_IMG="$TEST_DIR/fio.img" + BW_LOG="$TEST_DIR/ac-bw" + IOPS_LOG="$TEST_DIR/ac-iops" + rm -f "$BW_LOG"*.log "$IOPS_LOG"*.log "$FIO_IMG" + + set +e + OUTPUT=$(fio --name=autocheck --rw=randrw --rwmixread=70 --bs=4k \ + --size="${AUTOCHECK_DISK_SIZE_MB:-512}M" --iodepth=32 --numjobs=1 \ + --runtime="${AUTOCHECK_DURATION:-30}" --time_based --direct=1 \ + --group_reporting --output-format=normal \ + --write_bw_log="$BW_LOG" --write_iops_log="$IOPS_LOG" \ + --log_avg_msec=1000 --filename="$FIO_IMG" 2>&1) + EXIT_CODE=$? + set -e + + READ_LINE=$(echo "$OUTPUT" | grep -E '^[[:space:]]*read:' | head -1) + WRITE_LINE=$(echo "$OUTPUT" | grep -E '^[[:space:]]*write:' | head -1) + READ_IOPS=$(echo "$READ_LINE" | grep -oP 'IOPS=\K[0-9.]+' | head -1) + READ_BW=$(echo "$READ_LINE" | grep -oP 'BW=\K[0-9.]+' | head -1) + WRITE_IOPS=$(echo "$WRITE_LINE" | grep -oP 'IOPS=\K[0-9.]+' | head -1) + WRITE_BW=$(echo "$WRITE_LINE" | grep -oP 'BW=\K[0-9.]+' | head -1) + SCORE=$(awk -v r="${READ_BW:-0}" -v w="${WRITE_BW:-0}" 'BEGIN{printf "%.1f", r+w}') + IOPS_TOTAL=$(awk -v r="${READ_IOPS:-0}" -v w="${WRITE_IOPS:-0}" 'BEGIN{printf "%.1f", r+w}') + + END_TIME=$(date +%s%N) + DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 )) + if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi + echo "duration:${DURATION_MS}ms" + echo "score:${SCORE}" + echo "metric:iops_total=${IOPS_TOTAL}" + [ -n "$READ_IOPS" ] && echo "metric:read_iops=${READ_IOPS}" + [ -n "$WRITE_IOPS" ] && echo "metric:write_iops=${WRITE_IOPS}" + [ -n "$READ_BW" ] && echo "metric:read_bw=${READ_BW}" + [ -n "$WRITE_BW" ] && echo "metric:write_bw=${WRITE_BW}" + [ -n "$READ_BW" ] && [ -n "$WRITE_BW" ] && echo "metric:bw_total=${SCORE}" + echo "output:${OUTPUT}" + + # IOPS / 吞吐 曲线(按秒聚合) + BW_LOG_FILE=$(ls "$BW_LOG"*.log 2>/dev/null | head -1) + IOPS_LOG_FILE=$(ls "$IOPS_LOG"*.log 2>/dev/null | head -1) + echo "===SAMPLES===" + echo "time,iops_total,bw_total" + if [ -n "$BW_LOG_FILE" ] && [ -n "$IOPS_LOG_FILE" ]; then + awk -F',' ' + FNR==NR { t=int($1/1000); if($3==0) ri[t]=$2; else wi[t]=$2; seen[t]=1; next } + { t=int($1/1000); if($3==0) rb[t]=$2; else wb[t]=$2; seen[t]=1 } + END { for (k in seen) printf "%d,%.0f,%.1f\n", k, ri[k]+wi[k], (rb[k]+wb[k])/1024 } + ' "$IOPS_LOG_FILE" "$BW_LOG_FILE" | sort -n + fi + echo "===END_SAMPLES===" + + rm -f "$FIO_IMG" "$BW_LOG"*.log "$IOPS_LOG"*.log +else + # 无 fio,降级为 stress-ng 磁盘 IO 压测(无 IOPS/吞吐曲线) + set +e + OUTPUT=$(stress-ng --iomix "${AUTOCHECK_THREADS:-2}" --iomix-bytes "${AUTOCHECK_DISK_MB:-512}M" --timeout "${AUTOCHECK_DURATION:-30}s" --temp-path /tmp --metrics-brief 2>&1) + EXIT_CODE=$? + set -e + SCORE=$(echo "$OUTPUT" | awk '/^stress-ng:/ && $4=="iomix" { v=$(NF-1)+0; if(v>0) s+=v } END{printf "%.1f", s+0}') + + END_TIME=$(date +%s%N) + DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 )) + if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi + echo "duration:${DURATION_MS}ms" + echo "score:${SCORE}" + echo "output:${OUTPUT}" +fi + echo '' diff --git a/auto-check/scripts/full.sh b/auto-check/scripts/full.sh index d33a950..4aeb54b 100644 --- a/auto-check/scripts/full.sh +++ b/auto-check/scripts/full.sh @@ -1,12 +1,71 @@ echo "===TEST:full===" + +# 读取温度(sensors 不可用时输出空) +read_temp() { + sensors 2>/dev/null | grep -oP '\+?\d+(\.\d+)?(?=°C)' | head -1 +} + +# 后台采样 CPU + 内存 + 温度 +SAMPLE_LOG=/tmp/auto-check-full-sample.log +: > "$SAMPLE_LOG" +( + while true; do + TS=$(date +%H:%M:%S) + CPU=$(top -bn1 2>/dev/null | grep "Cpu(s)" | sed -n 's/.*,\s*\([0-9.]*\)%*\s*id.*/\1/p' | awk '{printf "%.1f", 100-$1}' 2>/dev/null) + MEM_TOTAL=$(awk '/^MemTotal:/{print $2}' /proc/meminfo) + MEM_AVAIL=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo) + if [ -z "$MEM_AVAIL" ] || [ "$MEM_AVAIL" = "0" ]; then + MEM_AVAIL=$(awk '/^MemFree:/{print $2}' /proc/meminfo) + fi + MEM_PCT=$(awk -v t="$MEM_TOTAL" -v a="$MEM_AVAIL" 'BEGIN{if(t>0) printf "%.1f", (t-a)/t*100}') + TEMP=$(read_temp) + echo "${TS},${CPU},${MEM_PCT},${TEMP}" >> "$SAMPLE_LOG" + sleep "${AUTOCHECK_SAMPLE_INTERVAL:-2}" + done +) & +SAMPLE_PID=$! + START_TIME=$(date +%s%N) set +e -OUTPUT=$(stress-ng --cpu "${AUTOCHECK_THREADS:-4}" --vm 2 --vm-bytes 128M --iomix 1 --iomix-bytes 256M --timeout "${AUTOCHECK_DURATION:-30}s" --metrics-brief 2>&1) +OUTPUT=$(stress-ng \ + --cpu "${AUTOCHECK_THREADS:-4}" --cpu-method all \ + --vm "${AUTOCHECK_THREADS:-4}" --vm-bytes "${AUTOCHECK_MEM_MB:-256}M" --vm-method all \ + --iomix 2 --iomix-bytes "${AUTOCHECK_DISK_SIZE_MB:-512}M" \ + --timeout "${AUTOCHECK_DURATION:-30}s" --temp-path /tmp --metrics-brief 2>&1) EXIT_CODE=$? set -e END_TIME=$(date +%s%N) + +kill $SAMPLE_PID 2>/dev/null || true +wait $SAMPLE_PID 2>/dev/null || true + DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 )) if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi echo "duration:${DURATION_MS}ms" + +# 跑分:各 stressor 的 bogo ops/s 之和 +SCORE=$(echo "$OUTPUT" | awk ' +/^stress-ng:/ && ($4=="cpu" || $4=="vm" || $4=="iomix" || $4=="memrate") { + v=$(NF-1)+0; if (v>0) s+=v +} +END { printf "%.1f", s+0 }') +echo "score:${SCORE}" + +# 关键指标 +AVG_CPU=$(awk -F',' '$2!="" {s+=$2; n++} END{printf "%.1f", (n?s/n:0)}' "$SAMPLE_LOG") +echo "metric:avg_cpu=${AVG_CPU}" +MAX_MEM=$(awk -F',' '$3!="" && $3+0>m {m=$3} END{printf "%.1f", m+0}' "$SAMPLE_LOG") +echo "metric:mem_used_pct=${MAX_MEM}" +MAX_TEMP=$(awk -F',' '$4!="" && $4+0>m {m=$4} END{printf "%.1f", m+0}' "$SAMPLE_LOG") +HAS_TEMP=$(awk -F',' '$4!="" && $4+0>0{f=1} END{print f+0}' "$SAMPLE_LOG") +if [ "$HAS_TEMP" = "1" ]; then echo "metric:max_temp=${MAX_TEMP}"; fi + echo "output:${OUTPUT}" + +# 采样数据(首行表头) +echo "===SAMPLES===" +echo "time,cpu,mem_used_pct,temp" +cat "$SAMPLE_LOG" +echo "===END_SAMPLES===" + echo '' diff --git a/auto-check/scripts/memory.sh b/auto-check/scripts/memory.sh index bd3db6a..479d02b 100644 --- a/auto-check/scripts/memory.sh +++ b/auto-check/scripts/memory.sh @@ -1,14 +1,64 @@ echo "===TEST:memory===" -VM_WORKERS=${AUTOCHECK_THREADS:-4} -if [ "$VM_WORKERS" -gt 4 ]; then VM_WORKERS=4; fi + +# 读取温度(sensors 不可用时输出空) +read_temp() { + sensors 2>/dev/null | grep -oP '\+?\d+(\.\d+)?(?=°C)' | head -1 +} + +# 后台采样内存使用率 + 温度 +SAMPLE_LOG=/tmp/auto-check-memory-sample.log +: > "$SAMPLE_LOG" +( + while true; do + TS=$(date +%H:%M:%S) + MEM_TOTAL=$(awk '/^MemTotal:/{print $2}' /proc/meminfo) + MEM_AVAIL=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo) + if [ -z "$MEM_AVAIL" ] || [ "$MEM_AVAIL" = "0" ]; then + MEM_AVAIL=$(awk '/^MemFree:/{print $2}' /proc/meminfo) + fi + MEM_PCT=$(awk -v t="$MEM_TOTAL" -v a="$MEM_AVAIL" 'BEGIN{if(t>0) printf "%.1f", (t-a)/t*100}') + TEMP=$(read_temp) + echo "${TS},${MEM_PCT},${TEMP}" >> "$SAMPLE_LOG" + sleep "${AUTOCHECK_SAMPLE_INTERVAL:-2}" + done +) & +SAMPLE_PID=$! + START_TIME=$(date +%s%N) set +e -OUTPUT=$(stress-ng --vm "${VM_WORKERS}" --vm-bytes 256M --vm-method all --timeout "${AUTOCHECK_DURATION:-30}s" --metrics-brief 2>&1) +OUTPUT=$(stress-ng --vm "${AUTOCHECK_THREADS:-4}" --vm-bytes "${AUTOCHECK_MEM_MB:-256}M" --vm-method all --timeout "${AUTOCHECK_DURATION:-30}s" --temp-path /tmp --metrics-brief 2>&1) EXIT_CODE=$? set -e END_TIME=$(date +%s%N) + +kill $SAMPLE_PID 2>/dev/null || true +wait $SAMPLE_PID 2>/dev/null || true + DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 )) if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi echo "duration:${DURATION_MS}ms" + +# 跑分:各 stressor 的 bogo ops/s 之和 +SCORE=$(echo "$OUTPUT" | awk ' +/^stress-ng:/ && ($4=="cpu" || $4=="vm" || $4=="iomix" || $4=="memrate") { + v=$(NF-1)+0; if (v>0) s+=v +} +END { printf "%.1f", s+0 }') +echo "score:${SCORE}" + +# 关键指标 +MAX_MEM=$(awk -F',' '$2!="" && $2+0>m {m=$2} END{printf "%.1f", m+0}' "$SAMPLE_LOG") +echo "metric:mem_used_pct=${MAX_MEM}" +MAX_TEMP=$(awk -F',' '$3!="" && $3+0>m {m=$3} END{printf "%.1f", m+0}' "$SAMPLE_LOG") +HAS_TEMP=$(awk -F',' '$3!="" && $3+0>0{f=1} END{print f+0}' "$SAMPLE_LOG") +if [ "$HAS_TEMP" = "1" ]; then echo "metric:max_temp=${MAX_TEMP}"; fi + echo "output:${OUTPUT}" + +# 采样数据(首行表头) +echo "===SAMPLES===" +echo "time,mem_used_pct,temp" +cat "$SAMPLE_LOG" +echo "===END_SAMPLES===" + echo '' diff --git a/auto-check/scripts/temp_end.sh b/auto-check/scripts/temp_end.sh index 7b216ef..a510e5f 100644 --- a/auto-check/scripts/temp_end.sh +++ b/auto-check/scripts/temp_end.sh @@ -1,12 +1,15 @@ echo '===MONITOR:temp===' if [ -f "$TEMP_LOG" ] && [ -s "$TEMP_LOG" ]; then + MAX_T=$(sort -n "$TEMP_LOG" | tail -1) + AVG_T=$(awk '{s+=$1; n++} END{printf "%.1f", (n?s/n:0)}' "$TEMP_LOG") + CNT=$(wc -l < "$TEMP_LOG") echo 'status:pass' - TEMP_MAX=$(grep -oP '\d+\.?\d*°C' "$TEMP_LOG" | sort -t. -k1 -n | tail -1 || echo 'unknown') - echo "samples:$(wc -l < "$TEMP_LOG")" - echo "max:${TEMP_MAX}" - echo "output:$(tail -10 "$TEMP_LOG")" + echo "metric:max_temp=${MAX_T}" + echo "metric:avg_temp=${AVG_T}" + echo "output:采样 ${CNT} 次, 最高 ${MAX_T}°C, 平均 ${AVG_T}°C" else echo 'status:skip' + echo 'output:未采集到温度数据' fi echo '' kill $TEMP_PID 2>/dev/null || true diff --git a/auto-check/scripts/temp_start.sh b/auto-check/scripts/temp_start.sh index 1dba5e7..ebcd309 100644 --- a/auto-check/scripts/temp_start.sh +++ b/auto-check/scripts/temp_start.sh @@ -1,4 +1,8 @@ TEMP_LOG=/tmp/auto-check-temp.log > "$TEMP_LOG" -(while true; do sensors 2>/dev/null | grep -i 'temp\|core\|cpu' | head -5 >> "$TEMP_LOG"; sleep "${AUTOCHECK_TEMP_INTERVAL:-10}"; done) & +(while true; do + T=$(sensors 2>/dev/null | grep -oP '\+?\d+(\.\d+)?(?=°C)' | head -1) + [ -n "$T" ] && echo "$T" >> "$TEMP_LOG" + sleep "${AUTOCHECK_TEMP_INTERVAL:-10}" +done) & TEMP_PID=$!