package server import ( "archive/zip" "bytes" "encoding/base64" "encoding/json" "fmt" "io" "strings" "time" "git.mchus.pro/mchus/logpile/internal/models" ) const rawExportFormatV1 = "logpile.raw-export.v1" const ( rawExportBundlePackageFile = "raw_export.json" rawExportBundleLogFile = "collect.log" rawExportBundleFieldsFile = "parser_fields.json" ) type RawExportPackage struct { Format string `json:"format"` ExportedAt time.Time `json:"exported_at"` Source RawExportSource `json:"source"` Analysis *models.AnalysisResult `json:"analysis_result,omitempty"` // CollectedAtHint is extracted from parser_fields.json when importing // a raw-export bundle and represents original collection time. CollectedAtHint time.Time `json:"-"` } type RawExportSource struct { Kind string `json:"kind"` // file_bytes | live_redfish | snapshot_json Filename string `json:"filename,omitempty"` MIMEType string `json:"mime_type,omitempty"` Encoding string `json:"encoding,omitempty"` // base64 Data string `json:"data,omitempty"` Protocol string `json:"protocol,omitempty"` TargetHost string `json:"target_host,omitempty"` SourceTimezone string `json:"source_timezone,omitempty"` RawPayloads map[string]any `json:"raw_payloads,omitempty"` CollectLogs []string `json:"collect_logs,omitempty"` CollectMeta *CollectRequestMeta `json:"collect_meta,omitempty"` } func newRawExportFromUploadedFile(filename, mimeType string, payload []byte, result *models.AnalysisResult) *RawExportPackage { return &RawExportPackage{ Format: rawExportFormatV1, ExportedAt: time.Now().UTC(), Source: RawExportSource{ Kind: "file_bytes", Filename: filename, MIMEType: mimeType, Encoding: "base64", Data: base64.StdEncoding.EncodeToString(payload), Protocol: resultProtocol(result), TargetHost: resultTargetHost(result), SourceTimezone: resultSourceTimezone(result), }, } } func newRawExportFromLiveCollect(result *models.AnalysisResult, req CollectRequest, logs []string) *RawExportPackage { rawPayloads := map[string]any{} if result != nil && result.RawPayloads != nil { for k, v := range result.RawPayloads { rawPayloads[k] = v } } meta := CollectRequestMeta{ Host: req.Host, Protocol: req.Protocol, Port: req.Port, Username: req.Username, AuthType: req.AuthType, TLSMode: req.TLSMode, } return &RawExportPackage{ Format: rawExportFormatV1, ExportedAt: time.Now().UTC(), Source: RawExportSource{ Kind: "live_redfish", Protocol: req.Protocol, TargetHost: req.Host, SourceTimezone: resultSourceTimezone(result), RawPayloads: rawPayloads, CollectLogs: append([]string(nil), logs...), CollectMeta: &meta, }, } } func parseRawExportPackage(payload []byte) (*RawExportPackage, bool, error) { var pkg RawExportPackage if err := json.Unmarshal(payload, &pkg); err != nil { return nil, false, err } if pkg.Format != rawExportFormatV1 { return nil, false, nil } if pkg.ExportedAt.IsZero() { pkg.ExportedAt = time.Now().UTC() } return &pkg, true, nil } func buildRawExportBundle(pkg *RawExportPackage, result *models.AnalysisResult, clientVersion string) ([]byte, error) { if pkg == nil { return nil, fmt.Errorf("nil raw export package") } pkgCopy := *pkg pkgCopy.Analysis = nil pkgCopy.ExportedAt = time.Now().UTC() jsonBytes, err := json.MarshalIndent(&pkgCopy, "", " ") if err != nil { return nil, err } var buf bytes.Buffer zw := zip.NewWriter(&buf) jf, err := zw.Create(rawExportBundlePackageFile) if err != nil { return nil, err } if _, err := jf.Write(jsonBytes); err != nil { return nil, err } lf, err := zw.Create(rawExportBundleLogFile) if err != nil { return nil, err } if _, err := io.WriteString(lf, buildHumanReadableCollectionLog(&pkgCopy, result, clientVersion)); err != nil { return nil, err } ff, err := zw.Create(rawExportBundleFieldsFile) if err != nil { return nil, err } fieldsJSON, err := json.MarshalIndent(buildParserFieldSummary(result), "", " ") if err != nil { return nil, err } if _, err := ff.Write(fieldsJSON); err != nil { return nil, err } if err := zw.Close(); err != nil { return nil, err } return buf.Bytes(), nil } func parseRawExportBundle(payload []byte) (*RawExportPackage, bool, error) { if len(payload) < 4 || payload[0] != 'P' || payload[1] != 'K' { return nil, false, nil } zr, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload))) if err != nil { return nil, false, nil } var pkgBody []byte var parserFieldsBody []byte for _, f := range zr.File { if f.Name != rawExportBundlePackageFile && f.Name != rawExportBundleFieldsFile { continue } rc, err := f.Open() if err != nil { return nil, true, err } body, err := io.ReadAll(rc) rc.Close() if err != nil { return nil, true, err } switch f.Name { case rawExportBundlePackageFile: pkgBody = body case rawExportBundleFieldsFile: parserFieldsBody = body } } if len(pkgBody) == 0 { return nil, false, nil } pkg, ok, err := parseRawExportPackage(pkgBody) if err != nil || !ok { return pkg, ok, err } if ts, ok := parseCollectedAtHint(parserFieldsBody); ok { pkg.CollectedAtHint = ts.UTC() } return pkg, true, nil } func parseCollectedAtHint(parserFieldsBody []byte) (time.Time, bool) { if len(parserFieldsBody) == 0 { return time.Time{}, false } var payload struct { CollectedAt string `json:"collected_at"` } if err := json.Unmarshal(parserFieldsBody, &payload); err != nil { return time.Time{}, false } collectedAt := strings.TrimSpace(payload.CollectedAt) if collectedAt == "" { return time.Time{}, false } ts, err := time.Parse(time.RFC3339Nano, collectedAt) if err != nil { return time.Time{}, false } return ts, true } func buildHumanReadableCollectionLog(pkg *RawExportPackage, result *models.AnalysisResult, clientVersion string) string { var b strings.Builder now := time.Now().UTC().Format(time.RFC3339) fmt.Fprintf(&b, "LOGPile Raw Export Log\n") fmt.Fprintf(&b, "Generated: %s\n", now) if clientVersion != "" { fmt.Fprintf(&b, "Client: %s\n", clientVersion) } if pkg != nil { fmt.Fprintf(&b, "Format: %s\n", pkg.Format) fmt.Fprintf(&b, "Source Kind: %s\n", pkg.Source.Kind) if pkg.Source.Protocol != "" { fmt.Fprintf(&b, "Protocol: %s\n", pkg.Source.Protocol) } if pkg.Source.TargetHost != "" { fmt.Fprintf(&b, "Target Host: %s\n", pkg.Source.TargetHost) } if pkg.Source.Filename != "" { fmt.Fprintf(&b, "Source Filename: %s\n", pkg.Source.Filename) } if startedAt, finishedAt, ok := collectLogTimeBounds(pkg.Source.CollectLogs); ok { fmt.Fprintf(&b, "Collection Started: %s\n", startedAt.Format(time.RFC3339Nano)) fmt.Fprintf(&b, "Collection Finished: %s\n", finishedAt.Format(time.RFC3339Nano)) fmt.Fprintf(&b, "Collection Duration: %s\n", formatRawExportDuration(finishedAt.Sub(startedAt))) } } if pkg != nil && len(pkg.Source.CollectLogs) > 0 { b.WriteString("\n=== Redfish Collection Log ===\n") for _, line := range pkg.Source.CollectLogs { b.WriteString(line) b.WriteByte('\n') } } b.WriteString("\n=== Parsed Field Summary ===\n") if result == nil || result.Hardware == nil { b.WriteString("No parsed hardware data available\n") return b.String() } hw := result.Hardware fmt.Fprintf(&b, "Board: manufacturer=%s model=%s serial=%s part=%s\n", hw.BoardInfo.Manufacturer, hw.BoardInfo.ProductName, hw.BoardInfo.SerialNumber, hw.BoardInfo.PartNumber) fmt.Fprintf(&b, "Counts: cpus=%d memory=%d storage=%d pcie=%d gpus=%d nics=%d psus=%d firmware=%d\n", len(hw.CPUs), len(hw.Memory), len(hw.Storage), len(hw.PCIeDevices), len(hw.GPUs), len(hw.NetworkAdapters), len(hw.PowerSupply), len(hw.Firmware)) if len(hw.CPUs) > 0 { b.WriteString("\n[CPUs]\n") for _, cpu := range hw.CPUs { fmt.Fprintf(&b, "- socket=%d model=%s cores=%d threads=%d serial=%s\n", cpu.Socket, cpu.Model, cpu.Cores, cpu.Threads, cpu.SerialNumber) } } if len(hw.Memory) > 0 { b.WriteString("\n[Memory]\n") for _, m := range hw.Memory { fmt.Fprintf(&b, "- slot=%s location=%s size_mb=%d part=%s serial=%s status=%s\n", m.Slot, m.Location, m.SizeMB, m.PartNumber, m.SerialNumber, m.Status) } } if len(hw.Storage) > 0 { b.WriteString("\n[Storage]\n") for _, s := range hw.Storage { fmt.Fprintf(&b, "- slot=%s type=%s model=%s size_gb=%d serial=%s\n", s.Slot, s.Type, s.Model, s.SizeGB, s.SerialNumber) } } if len(hw.Volumes) > 0 { b.WriteString("\n[Volumes]\n") for _, v := range hw.Volumes { name := v.Name if name == "" { name = v.ID } fmt.Fprintf(&b, "- controller=%s name=%s raid=%s size_gb=%d status=%s\n", v.Controller, name, v.RAIDLevel, v.SizeGB, v.Status) } } if len(hw.PCIeDevices) > 0 { b.WriteString("\n[PCIe Devices]\n") for _, d := range hw.PCIeDevices { fmt.Fprintf(&b, "- slot=%s class=%s model=%s bdf=%s vendor=%s serial=%s\n", d.Slot, d.DeviceClass, d.PartNumber, d.BDF, d.Manufacturer, d.SerialNumber) } } if len(hw.GPUs) > 0 { b.WriteString("\n[GPUs]\n") for _, g := range hw.GPUs { fmt.Fprintf(&b, "- slot=%s model=%s bdf=%s serial=%s status=%s\n", g.Slot, g.Model, g.BDF, g.SerialNumber, g.Status) } } if len(hw.NetworkAdapters) > 0 { b.WriteString("\n[Network Adapters]\n") for _, n := range hw.NetworkAdapters { fmt.Fprintf(&b, "- slot=%s location=%s model=%s serial=%s status=%s\n", n.Slot, n.Location, n.Model, n.SerialNumber, n.Status) } } if len(hw.PowerSupply) > 0 { b.WriteString("\n[Power Supplies]\n") for _, p := range hw.PowerSupply { fmt.Fprintf(&b, "- slot=%s model=%s serial=%s status=%s watt=%d\n", p.Slot, p.Model, p.SerialNumber, p.Status, p.WattageW) } } if len(hw.Firmware) > 0 { b.WriteString("\n[Firmware]\n") for _, fw := range hw.Firmware { fmt.Fprintf(&b, "- device=%s version=%s\n", fw.DeviceName, fw.Version) } } return b.String() } func collectLogTimeBounds(lines []string) (time.Time, time.Time, bool) { var first time.Time var last time.Time for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } tsToken := line if idx := strings.IndexByte(line, ' '); idx > 0 { tsToken = line[:idx] } ts, err := time.Parse(time.RFC3339Nano, tsToken) if err != nil { continue } if first.IsZero() || ts.Before(first) { first = ts } if last.IsZero() || ts.After(last) { last = ts } } if first.IsZero() || last.IsZero() || last.Before(first) { return time.Time{}, time.Time{}, false } return first, last, true } func formatRawExportDuration(d time.Duration) string { if d < 0 { d = 0 } return d.Round(time.Second).String() } func buildParserFieldSummary(result *models.AnalysisResult) map[string]any { out := map[string]any{ "generated_at": time.Now().UTC(), } if result == nil { out["available"] = false return out } out["available"] = true out["filename"] = result.Filename out["source_type"] = result.SourceType out["protocol"] = result.Protocol out["target_host"] = result.TargetHost out["source_timezone"] = result.SourceTimezone out["collected_at"] = result.CollectedAt if result.Hardware == nil { out["hardware"] = map[string]any{} return out } hw := result.Hardware out["vendor"] = hw.BoardInfo.Manufacturer out["model"] = hw.BoardInfo.ProductName out["serial"] = hw.BoardInfo.SerialNumber out["part_number"] = hw.BoardInfo.PartNumber out["hardware"] = map[string]any{ "board": hw.BoardInfo, "counts": map[string]int{ "cpus": len(hw.CPUs), "memory": len(hw.Memory), "storage": len(hw.Storage), "volumes": len(hw.Volumes), "pcie": len(hw.PCIeDevices), "gpus": len(hw.GPUs), "nics": len(hw.NetworkAdapters), "psus": len(hw.PowerSupply), "firmware": len(hw.Firmware), "events": len(result.Events), "sensors": len(result.Sensors), "fru": len(result.FRU), }, "cpus": hw.CPUs, "memory": hw.Memory, "storage": hw.Storage, "volumes": hw.Volumes, "pcie_devices": hw.PCIeDevices, "gpus": hw.GPUs, "network_adapters": hw.NetworkAdapters, "power_supplies": hw.PowerSupply, "firmware": hw.Firmware, } return out } func resultProtocol(result *models.AnalysisResult) string { if result == nil { return "" } return result.Protocol } func resultTargetHost(result *models.AnalysisResult) string { if result == nil { return "" } return result.TargetHost } func resultSourceTimezone(result *models.AnalysisResult) string { if result == nil { return "" } return strings.TrimSpace(result.SourceTimezone) }