package main import ( "crypto/sha256" "encoding/json" "flag" "fmt" "io/fs" "log/slog" "os" "path/filepath" "runtime" "sort" "strings" "sync" "time" "git.mchus.pro/mchus/logpile/internal/exporter" "git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/parser" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors" ) const auditSchema = "logpile.cpu-identity-audit.v1" type cpuAudit struct { Socket int `json:"socket"` Model string `json:"model,omitempty"` PPIN string `json:"ppin,omitempty"` SerialNumber string `json:"serial_number,omitempty"` ExpectedSerialNumber string `json:"expected_serial_number,omitempty"` PPINPromoted bool `json:"ppin_promoted"` Finding string `json:"finding"` } type parserAudit struct { Vendor string `json:"vendor,omitempty"` Name string `json:"name,omitempty"` Version string `json:"version,omitempty"` } type fileAudit struct { Schema string `json:"schema"` SourceFile string `json:"source_file"` Parser parserAudit `json:"parser,omitempty"` CPUs []cpuAudit `json:"cpus"` Error string `json:"error,omitempty"` } type failedAudit struct { SourceFile string `json:"source_file"` Error string `json:"error"` } type auditSummary struct { Schema string `json:"schema"` InputRoot string `json:"input_root"` OutputRoot string `json:"output_root"` FilesFound int `json:"files_found"` FilesParsed int `json:"files_parsed"` FilesFailed int `json:"files_failed"` FilesWithCPU int `json:"files_with_cpu"` FilesNoCPU int `json:"files_without_cpu"` CPUsFound int `json:"cpus_found"` FindingCount map[string]int `json:"finding_count"` Failures []failedAudit `json:"failures,omitempty"` } type auditJob struct { index int sourcePath string relative string } type auditResult struct { index int report fileAudit err error } type reanimatorResult struct { index int sourceFile string payload *exporter.ReanimatorExport cpus []cpuAudit err error noCPU bool } func main() { input := flag.String("input", "", "Dump file or directory to scan recursively") output := flag.String("output", "", "Directory for CPU audit JSON files") workers := flag.Int("workers", max(1, min(runtime.NumCPU(), 4)), "Number of dumps parsed concurrently") includePlain := flag.Bool("include-plain", false, "Also scan standalone .txt and .log inputs inside directories") reanimatorMode := flag.Bool("reanimator", false, "Write flat CPU-only Reanimator ingest JSON files") prefix := flag.String("prefix", "", "Filename prefix for flat Reanimator output, for example v0") flag.Parse() if strings.TrimSpace(*input) == "" || strings.TrimSpace(*output) == "" { flag.Usage() os.Exit(2) } if *workers < 1 { slog.Error("invalid worker count", "workers", *workers) os.Exit(2) } started := time.Now() var summary auditSummary var err error if *reanimatorMode { summary, err = runReanimatorExport(*input, *output, *prefix, *workers, *includePlain) } else { summary, err = runAudit(*input, *output, *workers, *includePlain) } if err != nil { slog.Error("CPU identity audit failed", "err", err) os.Exit(1) } slog.Info("CPU identity audit completed", "files", summary.FilesFound, "parsed", summary.FilesParsed, "failed", summary.FilesFailed, "cpus", summary.CPUsFound, "duration", time.Since(started).Round(time.Millisecond), "output", summary.OutputRoot, ) if summary.FilesFailed > 0 { os.Exit(2) } } func runReanimatorExport(inputPath, outputPath, prefix string, workers int, includePlain bool) (auditSummary, error) { inputRoot, err := filepath.Abs(inputPath) if err != nil { return auditSummary{}, fmt.Errorf("resolve input path: %w", err) } outputRoot, err := filepath.Abs(outputPath) if err != nil { return auditSummary{}, fmt.Errorf("resolve output path: %w", err) } info, err := os.Stat(inputRoot) if err != nil { return auditSummary{}, fmt.Errorf("inspect input: %w", err) } if err := os.MkdirAll(outputRoot, 0o755); err != nil { return auditSummary{}, fmt.Errorf("create output directory: %w", err) } jobs, err := collectAuditJobs(inputRoot, outputRoot, info.IsDir(), includePlain) if err != nil { return auditSummary{}, err } summary := auditSummary{ Schema: auditSchema, InputRoot: inputRoot, OutputRoot: outputRoot, FilesFound: len(jobs), FindingCount: make(map[string]int), } jobCh := make(chan auditJob) resultCh := make(chan reanimatorResult) var group sync.WaitGroup for range workers { group.Add(1) go func() { defer group.Done() for job := range jobCh { resultCh <- processReanimatorJob(job) } }() } go func() { for _, job := range jobs { jobCh <- job } close(jobCh) group.Wait() close(resultCh) }() completed := 0 for result := range resultCh { completed++ if result.err != nil { summary.FilesFailed++ summary.Failures = append(summary.Failures, failedAudit{SourceFile: result.sourceFile, Error: result.err.Error()}) } else { summary.FilesParsed++ if result.noCPU { summary.FilesNoCPU++ } else { summary.FilesWithCPU++ summary.CPUsFound += len(result.payload.Hardware.CPUs) for _, cpu := range result.cpus { summary.FindingCount[cpu.Finding]++ } name := flatReanimatorName(prefix, result.sourceFile) path := filepath.Join(outputRoot, name) if _, statErr := os.Stat(path); statErr == nil { hash := fmt.Sprintf("%x", sha256.Sum256([]byte(result.sourceFile)))[:8] name = flatReanimatorName(prefix+"__"+hash, result.sourceFile) path = filepath.Join(outputRoot, name) } if err := writeJSON(path, result.payload); err != nil { return auditSummary{}, fmt.Errorf("write Reanimator payload %s: %w", path, err) } } } if completed%25 == 0 || completed == len(jobs) || result.err != nil { slog.Info("CPU Reanimator export progress", "completed", completed, "total", len(jobs), "source", result.sourceFile) } } return summary, nil } func processReanimatorJob(job auditJob) reanimatorResult { result := reanimatorResult{index: job.index, sourceFile: filepath.ToSlash(job.relative)} bmcParser := parser.NewBMCParser() if err := bmcParser.ParseArchive(job.sourcePath); err != nil { result.err = err return result } parsed := bmcParser.Result() if parsed == nil || parsed.Hardware == nil || len(parsed.Hardware.CPUs) == 0 { result.noCPU = true return result } parsed.Filename = filepath.Base(job.sourcePath) converted, err := exporter.ConvertToReanimator(parsed) if err != nil { result.err = err return result } result.payload = cpuOnlyReanimatorPayload(converted) for _, cpu := range parsed.Hardware.CPUs { result.cpus = append(result.cpus, buildCPUAudit(cpu)) } return result } func cpuOnlyReanimatorPayload(converted *exporter.ReanimatorExport) *exporter.ReanimatorExport { converted.Hardware = exporter.ReanimatorHardware{ Board: converted.Hardware.Board, CPUs: converted.Hardware.CPUs, } return converted } func flatReanimatorName(prefix, sourceFile string) string { name := filepath.Base(sourceFile) + ".reanimator.json" prefix = strings.Trim(strings.TrimSpace(prefix), "_-") if prefix == "" { return name } return prefix + "__" + name } func runAudit(inputPath, outputPath string, workers int, includePlain bool) (auditSummary, error) { inputRoot, err := filepath.Abs(inputPath) if err != nil { return auditSummary{}, fmt.Errorf("resolve input path: %w", err) } outputRoot, err := filepath.Abs(outputPath) if err != nil { return auditSummary{}, fmt.Errorf("resolve output path: %w", err) } info, err := os.Stat(inputRoot) if err != nil { return auditSummary{}, fmt.Errorf("inspect input: %w", err) } if err := os.MkdirAll(outputRoot, 0o755); err != nil { return auditSummary{}, fmt.Errorf("create output directory: %w", err) } jobs, err := collectAuditJobs(inputRoot, outputRoot, info.IsDir(), includePlain) if err != nil { return auditSummary{}, err } summary := auditSummary{ Schema: auditSchema, InputRoot: inputRoot, OutputRoot: outputRoot, FilesFound: len(jobs), FindingCount: make(map[string]int), } jobCh := make(chan auditJob) resultCh := make(chan auditResult) var group sync.WaitGroup for range workers { group.Add(1) go func() { defer group.Done() for job := range jobCh { resultCh <- processAuditJob(job) } }() } go func() { for _, job := range jobs { jobCh <- job } close(jobCh) group.Wait() close(resultCh) }() results := make([]auditResult, len(jobs)) completed := 0 for result := range resultCh { results[result.index] = result completed++ if completed%25 == 0 || completed == len(jobs) || result.err != nil { slog.Info("CPU identity audit progress", "completed", completed, "total", len(jobs), "source", result.report.SourceFile) } } for _, result := range results { reportPath := filepath.Join(outputRoot, result.report.SourceFile+".cpu-audit.json") if err := writeJSON(reportPath, result.report); err != nil { return auditSummary{}, fmt.Errorf("write report %s: %w", reportPath, err) } if result.err != nil { summary.FilesFailed++ summary.Failures = append(summary.Failures, failedAudit{ SourceFile: result.report.SourceFile, Error: result.err.Error(), }) continue } summary.FilesParsed++ summary.CPUsFound += len(result.report.CPUs) if len(result.report.CPUs) == 0 { summary.FilesNoCPU++ } else { summary.FilesWithCPU++ } for _, cpu := range result.report.CPUs { summary.FindingCount[cpu.Finding]++ } } if err := writeJSON(filepath.Join(outputRoot, "_summary.json"), summary); err != nil { return auditSummary{}, fmt.Errorf("write summary: %w", err) } return summary, nil } func collectAuditJobs(inputRoot, outputRoot string, inputIsDir, includePlain bool) ([]auditJob, error) { if !inputIsDir { if !parser.IsSupportedArchiveFilename(inputRoot) { return nil, fmt.Errorf("unsupported input file: %s", inputRoot) } return []auditJob{{sourcePath: inputRoot, relative: filepath.Base(inputRoot)}}, nil } var jobs []auditJob err := filepath.WalkDir(inputRoot, func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return fmt.Errorf("walk %s: %w", path, walkErr) } if entry.IsDir() { if path == outputRoot && path != inputRoot { return filepath.SkipDir } return nil } if !isAuditCandidate(path, includePlain) { return nil } relative, err := filepath.Rel(inputRoot, path) if err != nil { return fmt.Errorf("resolve relative path for %s: %w", path, err) } jobs = append(jobs, auditJob{sourcePath: path, relative: relative}) return nil }) if err != nil { return nil, fmt.Errorf("scan input directory: %w", err) } sort.Slice(jobs, func(i, j int) bool { return jobs[i].relative < jobs[j].relative }) for i := range jobs { jobs[i].index = i } return jobs, nil } func isAuditCandidate(path string, includePlain bool) bool { if !parser.IsSupportedArchiveFilename(path) { return false } ext := strings.ToLower(filepath.Ext(path)) return includePlain || (ext != ".txt" && ext != ".log") } func processAuditJob(job auditJob) auditResult { report := fileAudit{ Schema: auditSchema, SourceFile: filepath.ToSlash(job.relative), CPUs: []cpuAudit{}, } bmcParser := parser.NewBMCParser() if err := bmcParser.ParseArchive(job.sourcePath); err != nil { report.Error = err.Error() return auditResult{index: job.index, report: report, err: err} } report.Parser = detectedParserAudit(bmcParser.DetectedVendor()) result := bmcParser.Result() if result == nil || result.Hardware == nil { return auditResult{index: job.index, report: report} } for _, cpu := range result.Hardware.CPUs { report.CPUs = append(report.CPUs, buildCPUAudit(cpu)) } sort.SliceStable(report.CPUs, func(i, j int) bool { return report.CPUs[i].Socket < report.CPUs[j].Socket }) return auditResult{index: job.index, report: report} } func detectedParserAudit(name string) parserAudit { for _, info := range parser.ListParsersInfo() { if info.Name == name { return parserAudit{Vendor: info.Vendor, Name: info.Name, Version: info.Version} } } return parserAudit{Name: name} } func buildCPUAudit(cpu models.CPU) cpuAudit { ppin := strings.TrimSpace(cpu.PPIN) serial := strings.TrimSpace(cpu.SerialNumber) validPPIN := models.ResolveCPUSerialNumber("", ppin) validSerial := models.ResolveCPUSerialNumber(serial, "") expected := models.ResolveCPUSerialNumber(serial, ppin) report := cpuAudit{ Socket: cpu.Socket, Model: strings.TrimSpace(cpu.Model), PPIN: ppin, SerialNumber: serial, ExpectedSerialNumber: expected, PPINPromoted: validPPIN != "" && validSerial == validPPIN, } switch { case validPPIN != "" && validSerial == "": report.Finding = "missing_serial_with_ppin" case validPPIN != "" && validSerial == validPPIN: report.Finding = "ppin_used_as_serial" case validSerial != "": report.Finding = "source_serial_present" default: report.Finding = "cpu_identity_missing" } return report } func writeJSON(path string, value any) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("create parent directory: %w", err) } payload, err := json.MarshalIndent(value, "", " ") if err != nil { return fmt.Errorf("encode JSON: %w", err) } payload = append(payload, '\n') temporary, err := os.CreateTemp(filepath.Dir(path), ".cpu-audit-*.tmp") if err != nil { return fmt.Errorf("create temporary file: %w", err) } temporaryPath := temporary.Name() defer os.Remove(temporaryPath) if _, err := temporary.Write(payload); err != nil { temporary.Close() return fmt.Errorf("write temporary file: %w", err) } if err := temporary.Close(); err != nil { return fmt.Errorf("close temporary file: %w", err) } if err := os.Rename(temporaryPath, path); err != nil { return fmt.Errorf("replace report: %w", err) } return nil }