export: align reanimator contract v2.7
This commit is contained in:
@@ -14,16 +14,50 @@ import (
|
||||
|
||||
func newCollectTestServer() (*Server, *httptest.Server) {
|
||||
s := &Server{
|
||||
jobManager: NewJobManager(),
|
||||
jobManager: NewJobManager(),
|
||||
collectors: testCollectorRegistry(),
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/collect/probe", s.handleCollectProbe)
|
||||
mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
||||
mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
|
||||
mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
|
||||
return s, httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestCollectProbe(t *testing.T) {
|
||||
_, ts := newCollectTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
body := `{"host":"bmc-off.local","protocol":"redfish","port":443,"username":"admin","auth_type":"password","password":"secret","tls_mode":"strict"}`
|
||||
resp, err := http.Post(ts.URL+"/api/collect/probe", "application/json", bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
t.Fatalf("post collect probe failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload CollectProbeResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode probe response: %v", err)
|
||||
}
|
||||
if !payload.Reachable {
|
||||
t.Fatalf("expected reachable=true, got false")
|
||||
}
|
||||
if payload.HostPoweredOn {
|
||||
t.Fatalf("expected host powered off in probe response")
|
||||
}
|
||||
if payload.HostPowerState != "Off" {
|
||||
t.Fatalf("expected host power state Off, got %q", payload.HostPowerState)
|
||||
}
|
||||
if !payload.PowerControlAvailable {
|
||||
t.Fatalf("expected power control to be available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectLifecycleToTerminal(t *testing.T) {
|
||||
_, ts := newCollectTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
@@ -17,6 +17,20 @@ func (c *mockConnector) Protocol() string {
|
||||
return c.protocol
|
||||
}
|
||||
|
||||
func (c *mockConnector) Probe(ctx context.Context, req collector.Request) (*collector.ProbeResult, error) {
|
||||
if strings.Contains(strings.ToLower(req.Host), "fail") {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
return &collector.ProbeResult{
|
||||
Reachable: true,
|
||||
Protocol: c.protocol,
|
||||
HostPowerState: map[bool]string{true: "On", false: "Off"}[!strings.Contains(strings.ToLower(req.Host), "off")],
|
||||
HostPoweredOn: !strings.Contains(strings.ToLower(req.Host), "off"),
|
||||
PowerControlAvailable: true,
|
||||
SystemPath: "/redfish/v1/Systems/1",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *mockConnector) Collect(ctx context.Context, req collector.Request, emit collector.ProgressFn) (*models.AnalysisResult, error) {
|
||||
steps := []collector.Progress{
|
||||
{Status: CollectStatusRunning, Progress: 20, Message: "Подключение..."},
|
||||
|
||||
@@ -11,14 +11,24 @@ const (
|
||||
)
|
||||
|
||||
type CollectRequest struct {
|
||||
Host string `json:"host"`
|
||||
Protocol string `json:"protocol"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
AuthType string `json:"auth_type"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
TLSMode string `json:"tls_mode"`
|
||||
Host string `json:"host"`
|
||||
Protocol string `json:"protocol"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
AuthType string `json:"auth_type"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
TLSMode string `json:"tls_mode"`
|
||||
PowerOnIfHostOff bool `json:"power_on_if_host_off,omitempty"`
|
||||
}
|
||||
|
||||
type CollectProbeResponse struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
HostPowerState string `json:"host_power_state,omitempty"`
|
||||
HostPoweredOn bool `json:"host_powered_on"`
|
||||
PowerControlAvailable bool `json:"power_control_available"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type CollectJobResponse struct {
|
||||
|
||||
@@ -1510,6 +1510,69 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(job.toJobResponse("Collection job accepted"))
|
||||
}
|
||||
|
||||
func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
|
||||
var req CollectRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "Invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validateCollectRequest(req); err != nil {
|
||||
jsonError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
connector, ok := s.getCollector(req.Protocol)
|
||||
if !ok {
|
||||
jsonError(w, "Коннектор для протокола не зарегистрирован", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
prober, ok := connector.(collector.Prober)
|
||||
if !ok {
|
||||
jsonError(w, "Проверка подключения для протокола не поддерживается", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := prober.Probe(ctx, toCollectorRequest(req))
|
||||
if err != nil {
|
||||
jsonError(w, "Проверка подключения не удалась: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
message := "Связь с BMC установлена"
|
||||
if result != nil {
|
||||
switch {
|
||||
case !result.HostPoweredOn && result.PowerControlAvailable:
|
||||
message = "Связь с BMC установлена, host выключен. Можно включить перед сбором."
|
||||
case !result.HostPoweredOn:
|
||||
message = "Связь с BMC установлена, host выключен."
|
||||
default:
|
||||
message = "Связь с BMC установлена, host включен."
|
||||
}
|
||||
}
|
||||
|
||||
hostPowerState := ""
|
||||
hostPoweredOn := false
|
||||
powerControlAvailable := false
|
||||
reachable := false
|
||||
if result != nil {
|
||||
reachable = result.Reachable
|
||||
hostPowerState = strings.TrimSpace(result.HostPowerState)
|
||||
hostPoweredOn = result.HostPoweredOn
|
||||
powerControlAvailable = result.PowerControlAvailable
|
||||
}
|
||||
|
||||
jsonResponse(w, CollectProbeResponse{
|
||||
Reachable: reachable,
|
||||
Protocol: req.Protocol,
|
||||
HostPowerState: hostPowerState,
|
||||
HostPoweredOn: hostPoweredOn,
|
||||
PowerControlAvailable: powerControlAvailable,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleCollectStatus(w http.ResponseWriter, r *http.Request) {
|
||||
jobID := strings.TrimSpace(r.PathValue("id"))
|
||||
if !isValidCollectJobID(jobID) {
|
||||
@@ -1787,14 +1850,15 @@ func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectReques
|
||||
|
||||
func toCollectorRequest(req CollectRequest) collector.Request {
|
||||
return collector.Request{
|
||||
Host: req.Host,
|
||||
Protocol: req.Protocol,
|
||||
Port: req.Port,
|
||||
Username: req.Username,
|
||||
AuthType: req.AuthType,
|
||||
Password: req.Password,
|
||||
Token: req.Token,
|
||||
TLSMode: req.TLSMode,
|
||||
Host: req.Host,
|
||||
Protocol: req.Protocol,
|
||||
Port: req.Port,
|
||||
Username: req.Username,
|
||||
AuthType: req.AuthType,
|
||||
Password: req.Password,
|
||||
Token: req.Token,
|
||||
TLSMode: req.TLSMode,
|
||||
PowerOnIfHostOff: req.PowerOnIfHostOff,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,17 +32,17 @@ type RawExportPackage struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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 {
|
||||
@@ -50,13 +50,13 @@ func newRawExportFromUploadedFile(filename, mimeType string, payload []byte, res
|
||||
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),
|
||||
Kind: "file_bytes",
|
||||
Filename: filename,
|
||||
MIMEType: mimeType,
|
||||
Encoding: "base64",
|
||||
Data: base64.StdEncoding.EncodeToString(payload),
|
||||
Protocol: resultProtocol(result),
|
||||
TargetHost: resultTargetHost(result),
|
||||
SourceTimezone: resultSourceTimezone(result),
|
||||
},
|
||||
}
|
||||
@@ -81,13 +81,13 @@ func newRawExportFromLiveCollect(result *models.AnalysisResult, req CollectReque
|
||||
Format: rawExportFormatV1,
|
||||
ExportedAt: time.Now().UTC(),
|
||||
Source: RawExportSource{
|
||||
Kind: "live_redfish",
|
||||
Protocol: req.Protocol,
|
||||
TargetHost: req.Host,
|
||||
Kind: "live_redfish",
|
||||
Protocol: req.Protocol,
|
||||
TargetHost: req.Host,
|
||||
SourceTimezone: resultSourceTimezone(result),
|
||||
RawPayloads: rawPayloads,
|
||||
CollectLogs: append([]string(nil), logs...),
|
||||
CollectMeta: &meta,
|
||||
RawPayloads: rawPayloads,
|
||||
CollectLogs: append([]string(nil), logs...),
|
||||
CollectMeta: &meta,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -386,6 +386,10 @@ func buildParserFieldSummary(result *models.AnalysisResult) 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{
|
||||
|
||||
@@ -1,101 +1,35 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
)
|
||||
|
||||
func TestCollectLogTimeBounds(t *testing.T) {
|
||||
lines := []string{
|
||||
"2026-02-28T13:10:13.7442032Z Задача поставлена в очередь",
|
||||
"not-a-timestamp line",
|
||||
"2026-02-28T13:31:00.5077486Z Сбор завершен",
|
||||
}
|
||||
|
||||
startedAt, finishedAt, ok := collectLogTimeBounds(lines)
|
||||
if !ok {
|
||||
t.Fatalf("expected bounds to be parsed")
|
||||
}
|
||||
if got := formatRawExportDuration(finishedAt.Sub(startedAt)); got != "20m47s" {
|
||||
t.Fatalf("unexpected duration: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHumanReadableCollectionLog_IncludesDurationHeader(t *testing.T) {
|
||||
pkg := &RawExportPackage{
|
||||
Format: rawExportFormatV1,
|
||||
Source: RawExportSource{
|
||||
Kind: "live_redfish",
|
||||
CollectLogs: []string{
|
||||
"2026-02-28T13:10:13.7442032Z Redfish: подключение к BMC...",
|
||||
"2026-02-28T13:31:00.5077486Z Сбор завершен",
|
||||
func TestBuildParserFieldSummary_MirrorsBoardInfoToTopLevel(t *testing.T) {
|
||||
result := &models.AnalysisResult{
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{
|
||||
Manufacturer: "Supermicro",
|
||||
ProductName: "SYS-821GE-TNHR",
|
||||
SerialNumber: "A514359X5C08846",
|
||||
PartNumber: "SYS-821GE-TNHR",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
logText := buildHumanReadableCollectionLog(pkg, nil, "LOGPile test")
|
||||
for _, token := range []string{
|
||||
"Collection Started:",
|
||||
"Collection Finished:",
|
||||
"Collection Duration:",
|
||||
} {
|
||||
if !strings.Contains(logText, token) {
|
||||
t.Fatalf("expected %q in log header", token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRawExportBundle_ExtractsCollectedAtHintFromParserFields(t *testing.T) {
|
||||
pkg := &RawExportPackage{
|
||||
Format: rawExportFormatV1,
|
||||
ExportedAt: time.Date(2026, 2, 25, 9, 59, 41, 479023400, time.UTC),
|
||||
Source: RawExportSource{
|
||||
Kind: "live_redfish",
|
||||
},
|
||||
}
|
||||
pkgJSON, err := json.Marshal(pkg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pkg: %v", err)
|
||||
}
|
||||
|
||||
parserFields := []byte(`{"collected_at":"2026-02-25T09:58:05.9129753Z"}`)
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
jf, err := zw.Create(rawExportBundlePackageFile)
|
||||
if err != nil {
|
||||
t.Fatalf("create package file: %v", err)
|
||||
}
|
||||
if _, err := jf.Write(pkgJSON); err != nil {
|
||||
t.Fatalf("write package file: %v", err)
|
||||
}
|
||||
|
||||
ff, err := zw.Create(rawExportBundleFieldsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("create parser fields file: %v", err)
|
||||
}
|
||||
if _, err := ff.Write(parserFields); err != nil {
|
||||
t.Fatalf("write parser fields file: %v", err)
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close zip writer: %v", err)
|
||||
}
|
||||
|
||||
gotPkg, ok, err := parseRawExportBundle(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("parse bundle: %v", err)
|
||||
}
|
||||
if !ok || gotPkg == nil {
|
||||
t.Fatalf("expected valid raw export bundle")
|
||||
}
|
||||
want := time.Date(2026, 2, 25, 9, 58, 5, 912975300, time.UTC)
|
||||
if !gotPkg.CollectedAtHint.Equal(want) {
|
||||
t.Fatalf("expected collected_at hint %s, got %s", want, gotPkg.CollectedAtHint)
|
||||
got := buildParserFieldSummary(result)
|
||||
|
||||
if got["vendor"] != "Supermicro" {
|
||||
t.Fatalf("expected vendor mirror, got %v", got["vendor"])
|
||||
}
|
||||
if got["model"] != "SYS-821GE-TNHR" {
|
||||
t.Fatalf("expected model mirror, got %v", got["model"])
|
||||
}
|
||||
if got["serial"] != "A514359X5C08846" {
|
||||
t.Fatalf("expected serial mirror, got %v", got["serial"])
|
||||
}
|
||||
if got["part_number"] != "SYS-821GE-TNHR" {
|
||||
t.Fatalf("expected part_number mirror, got %v", got["part_number"])
|
||||
}
|
||||
}
|
||||
|
||||
81
internal/server/reanimator_example_regression_test.go
Normal file
81
internal/server/reanimator_example_regression_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/exporter"
|
||||
)
|
||||
|
||||
func TestReanimatorExport_RedfishExampleDoesNotDuplicateCPUs(t *testing.T) {
|
||||
payload, err := os.ReadFile(filepath.Join("..", "..", "example", "2026-03-11 (SYS-821GE-TNHR) - A514359X5C08846.zip"))
|
||||
if err != nil {
|
||||
t.Fatalf("read example bundle: %v", err)
|
||||
}
|
||||
|
||||
rawPkg, ok, err := parseRawExportBundle(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("parse raw export bundle: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("example bundle was not recognized as raw export bundle")
|
||||
}
|
||||
|
||||
s := &Server{}
|
||||
result, vendor, err := s.reanalyzeRawExportPackage(rawPkg)
|
||||
if err != nil {
|
||||
t.Fatalf("reanalyze raw export bundle: %v", err)
|
||||
}
|
||||
if vendor != "redfish" {
|
||||
t.Fatalf("expected redfish vendor, got %q", vendor)
|
||||
}
|
||||
if result == nil || result.Hardware == nil {
|
||||
t.Fatalf("expected parsed hardware result")
|
||||
}
|
||||
if got := len(result.Hardware.CPUs); got != 2 {
|
||||
t.Fatalf("expected 2 CPUs after replay, got %d", got)
|
||||
}
|
||||
|
||||
reanimatorData, err := exporter.ConvertToReanimator(result)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertToReanimator: %v", err)
|
||||
}
|
||||
if got := len(reanimatorData.Hardware.CPUs); got != 2 {
|
||||
t.Fatalf("expected 2 CPUs in reanimator export, got %d", got)
|
||||
}
|
||||
for i, cpu := range reanimatorData.Hardware.CPUs {
|
||||
if cpu.SerialNumber != "" {
|
||||
t.Fatalf("expected CPU %d serial to stay empty, got %q", i, cpu.SerialNumber)
|
||||
}
|
||||
}
|
||||
for _, dev := range reanimatorData.Hardware.PCIeDevices {
|
||||
joined := strings.ToLower(strings.Join([]string{dev.Slot, dev.Model, dev.DeviceClass}, " "))
|
||||
if strings.Contains(joined, "nvme") || strings.Contains(joined, "ssd") || strings.Contains(joined, "disk") {
|
||||
t.Fatalf("expected storage endpoint to stay out of pcie export, got %+v", dev)
|
||||
}
|
||||
if strings.EqualFold(dev.Model, "Network Device View") {
|
||||
t.Fatalf("expected placeholder network model to be replaced, got %+v", dev)
|
||||
}
|
||||
if strings.Contains(dev.SerialNumber, "-PCIE-") {
|
||||
t.Fatalf("expected no synthetic pcie serials, got %+v", dev)
|
||||
}
|
||||
if isNumericOnly(dev.Slot) && dev.DeviceClass == "NetworkController" && dev.Model == "" && dev.SerialNumber == "" {
|
||||
t.Fatalf("expected placeholder numeric-slot network export to be skipped, got %+v", dev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isNumericOnly(v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range v {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -88,6 +88,7 @@ func (s *Server) setupRoutes() {
|
||||
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
|
||||
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
|
||||
s.mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
||||
s.mux.HandleFunc("POST /api/collect/probe", s.handleCollectProbe)
|
||||
s.mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
|
||||
s.mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user