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>
483 lines
14 KiB
Go
483 lines
14 KiB
Go
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
|
|
}
|