feat(cmd): add logpile-cpu-audit offline CPU identity audit CLI

Non-server entry point over the parser registry: recursively discovers
supported dumps, parses them with bounded concurrency, and writes one
path-mirrored CPU identity report per input plus a deterministic summary.
Failures are isolated as JSON. A -reanimator mode emits a flat CPU-only
dataset via the production exporter.

See ADL-059.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-08-27 17:05:52 +03:00
co-authored by Claude Sonnet 5
parent a430a8c46f
commit fc68134ed9
2 changed files with 612 additions and 0 deletions
+482
View File
@@ -0,0 +1,482 @@
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
}
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"os"
"path/filepath"
"testing"
"git.mchus.pro/mchus/logpile/internal/exporter"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestIsAuditCandidate(t *testing.T) {
tests := []struct {
name string
path string
includePlain bool
want bool
}{
{name: "tar gzip", path: "dump.tar.gz", want: true},
{name: "zip", path: "dump.zip", want: true},
{name: "plain log excluded", path: "component.log", want: false},
{name: "plain log requested", path: "nvidia-bug-report.log", includePlain: true, want: true},
{name: "JSON report excluded", path: "report.json", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isAuditCandidate(tt.path, tt.includePlain); got != tt.want {
t.Fatalf("isAuditCandidate(%q, %v) = %v, want %v", tt.path, tt.includePlain, got, tt.want)
}
})
}
}
func TestCPUOnlyReanimatorPayloadPreservesRequiredFields(t *testing.T) {
payload := &exporter.ReanimatorExport{
Filename: "dump.tar.gz",
CollectedAt: "2026-07-27T10:34:40Z",
Hardware: exporter.ReanimatorHardware{
Board: exporter.ReanimatorBoard{SerialNumber: "23E102624"},
CPUs: []exporter.ReanimatorCPU{{Socket: 0, SerialNumber: "D46E5D6B1D3E40E1"}},
Firmware: []exporter.ReanimatorFirmware{{DeviceName: "BIOS", Version: "1.0"}},
},
}
got := cpuOnlyReanimatorPayload(payload)
if got.CollectedAt == "" || got.Hardware.Board.SerialNumber == "" || len(got.Hardware.CPUs) != 1 {
t.Fatalf("required ingest fields were lost: %+v", got)
}
if len(got.Hardware.Firmware) != 0 {
t.Fatalf("expected non-CPU hardware to be removed: %+v", got.Hardware.Firmware)
}
}
func TestFlatReanimatorName(t *testing.T) {
got := flatReanimatorName("v1", "NL/example/dump.tar.gz")
if got != "v1__dump.tar.gz.reanimator.json" {
t.Fatalf("unexpected flat name %q", got)
}
}
func TestBuildCPUAudit(t *testing.T) {
tests := []struct {
name string
cpu models.CPU
finding string
promoted bool
expectedSer string
}{
{
name: "PPIN promoted",
cpu: models.CPU{Socket: 0, PPIN: "D46E5D6B1D3E40E1", SerialNumber: "D46E5D6B1D3E40E1"},
finding: "ppin_used_as_serial",
promoted: true,
expectedSer: "D46E5D6B1D3E40E1",
},
{
name: "regression detected",
cpu: models.CPU{Socket: 1, PPIN: "D44F8D6B9155EE0E"},
finding: "missing_serial_with_ppin",
expectedSer: "D44F8D6B9155EE0E",
},
{
name: "placeholder serial with PPIN",
cpu: models.CPU{Socket: 1, PPIN: "D44F8D6B9155EE0E", SerialNumber: "Unknown"},
finding: "missing_serial_with_ppin",
expectedSer: "D44F8D6B9155EE0E",
},
{
name: "native serial",
cpu: models.CPU{Socket: 0, SerialNumber: "CPU-SERIAL"},
finding: "source_serial_present",
expectedSer: "CPU-SERIAL",
},
{
name: "placeholder PPIN",
cpu: models.CPU{Socket: 0, PPIN: "N/A"},
finding: "cpu_identity_missing",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildCPUAudit(tt.cpu)
if got.Finding != tt.finding || got.PPINPromoted != tt.promoted || got.ExpectedSerialNumber != tt.expectedSer {
t.Fatalf("unexpected audit: %+v", got)
}
})
}
}
func TestRunAuditWritesFailureReportAndSummary(t *testing.T) {
input := t.TempDir()
output := t.TempDir()
badArchive := filepath.Join(input, "bad.zip")
if err := os.WriteFile(badArchive, []byte("not a zip"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
summary, err := runAudit(input, output, 1, false)
if err != nil {
t.Fatalf("runAudit failed: %v", err)
}
if summary.FilesFound != 1 || summary.FilesFailed != 1 || summary.FilesParsed != 0 {
t.Fatalf("unexpected summary: %+v", summary)
}
for _, name := range []string{"bad.zip.cpu-audit.json", "_summary.json"} {
if _, err := os.Stat(filepath.Join(output, name)); err != nil {
t.Fatalf("expected %s: %v", name, err)
}
}
}