Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c47c34fd11 |
@@ -994,9 +994,54 @@ significant complexity before proving user value.
|
||||
- decode the outer `ABJR` container
|
||||
- gunzip embedded members when applicable
|
||||
- extract inventory from printable SMBIOS/FRU payloads
|
||||
- extract storage/controller details from embedded Redfish JSON objects
|
||||
- extract storage/controller/backplane details from embedded Redfish JSON objects
|
||||
- enrich firmware and PSU inventory from auxiliary package payloads such as `bcert.pkg`
|
||||
- do not attempt complete semantic decoding of the internal `zbb` record format
|
||||
**Consequences:**
|
||||
- Parser reaches inventory-grade usefulness quickly for HPE `.ahs` uploads.
|
||||
- Storage inventory is stronger than text-only parsing because it reuses structured Redfish data when present.
|
||||
- Auxiliary package payloads can supply missing firmware/PSU fields even when the main SMBIOS-like blob is incomplete.
|
||||
- Future deeper `zbb` decoding can be added incrementally without replacing the current parser contract.
|
||||
|
||||
---
|
||||
|
||||
## ADL-039 — Canonical inventory keeps DIMMs with unknown capacity when identity is known
|
||||
|
||||
**Date:** 2026-03-30
|
||||
**Context:** Some sources, notably HPE iLO AHS SMBIOS-like blobs, expose installed DIMM identity
|
||||
(slot, serial, part number, manufacturer) but do not include capacity. The parser already extracts
|
||||
those modules into `Hardware.Memory`, but canonical device building and export previously dropped
|
||||
them because `size_mb == 0`.
|
||||
**Decision:** Treat a DIMM as installed inventory when `present=true` and it has identifying
|
||||
memory fields such as serial number or part number, even if `size_mb` is unknown.
|
||||
**Consequences:**
|
||||
- HPE AHS uploads now show real installed memory modules instead of hiding them.
|
||||
- Empty slots still stay filtered because they lack inventory identity or are marked absent.
|
||||
- Specification/export can include "size unknown" memory entries without inventing capacity data.
|
||||
|
||||
---
|
||||
|
||||
## ADL-040 — HPE Redfish normalization prefers chassis `Devices/*` over generic PCIe topology labels
|
||||
|
||||
**Date:** 2026-03-30
|
||||
**Context:** HPE ProLiant Gen11 Redfish snapshots expose parallel inventory trees. `Chassis/*/PCIeDevices/*`
|
||||
is good for topology presence, but often reports only generic `DeviceType` values such as
|
||||
`SingleFunction`. `Chassis/*/Devices/*` carries the concrete slot label, richer device type, and
|
||||
product-vs-spare part identifiers for the same physical NIC/controller. Replay fallback over empty
|
||||
storage volume collections can also discover `Volumes/Capabilities` children, which are not real
|
||||
logical volumes.
|
||||
|
||||
**Decision:**
|
||||
- Treat Redfish `SKU` as a valid fallback for `hardware.board.part_number` when `PartNumber` is empty.
|
||||
- Ignore `Volumes/Capabilities` documents during logical-volume parsing.
|
||||
- Enrich `Chassis/*/PCIeDevices/*` entries with matching `Chassis/*/Devices/*` documents by
|
||||
serial/name/part identity.
|
||||
- Keep `pcie.device_class` semantic; do not replace it with model or part-number strings when
|
||||
Redfish exposes only generic topology labels.
|
||||
|
||||
**Consequences:**
|
||||
- HPE Redfish imports now keep the server SKU in `hardware.board.part_number`.
|
||||
- Empty volume collections no longer produce fake `Capabilities` volume records.
|
||||
- HPE PCIe inventory gets better slot labels like `OCP 3.0 Slot 15` plus concrete classes such as
|
||||
`LOM/NIC` or `SAS/SATA Storage Controller`.
|
||||
- `part_number` remains available separately for model identity, without polluting the class field.
|
||||
|
||||
@@ -1513,16 +1513,13 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
|
||||
}
|
||||
|
||||
func (c *RedfishConnector) getChassisScopedPCIeSupplementalDocs(ctx context.Context, client *http.Client, req Request, baseURL string, doc map[string]interface{}) []map[string]interface{} {
|
||||
if !looksLikeNVSwitchPCIeDoc(doc) {
|
||||
return nil
|
||||
}
|
||||
docPath := normalizeRedfishPath(asString(doc["@odata.id"]))
|
||||
chassisPath := chassisPathForPCIeDoc(docPath)
|
||||
if chassisPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]map[string]interface{}, 0, 4)
|
||||
out := make([]map[string]interface{}, 0, 6)
|
||||
seen := make(map[string]struct{})
|
||||
add := func(path string) {
|
||||
path = normalizeRedfishPath(path)
|
||||
@@ -1540,8 +1537,19 @@ func (c *RedfishConnector) getChassisScopedPCIeSupplementalDocs(ctx context.Cont
|
||||
out = append(out, supplementalDoc)
|
||||
}
|
||||
|
||||
add(joinPath(chassisPath, "/EnvironmentMetrics"))
|
||||
add(joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"))
|
||||
if looksLikeNVSwitchPCIeDoc(doc) {
|
||||
add(joinPath(chassisPath, "/EnvironmentMetrics"))
|
||||
add(joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"))
|
||||
}
|
||||
deviceDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/Devices"))
|
||||
if err == nil {
|
||||
for _, deviceDoc := range deviceDocs {
|
||||
if !redfishPCIeMatchesChassisDeviceDoc(doc, deviceDoc) {
|
||||
continue
|
||||
}
|
||||
add(asString(deviceDoc["@odata.id"]))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -3434,8 +3442,11 @@ func parseBoardInfo(system map[string]interface{}) models.BoardInfo {
|
||||
asString(system["Name"]),
|
||||
)),
|
||||
SerialNumber: normalizeRedfishIdentityField(asString(system["SerialNumber"])),
|
||||
PartNumber: normalizeRedfishIdentityField(asString(system["PartNumber"])),
|
||||
UUID: normalizeRedfishIdentityField(asString(system["UUID"])),
|
||||
PartNumber: normalizeRedfishIdentityField(firstNonEmpty(
|
||||
asString(system["PartNumber"]),
|
||||
asString(system["SKU"]),
|
||||
)),
|
||||
UUID: normalizeRedfishIdentityField(asString(system["UUID"])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3818,6 +3829,22 @@ func parseStorageVolume(doc map[string]interface{}, controller string) models.St
|
||||
}
|
||||
}
|
||||
|
||||
func redfishVolumeCapabilitiesDoc(doc map[string]interface{}) bool {
|
||||
if len(doc) == 0 {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(strings.ToLower(strings.TrimSpace(asString(doc["@odata.type"]))), "collectioncapabilities") {
|
||||
return true
|
||||
}
|
||||
path := strings.ToLower(normalizeRedfishPath(asString(doc["@odata.id"])))
|
||||
if strings.HasSuffix(path, "/volumes/capabilities") {
|
||||
return true
|
||||
}
|
||||
id := strings.TrimSpace(asString(doc["Id"]))
|
||||
name := strings.ToLower(strings.TrimSpace(asString(doc["Name"])))
|
||||
return strings.EqualFold(id, "Capabilities") || strings.Contains(name, "capabilities for volumecollection")
|
||||
}
|
||||
|
||||
func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
|
||||
vendorID := asHexOrInt(doc["VendorId"])
|
||||
deviceID := asHexOrInt(doc["DeviceId"])
|
||||
@@ -4356,6 +4383,39 @@ func redfishFirstBoolAcrossDocs(docs []map[string]interface{}, keys ...string) *
|
||||
return nil
|
||||
}
|
||||
|
||||
func redfishFirstString(doc map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if v, ok := redfishLookupValue(doc, key); ok {
|
||||
if s := strings.TrimSpace(asString(v)); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func redfishFirstStringAcrossDocs(docs []map[string]interface{}, keys ...string) string {
|
||||
for _, doc := range docs {
|
||||
if v := redfishFirstString(doc, keys...); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func redfishFirstLocationAcrossDocs(docs []map[string]interface{}, keys ...string) string {
|
||||
for _, doc := range docs {
|
||||
for _, key := range keys {
|
||||
if v, ok := redfishLookupValue(doc, key); ok {
|
||||
if loc := redfishLocationLabel(v); loc != "" {
|
||||
return loc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func redfishLookupValue(doc map[string]interface{}, key string) (any, bool) {
|
||||
if doc == nil || strings.TrimSpace(key) == "" {
|
||||
return nil, false
|
||||
@@ -4537,8 +4597,9 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
|
||||
}
|
||||
|
||||
func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) models.PCIeDevice {
|
||||
supplementalSlot := redfishFirstLocationAcrossDocs(supplementalDocs, "Slot", "Location", "PhysicalLocation")
|
||||
dev := models.PCIeDevice{
|
||||
Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), asString(doc["Name"]), asString(doc["Id"])),
|
||||
Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), supplementalSlot, asString(doc["Name"]), asString(doc["Id"])),
|
||||
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
|
||||
DeviceClass: asString(doc["DeviceType"]),
|
||||
Manufacturer: asString(doc["Manufacturer"]),
|
||||
@@ -4578,6 +4639,9 @@ func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDoc
|
||||
dev.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
|
||||
}
|
||||
}
|
||||
if dev.DeviceClass == "" || isGenericPCIeClassLabel(dev.DeviceClass) {
|
||||
dev.DeviceClass = firstNonEmpty(redfishFirstStringAcrossDocs(supplementalDocs, "DeviceType"), dev.DeviceClass)
|
||||
}
|
||||
|
||||
if dev.DeviceClass == "" {
|
||||
dev.DeviceClass = "PCIe device"
|
||||
@@ -4588,15 +4652,22 @@ func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDoc
|
||||
}
|
||||
}
|
||||
if isGenericPCIeClassLabel(dev.DeviceClass) {
|
||||
// Redfish DeviceType (e.g. MultiFunction/Simulated) is a topology attribute,
|
||||
// not a user-facing device name. Prefer model/part labels when class cannot be resolved.
|
||||
dev.DeviceClass = firstNonEmpty(asString(doc["Model"]), dev.PartNumber, dev.DeviceClass)
|
||||
dev.DeviceClass = "PCIe device"
|
||||
}
|
||||
if strings.TrimSpace(dev.Manufacturer) == "" {
|
||||
dev.Manufacturer = pciids.VendorName(dev.VendorID)
|
||||
dev.Manufacturer = firstNonEmpty(
|
||||
redfishFirstStringAcrossDocs(supplementalDocs, "Manufacturer"),
|
||||
pciids.VendorName(dev.VendorID),
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(dev.PartNumber) == "" {
|
||||
dev.PartNumber = pciids.DeviceName(dev.VendorID, dev.DeviceID)
|
||||
dev.PartNumber = firstNonEmpty(
|
||||
redfishFirstStringAcrossDocs(supplementalDocs, "ProductPartNumber", "PartNumber"),
|
||||
pciids.DeviceName(dev.VendorID, dev.DeviceID),
|
||||
)
|
||||
}
|
||||
if normalizeRedfishIdentityField(dev.SerialNumber) == "" {
|
||||
dev.SerialNumber = redfishFirstStringAcrossDocs(supplementalDocs, "SerialNumber")
|
||||
}
|
||||
return dev
|
||||
}
|
||||
@@ -4699,6 +4770,70 @@ func isGenericPCIeClassLabel(v string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func redfishPCIeMatchesChassisDeviceDoc(doc, deviceDoc map[string]interface{}) bool {
|
||||
if len(doc) == 0 || len(deviceDoc) == 0 || redfishChassisDeviceDocLooksEmpty(deviceDoc) {
|
||||
return false
|
||||
}
|
||||
docSerial := normalizeRedfishIdentityField(findFirstNormalizedStringByKeys(doc, "SerialNumber"))
|
||||
deviceSerial := normalizeRedfishIdentityField(findFirstNormalizedStringByKeys(deviceDoc, "SerialNumber"))
|
||||
if docSerial != "" && deviceSerial != "" && strings.EqualFold(docSerial, deviceSerial) {
|
||||
return true
|
||||
}
|
||||
docTokens := redfishPCIeMatchTokens(doc)
|
||||
deviceTokens := redfishPCIeMatchTokens(deviceDoc)
|
||||
if len(docTokens) == 0 || len(deviceTokens) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, token := range docTokens {
|
||||
for _, candidate := range deviceTokens {
|
||||
if strings.EqualFold(token, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redfishPCIeMatchTokens(doc map[string]interface{}) []string {
|
||||
if len(doc) == 0 {
|
||||
return nil
|
||||
}
|
||||
rawValues := []string{
|
||||
asString(doc["Name"]),
|
||||
asString(doc["Model"]),
|
||||
asString(doc["PartNumber"]),
|
||||
asString(doc["ProductPartNumber"]),
|
||||
}
|
||||
out := make([]string, 0, len(rawValues))
|
||||
seen := make(map[string]struct{}, len(rawValues))
|
||||
for _, raw := range rawValues {
|
||||
value := normalizeRedfishIdentityField(raw)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(value)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func redfishChassisDeviceDocLooksEmpty(doc map[string]interface{}) bool {
|
||||
name := strings.ToLower(strings.TrimSpace(asString(doc["Name"])))
|
||||
if strings.HasPrefix(name, "empty slot") {
|
||||
return true
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(asString(doc["DeviceType"]))) != "unknown" {
|
||||
return false
|
||||
}
|
||||
return normalizeRedfishIdentityField(asString(doc["PartNumber"])) == "" &&
|
||||
normalizeRedfishIdentityField(asString(doc["ProductPartNumber"])) == "" &&
|
||||
normalizeRedfishIdentityField(findFirstNormalizedStringByKeys(doc, "SerialNumber")) == ""
|
||||
}
|
||||
|
||||
func buildBDFfromOemPublic(doc map[string]interface{}) string {
|
||||
if len(doc) == 0 {
|
||||
return ""
|
||||
@@ -5126,6 +5261,9 @@ func classifyStorageType(doc map[string]interface{}) string {
|
||||
}
|
||||
|
||||
func looksLikeVolume(doc map[string]interface{}) bool {
|
||||
if redfishVolumeCapabilitiesDoc(doc) {
|
||||
return false
|
||||
}
|
||||
if asString(doc["RAIDType"]) != "" || asString(doc["VolumeType"]) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -143,24 +143,33 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) getChassisScopedPCIeSupplementalDocs(doc map[string]interface{}) []map[string]interface{} {
|
||||
if !looksLikeNVSwitchPCIeDoc(doc) {
|
||||
return nil
|
||||
}
|
||||
docPath := normalizeRedfishPath(asString(doc["@odata.id"]))
|
||||
chassisPath := chassisPathForPCIeDoc(docPath)
|
||||
if chassisPath == "" {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, 4)
|
||||
for _, path := range []string{
|
||||
joinPath(chassisPath, "/EnvironmentMetrics"),
|
||||
joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"),
|
||||
} {
|
||||
supplementalDoc, err := r.getJSON(path)
|
||||
if err != nil || len(supplementalDoc) == 0 {
|
||||
continue
|
||||
|
||||
out := make([]map[string]interface{}, 0, 6)
|
||||
if looksLikeNVSwitchPCIeDoc(doc) {
|
||||
for _, path := range []string{
|
||||
joinPath(chassisPath, "/EnvironmentMetrics"),
|
||||
joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"),
|
||||
} {
|
||||
supplementalDoc, err := r.getJSON(path)
|
||||
if err != nil || len(supplementalDoc) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, supplementalDoc)
|
||||
}
|
||||
}
|
||||
deviceDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/Devices"))
|
||||
if err == nil {
|
||||
for _, deviceDoc := range deviceDocs {
|
||||
if !redfishPCIeMatchesChassisDeviceDoc(doc, deviceDoc) {
|
||||
continue
|
||||
}
|
||||
out = append(out, deviceDoc)
|
||||
}
|
||||
out = append(out, supplementalDoc)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1316,6 +1316,23 @@ func TestParsePCIeDevice_PrefersFunctionClassOverDeviceType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePCIeDevice_DoesNotPromotePartNumberToDeviceClass(t *testing.T) {
|
||||
doc := map[string]interface{}{
|
||||
"Id": "NIC1",
|
||||
"DeviceType": "SingleFunction",
|
||||
"Model": "MCX75310AAS-NEAT",
|
||||
"PartNumber": "MCX75310AAS-NEAT",
|
||||
}
|
||||
|
||||
got := parsePCIeDevice(doc, nil)
|
||||
if got.DeviceClass != "PCIe device" {
|
||||
t.Fatalf("expected generic PCIe class fallback, got %q", got.DeviceClass)
|
||||
}
|
||||
if got.PartNumber != "MCX75310AAS-NEAT" {
|
||||
t.Fatalf("expected part number to stay intact, got %q", got.PartNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePCIeComponents_DoNotTreatNumericFunctionIDAsBDF(t *testing.T) {
|
||||
pcieFn := parsePCIeFunction(map[string]interface{}{
|
||||
"Id": "1",
|
||||
@@ -2160,6 +2177,94 @@ func TestReplayCollectStorage_UsesKnownControllerRecoveryWhenEnabled(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayCollectStorageVolumes_SkipsVolumeCapabilitiesFallbackMember(t *testing.T) {
|
||||
r := redfishSnapshotReader{tree: map[string]interface{}{
|
||||
"/redfish/v1/Systems/1/Storage": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage/DE00A000"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1/Storage/DE00A000": map[string]interface{}{
|
||||
"Id": "DE00A000",
|
||||
"Volumes": map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage/DE00A000/Volumes"},
|
||||
},
|
||||
"/redfish/v1/Systems/1/Storage/DE00A000/Volumes": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Systems/1/Storage/DE00A000/Volumes",
|
||||
"@odata.type": "#VolumeCollection.VolumeCollection",
|
||||
"Members": []interface{}{},
|
||||
"Members@odata.count": 0,
|
||||
"Name": "MR Volume Collection",
|
||||
},
|
||||
"/redfish/v1/Systems/1/Storage/DE00A000/Volumes/Capabilities": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Systems/1/Storage/DE00A000/Volumes/Capabilities",
|
||||
"@odata.type": "#Volume.v1_9_0.Volume",
|
||||
"Id": "Capabilities",
|
||||
"Name": "Capabilities for VolumeCollection",
|
||||
},
|
||||
}}
|
||||
|
||||
got := r.collectStorageVolumes("/redfish/v1/Systems/1", testAnalysisPlan(redfishprofile.AnalysisDirectives{}))
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected capabilities-only volume collection to stay empty, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayCollectPCIeDevices_UsesChassisDeviceSupplementalDocs(t *testing.T) {
|
||||
r := redfishSnapshotReader{tree: map[string]interface{}{
|
||||
"/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/2"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Chassis/1/PCIeDevices/2": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/2",
|
||||
"Name": "BCM 5719 1Gb 4p BASE-T OCP Adptr",
|
||||
"Model": "P51183-001",
|
||||
"PartNumber": "P51183-001",
|
||||
"Manufacturer": "Broadcom",
|
||||
"SerialNumber": "1CH0150001",
|
||||
"DeviceType": "SingleFunction",
|
||||
},
|
||||
"/redfish/v1/Chassis/1/Devices": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Devices/2"},
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Devices/4"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Chassis/1/Devices/2": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Chassis/1/Devices/2",
|
||||
"Name": "BCM 5719 1Gb 4p BASE-T OCP Adptr",
|
||||
"DeviceType": "LOM/NIC",
|
||||
"Manufacturer": "Broadcom",
|
||||
"PartNumber": "BCM95719N1905HC",
|
||||
"ProductPartNumber": "P51183-001",
|
||||
"SerialNumber": "1CH0150001",
|
||||
"Location": "OCP 3.0 Slot 15",
|
||||
},
|
||||
"/redfish/v1/Chassis/1/Devices/4": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Chassis/1/Devices/4",
|
||||
"Name": "Empty slot 2",
|
||||
"DeviceType": "Unknown",
|
||||
"Location": "PCI-E Slot 2",
|
||||
"SerialNumber": "",
|
||||
},
|
||||
}}
|
||||
|
||||
got := r.collectPCIeDevices(nil, []string{"/redfish/v1/Chassis/1"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one PCIe device, got %d", len(got))
|
||||
}
|
||||
if got[0].Slot != "OCP 3.0 Slot 15" {
|
||||
t.Fatalf("expected chassis device location to override weak slot label, got %+v", got[0])
|
||||
}
|
||||
if got[0].DeviceClass != "LOM/NIC" {
|
||||
t.Fatalf("expected chassis device type to enrich class, got %+v", got[0])
|
||||
}
|
||||
if got[0].DeviceClass == "P51183-001" {
|
||||
t.Fatalf("device class should not degrade into part number: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayCollectGPUs_DoesNotCollapseOnPlaceholderSerialAndSkipsNIC(t *testing.T) {
|
||||
r := redfishSnapshotReader{tree: map[string]interface{}{
|
||||
"/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{
|
||||
@@ -2240,6 +2345,18 @@ func TestParseBoardInfo_NormalizesNullPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBoardInfo_UsesSKUAsPartNumberFallback(t *testing.T) {
|
||||
got := parseBoardInfo(map[string]interface{}{
|
||||
"Manufacturer": "HPE",
|
||||
"Model": "ProLiant DL380 Gen11",
|
||||
"SerialNumber": "CZ2D1X0GS4",
|
||||
"SKU": "P52560-421",
|
||||
})
|
||||
if got.PartNumber != "P52560-421" {
|
||||
t.Fatalf("expected SKU to populate part number, got %q", got.PartNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCrawlPath_SkipsJsonSchemas(t *testing.T) {
|
||||
if shouldCrawlPath("/redfish/v1/JsonSchemas") {
|
||||
t.Fatalf("expected /JsonSchemas to be skipped")
|
||||
|
||||
@@ -43,13 +43,13 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
|
||||
TargetHost: targetHost,
|
||||
CollectedAt: collectedAt,
|
||||
Hardware: ReanimatorHardware{
|
||||
Board: convertBoard(result.Hardware.BoardInfo),
|
||||
Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)),
|
||||
CPUs: dedupeCPUs(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))),
|
||||
Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)),
|
||||
Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)),
|
||||
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
|
||||
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
|
||||
Board: convertBoard(result.Hardware.BoardInfo),
|
||||
Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)),
|
||||
CPUs: dedupeCPUs(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))),
|
||||
Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)),
|
||||
Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)),
|
||||
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
|
||||
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
|
||||
Sensors: convertSensors(result.Sensors),
|
||||
EventLogs: convertEventLogs(result.Events, collectedAt),
|
||||
},
|
||||
@@ -669,7 +669,17 @@ func convertMemoryFromDevices(devices []models.HardwareDevice, collectedAt strin
|
||||
}
|
||||
present := boolFromPresentPtr(d.Present, true)
|
||||
status := normalizeStatus(d.Status, true)
|
||||
if !present || d.SizeMB == 0 || status == "Empty" || strings.TrimSpace(d.SerialNumber) == "" {
|
||||
mem := models.MemoryDIMM{
|
||||
Present: present,
|
||||
SizeMB: d.SizeMB,
|
||||
Type: d.Type,
|
||||
Description: stringFromDetailMap(d.Details, "description"),
|
||||
Manufacturer: d.Manufacturer,
|
||||
SerialNumber: d.SerialNumber,
|
||||
PartNumber: d.PartNumber,
|
||||
Status: d.Status,
|
||||
}
|
||||
if !mem.IsInstalledInventory() || status == "Empty" || strings.TrimSpace(d.SerialNumber) == "" {
|
||||
continue
|
||||
}
|
||||
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
||||
@@ -1334,7 +1344,7 @@ func convertMemory(memory []models.MemoryDIMM, collectedAt string) []ReanimatorM
|
||||
|
||||
result := make([]ReanimatorMemory, 0, len(memory))
|
||||
for _, mem := range memory {
|
||||
if !mem.Present || mem.SizeMB == 0 || normalizeStatus(mem.Status, true) == "Empty" || strings.TrimSpace(mem.SerialNumber) == "" {
|
||||
if !mem.IsInstalledInventory() || normalizeStatus(mem.Status, true) == "Empty" || strings.TrimSpace(mem.SerialNumber) == "" {
|
||||
continue
|
||||
}
|
||||
status := normalizeStatus(mem.Status, true)
|
||||
|
||||
@@ -259,6 +259,29 @@ func TestConvertMemory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertMemory_KeepsInstalledDIMMWithUnknownSize(t *testing.T) {
|
||||
memory := []models.MemoryDIMM{
|
||||
{
|
||||
Slot: "PROC 1 DIMM 3",
|
||||
Present: true,
|
||||
SizeMB: 0,
|
||||
Manufacturer: "Hynix",
|
||||
PartNumber: "HMCG88AEBRA115N",
|
||||
SerialNumber: "2B5F92C6",
|
||||
Status: "OK",
|
||||
},
|
||||
}
|
||||
|
||||
result := convertMemory(memory, "2026-03-30T10:00:00Z")
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 inventory-only DIMM, got %d", len(result))
|
||||
}
|
||||
if result[0].PartNumber != "HMCG88AEBRA115N" || result[0].SerialNumber != "2B5F92C6" || result[0].SizeMB != 0 {
|
||||
t.Fatalf("unexpected converted memory: %+v", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_CPUSerialIsNotSynthesizedAndSocketIsDeduped(t *testing.T) {
|
||||
input := &models.AnalysisResult{
|
||||
Filename: "cpu-dedupe.json",
|
||||
|
||||
29
internal/models/memory.go
Normal file
29
internal/models/memory.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package models
|
||||
|
||||
import "strings"
|
||||
|
||||
// HasInventoryIdentity reports whether the DIMM has enough identifying
|
||||
// inventory data to treat it as a populated module even when size is unknown.
|
||||
func (m MemoryDIMM) HasInventoryIdentity() bool {
|
||||
return strings.TrimSpace(m.SerialNumber) != "" ||
|
||||
strings.TrimSpace(m.PartNumber) != "" ||
|
||||
strings.TrimSpace(m.Type) != "" ||
|
||||
strings.TrimSpace(m.Technology) != "" ||
|
||||
strings.TrimSpace(m.Description) != ""
|
||||
}
|
||||
|
||||
// IsInstalledInventory reports whether the DIMM represents an installed module
|
||||
// that should be kept in canonical inventory and exports.
|
||||
func (m MemoryDIMM) IsInstalledInventory() bool {
|
||||
if !m.Present {
|
||||
return false
|
||||
}
|
||||
|
||||
status := strings.ToLower(strings.TrimSpace(m.Status))
|
||||
switch status {
|
||||
case "empty", "absent", "not installed":
|
||||
return false
|
||||
}
|
||||
|
||||
return m.SizeMB > 0 || m.HasInventoryIdentity()
|
||||
}
|
||||
455
internal/parser/vendors/hpe_ilo_ahs/parser.go
vendored
455
internal/parser/vendors/hpe_ilo_ahs/parser.go
vendored
@@ -25,12 +25,17 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
partNumberPattern = regexp.MustCompile(`(?i)^[a-z0-9]{1,4}\d{4,6}-[a-z0-9]{2,4}$`)
|
||||
serverSerialRE = regexp.MustCompile(`(?i)(?:^|[_-])([a-z0-9]{10})(?:[_-]|\.)`)
|
||||
dimmSlotRE = regexp.MustCompile(`^PROC\s+(\d+)\s+DIMM\s+(\d+)$`)
|
||||
procSlotRE = regexp.MustCompile(`^Proc\s+(\d+)$`)
|
||||
psuSlotRE = regexp.MustCompile(`^Power Supply\s+(\d+)$`)
|
||||
eventTimeRE = regexp.MustCompile(`^\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}$`)
|
||||
partNumberPattern = regexp.MustCompile(`(?i)^[a-z0-9]{1,4}\d{4,6}-[a-z0-9]{2,4}$`)
|
||||
serverSerialRE = regexp.MustCompile(`(?i)(?:^|[_-])([a-z0-9]{10})(?:[_-]|\.)`)
|
||||
dimmSlotRE = regexp.MustCompile(`^PROC\s+(\d+)\s+DIMM\s+(\d+)$`)
|
||||
procSlotRE = regexp.MustCompile(`^Proc\s+(\d+)$`)
|
||||
psuSlotRE = regexp.MustCompile(`^Power Supply\s+(\d+)$`)
|
||||
eventTimeRE = regexp.MustCompile(`^\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}$`)
|
||||
psuXMLRE = regexp.MustCompile(`(?s)<PowerSupplySlot id="(\d+)">(.*?)</PowerSupplySlot>`)
|
||||
firmwareLockdownRE = regexp.MustCompile(`(?s)<FirmwareLockdown>(.*?)</FirmwareLockdown>`)
|
||||
xmlFieldRE = regexp.MustCompile(`(?s)<([A-Za-z0-9_-]+)>([^<]*)</[A-Za-z0-9_-]+>`)
|
||||
psuLogRE = regexp.MustCompile(`Update bay (\d+) (SPN|Serial Number|Model Number|fw ver\.), value = ([A-Za-z0-9._-]+)`)
|
||||
versionFragmentRE = regexp.MustCompile(`\d+(?:\.\d+)+`)
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -129,6 +134,13 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
||||
result.Hardware.NetworkAdapters = dedupeNetworkAdapters(parseNetworkAdapters(tokens))
|
||||
result.Hardware.Firmware = dedupeFirmware(parseFirmware(tokens))
|
||||
|
||||
psuSupplements := parsePSUSupplements(entries)
|
||||
result.Hardware.PowerSupply = dedupePSUs(mergePSUs(result.Hardware.PowerSupply, psuSupplements))
|
||||
|
||||
lockdownFW, nicFirmwareByVendor := parseBCertFirmware(entries)
|
||||
result.Hardware.NetworkAdapters = dedupeNetworkAdapters(enrichNetworkAdapters(result.Hardware.NetworkAdapters, nicFirmwareByVendor))
|
||||
result.Hardware.Firmware = dedupeFirmware(append(result.Hardware.Firmware, lockdownFW...))
|
||||
|
||||
storage, volumes, controllerDevices, controllerFW := parseRedfishStorage(redfishDocs)
|
||||
result.Hardware.Storage = dedupeStorage(storage)
|
||||
result.Hardware.Volumes = volumes
|
||||
@@ -446,22 +458,37 @@ func parseDIMMs(tokens []string) []models.MemoryDIMM {
|
||||
|
||||
func parsePSUs(tokens []string) []models.PSU {
|
||||
out := make([]models.PSU, 0, 4)
|
||||
for i := 0; i+2 < len(tokens); i++ {
|
||||
for i := 0; i < len(tokens); i++ {
|
||||
match := psuSlotRE.FindStringSubmatch(tokens[i])
|
||||
if len(match) != 2 {
|
||||
continue
|
||||
}
|
||||
slot := "PSU " + match[1]
|
||||
serial := tokens[i+1]
|
||||
partNumber := tokens[i+2]
|
||||
if isUnavailable(serial) && isUnavailable(partNumber) {
|
||||
vendor := ""
|
||||
serial := ""
|
||||
partNumber := ""
|
||||
for j := i + 1; j < len(tokens) && j <= i+5; j++ {
|
||||
field := strings.TrimSpace(tokens[j])
|
||||
if strings.HasPrefix(field, "PciRoot(") || psuSlotRE.MatchString(field) || dimmSlotRE.MatchString(field) || procSlotRE.MatchString(field) || eventTimeRE.MatchString(field) {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case vendor == "" && looksLikePSUVendor(field):
|
||||
vendor = field
|
||||
case partNumber == "" && looksLikePartNumber(field):
|
||||
partNumber = field
|
||||
case serial == "" && isLikelySerial(field):
|
||||
serial = field
|
||||
}
|
||||
}
|
||||
if serial == "" && partNumber == "" {
|
||||
continue
|
||||
}
|
||||
psu := models.PSU{
|
||||
Slot: slot,
|
||||
Present: true,
|
||||
Model: valueOr(partNumber, "Power Supply"),
|
||||
Vendor: "HPE",
|
||||
Vendor: valueOr(cleanUnavailable(vendor), "HPE"),
|
||||
SerialNumber: cleanUnavailable(serial),
|
||||
PartNumber: cleanUnavailable(partNumber),
|
||||
Status: "ok",
|
||||
@@ -471,6 +498,80 @@ func parsePSUs(tokens []string) []models.PSU {
|
||||
return out
|
||||
}
|
||||
|
||||
func parsePSUSupplements(entries []ahsEntry) []models.PSU {
|
||||
bySlot := make(map[string]models.PSU)
|
||||
|
||||
for _, entry := range entries {
|
||||
text := string(entry.Content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.EqualFold(entry.Name, "bcert.pkg") {
|
||||
for _, match := range psuXMLRE.FindAllStringSubmatch(text, -1) {
|
||||
slotNum, _ := strconv.Atoi(match[1])
|
||||
slot := fmt.Sprintf("PSU %d", slotNum+1)
|
||||
fields := parseXMLFields(match[2])
|
||||
item := bySlot[slot]
|
||||
item.Slot = slot
|
||||
item.Present = strings.EqualFold(fields["Present"], "Yes") || item.Present
|
||||
if serial := strings.TrimSpace(fields["SerialNumber"]); serial != "" {
|
||||
item.SerialNumber = serial
|
||||
}
|
||||
if fw := strings.TrimSpace(fields["FirmwareVersion"]); fw != "" {
|
||||
item.Firmware = fw
|
||||
}
|
||||
if spare := strings.TrimSpace(fields["SparePartNumber"]); spare != "" {
|
||||
if item.Details == nil {
|
||||
item.Details = make(map[string]any)
|
||||
}
|
||||
item.Details["spare_part_number"] = spare
|
||||
}
|
||||
bySlot[slot] = item
|
||||
}
|
||||
}
|
||||
|
||||
for _, match := range psuLogRE.FindAllStringSubmatch(text, -1) {
|
||||
slotNum, _ := strconv.Atoi(match[1])
|
||||
slot := fmt.Sprintf("PSU %d", slotNum+1)
|
||||
item := bySlot[slot]
|
||||
item.Slot = slot
|
||||
item.Present = true
|
||||
value := strings.TrimSpace(match[3])
|
||||
switch match[2] {
|
||||
case "SPN":
|
||||
if item.Details == nil {
|
||||
item.Details = make(map[string]any)
|
||||
}
|
||||
item.Details["spare_part_number"] = value
|
||||
case "Serial Number":
|
||||
item.SerialNumber = value
|
||||
case "Model Number":
|
||||
item.Model = value
|
||||
item.PartNumber = value
|
||||
case "fw ver.":
|
||||
item.Firmware = normalizeLooseVersion(value)
|
||||
}
|
||||
bySlot[slot] = item
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]models.PSU, 0, len(bySlot))
|
||||
for _, item := range bySlot {
|
||||
if item.Slot == "" {
|
||||
continue
|
||||
}
|
||||
item.Vendor = valueOr(item.Vendor, "HPE")
|
||||
item.Status = valueOr(item.Status, "ok")
|
||||
if item.Model == "" {
|
||||
item.Model = valueOr(item.PartNumber, "Power Supply")
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Slot < out[j].Slot })
|
||||
return out
|
||||
}
|
||||
|
||||
type pcieSequence struct {
|
||||
UEFIPath string
|
||||
Code string
|
||||
@@ -621,13 +722,53 @@ func parseRedfishStorage(docs map[string]map[string]any) ([]models.Storage, []mo
|
||||
|
||||
storage := make([]models.Storage, 0, 8)
|
||||
volumes := make([]models.StorageVolume, 0, 4)
|
||||
devices := make([]models.HardwareDevice, 0, 4)
|
||||
firmware := make([]models.FirmwareInfo, 0, 4)
|
||||
devices := make([]models.HardwareDevice, 0, 6)
|
||||
firmware := make([]models.FirmwareInfo, 0, 8)
|
||||
fabricNames := make(map[string]string)
|
||||
fabricTypes := make(map[string]string)
|
||||
|
||||
for _, path := range paths {
|
||||
doc := docs[path]
|
||||
docType := asString(doc["@odata.type"])
|
||||
switch {
|
||||
case strings.Contains(docType, "#Fabric."):
|
||||
fabricID := redfishID(path)
|
||||
fabricNames[fabricID] = strings.TrimSpace(asString(doc["Name"]))
|
||||
fabricTypes[fabricID] = strings.TrimSpace(asString(doc["FabricType"]))
|
||||
|
||||
case strings.Contains(docType, "#Switch."):
|
||||
fabricID := fabricIDFromPath(path)
|
||||
name := valueOr(fabricNames[fabricID], strings.TrimSpace(asString(doc["Name"])))
|
||||
model := strings.TrimSpace(asString(doc["Model"]))
|
||||
fw := strings.TrimSpace(asString(doc["FirmwareVersion"]))
|
||||
device := models.HardwareDevice{
|
||||
ID: "hpe-fabric-" + redfishID(path),
|
||||
Kind: models.DeviceKindStorage,
|
||||
Source: "redfish",
|
||||
Slot: valueOr(fabricID, redfishID(path)),
|
||||
DeviceClass: "storage_backplane",
|
||||
Model: valueOr(name, model),
|
||||
PartNumber: model,
|
||||
Firmware: fw,
|
||||
Status: redfishStatus(doc["Status"]),
|
||||
Details: map[string]any{
|
||||
"odata_id": path,
|
||||
"fabric_type": valueOr(fabricTypes[fabricID], strings.TrimSpace(asString(doc["FabricType"]))),
|
||||
"switch_type": strings.TrimSpace(asString(doc["SwitchType"])),
|
||||
"supported_protocols": stringSlice(doc["SupportedProtocols"]),
|
||||
"domain_id": asInt64(doc["DomainID"]),
|
||||
"fabric_name": fabricNames[fabricID],
|
||||
"connected_chassis_id": asString(nested(doc, "Links", "Chassis", "@odata.id")),
|
||||
},
|
||||
}
|
||||
devices = append(devices, device)
|
||||
if fw != "" {
|
||||
firmware = append(firmware, models.FirmwareInfo{
|
||||
DeviceName: valueOr(name, model),
|
||||
Version: fw,
|
||||
})
|
||||
}
|
||||
|
||||
case strings.Contains(docType, "#StorageController."):
|
||||
slot := redfishServiceLabel(doc, "Location", "PartLocation", "ServiceLabel")
|
||||
model := valueOr(asString(doc["Model"]), asString(doc["Name"]))
|
||||
@@ -649,9 +790,16 @@ func parseRedfishStorage(docs map[string]map[string]any) ([]models.Storage, []mo
|
||||
Firmware: fw,
|
||||
Status: redfishStatus(doc["Status"]),
|
||||
Details: map[string]any{
|
||||
"odata_id": path,
|
||||
"part_number": partNumber,
|
||||
"sku": sku,
|
||||
"odata_id": path,
|
||||
"part_number": partNumber,
|
||||
"sku": sku,
|
||||
"speed_gbps": asFloat64(doc["SpeedGbps"]),
|
||||
"supported_controller_protocols": stringSlice(doc["SupportedControllerProtocols"]),
|
||||
"supported_device_protocols": stringSlice(doc["SupportedDeviceProtocols"]),
|
||||
"supported_raid_types": stringSlice(doc["SupportedRAIDTypes"]),
|
||||
"cache_total_mib": asInt64(nested(doc, "CacheSummary", "TotalCacheSizeMiB")),
|
||||
"persistent_cache_mib": asInt64(nested(doc, "CacheSummary", "PersistentCacheSizeMiB")),
|
||||
"durable_name": firstDurableName(doc),
|
||||
},
|
||||
}
|
||||
if width := asInt(doc, "PCIeInterface", "LanesInUse"); width > 0 {
|
||||
@@ -692,8 +840,12 @@ func parseRedfishStorage(docs map[string]map[string]any) ([]models.Storage, []mo
|
||||
RemainingEndurancePct: endurance,
|
||||
Status: redfishStatus(doc["Status"]),
|
||||
Details: map[string]any{
|
||||
"odata_id": path,
|
||||
"capacity_bytes": capacity,
|
||||
"odata_id": path,
|
||||
"capacity_bytes": capacity,
|
||||
"failure_predicted": asBool(doc["FailurePredicted"]),
|
||||
"negotiated_speed_gbps": asFloat64(doc["NegotiatedSpeedGbs"]),
|
||||
"capable_speed_gbps": asFloat64(doc["CapableSpeedGbs"]),
|
||||
"location_indicator_active": asBool(doc["LocationIndicatorActive"]),
|
||||
},
|
||||
}
|
||||
storage = append(storage, entry)
|
||||
@@ -1005,6 +1157,16 @@ func isHPEManufacturer(v string) bool {
|
||||
return v == "HPE" || v == "HP"
|
||||
}
|
||||
|
||||
func looksLikePSUVendor(v string) bool {
|
||||
v = strings.TrimSpace(strings.ToUpper(v))
|
||||
switch v {
|
||||
case "HPE", "HP", "DELTA", "LITEON", "LTEON":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikeServerModel(v string) bool {
|
||||
v = sanitizeModel(v)
|
||||
if v == "" {
|
||||
@@ -1115,6 +1277,163 @@ func inferVendor(model string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func mergePSUs(base, extra []models.PSU) []models.PSU {
|
||||
merged := make(map[string]models.PSU)
|
||||
order := make([]string, 0, len(base)+len(extra))
|
||||
mergeOne := func(item models.PSU) {
|
||||
key := strings.ToLower(strings.TrimSpace(item.Slot))
|
||||
if key == "" {
|
||||
key = strings.ToLower(strings.TrimSpace(valueOr(item.SerialNumber, item.Model+"|"+item.PartNumber)))
|
||||
}
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
current, exists := merged[key]
|
||||
if !exists {
|
||||
merged[key] = item
|
||||
order = append(order, key)
|
||||
return
|
||||
}
|
||||
if current.Slot == "" {
|
||||
current.Slot = item.Slot
|
||||
}
|
||||
current.Present = current.Present || item.Present
|
||||
current.Model = valueOr(current.Model, item.Model)
|
||||
current.Description = valueOr(current.Description, item.Description)
|
||||
current.Vendor = valueOr(current.Vendor, item.Vendor)
|
||||
if current.WattageW == 0 {
|
||||
current.WattageW = item.WattageW
|
||||
}
|
||||
current.SerialNumber = valueOr(current.SerialNumber, item.SerialNumber)
|
||||
current.PartNumber = valueOr(current.PartNumber, item.PartNumber)
|
||||
current.Firmware = valueOr(current.Firmware, item.Firmware)
|
||||
current.Status = valueOr(current.Status, item.Status)
|
||||
current.InputType = valueOr(current.InputType, item.InputType)
|
||||
if current.InputPowerW == 0 {
|
||||
current.InputPowerW = item.InputPowerW
|
||||
}
|
||||
if current.OutputPowerW == 0 {
|
||||
current.OutputPowerW = item.OutputPowerW
|
||||
}
|
||||
if current.InputVoltage == 0 {
|
||||
current.InputVoltage = item.InputVoltage
|
||||
}
|
||||
if current.OutputVoltage == 0 {
|
||||
current.OutputVoltage = item.OutputVoltage
|
||||
}
|
||||
if current.TemperatureC == 0 {
|
||||
current.TemperatureC = item.TemperatureC
|
||||
}
|
||||
current.Details = mergeDetailMaps(current.Details, item.Details)
|
||||
merged[key] = current
|
||||
}
|
||||
for _, item := range base {
|
||||
mergeOne(item)
|
||||
}
|
||||
for _, item := range extra {
|
||||
mergeOne(item)
|
||||
}
|
||||
out := make([]models.PSU, 0, len(order))
|
||||
for _, key := range order {
|
||||
out = append(out, merged[key])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func enrichNetworkAdapters(items []models.NetworkAdapter, firmwareByVendor map[string]string) []models.NetworkAdapter {
|
||||
out := make([]models.NetworkAdapter, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Firmware == "" {
|
||||
if fw := firmwareByVendor[strings.ToLower(strings.TrimSpace(item.Vendor))]; fw != "" {
|
||||
item.Firmware = fw
|
||||
}
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseBCertFirmware(entries []ahsEntry) ([]models.FirmwareInfo, map[string]string) {
|
||||
out := make([]models.FirmwareInfo, 0, 8)
|
||||
nicFirmwareByVendor := make(map[string]string)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
tagNames := map[string]string{
|
||||
"SystemProgrammableLogicDevice": "System Programmable Logic Device",
|
||||
"ServerPlatformServicesSPSFirmware": "Server Platform Services (SPS) Firmware",
|
||||
"STMicroGen11TPM": "TPM Firmware",
|
||||
"PrimaryR012U3x16slotsriserx8-x16-x8": "PCIe Riser 1 Programmable Logic Device",
|
||||
"HPEMR408i-oGen11": "HPE MR408i-o Gen11",
|
||||
"UBM3": "8 SFF 24G x1NVMe/SAS UBM3 BC BP",
|
||||
"BCM57191Gb4pBASE-T": "BCM 5719 1Gb 4p BASE-T OCP Adptr",
|
||||
"BCM57191Gb4pBASE-TOCP3": "BCM 5719 1Gb 4p BASE-T OCP Adptr",
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !strings.EqualFold(entry.Name, "bcert.pkg") {
|
||||
continue
|
||||
}
|
||||
text := string(entry.Content)
|
||||
for _, match := range firmwareLockdownRE.FindAllStringSubmatch(text, -1) {
|
||||
fields := parseXMLFields(match[1])
|
||||
for tag, value := range fields {
|
||||
name := tagNames[tag]
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
version := normalizeBCertVersion(tag, value)
|
||||
if version == "" {
|
||||
continue
|
||||
}
|
||||
appendFirmware(&out, seen, models.FirmwareInfo{
|
||||
DeviceName: name,
|
||||
Version: version,
|
||||
})
|
||||
if strings.Contains(name, "BCM 5719") {
|
||||
nicFirmwareByVendor["broadcom"] = version
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nicFirmwareByVendor
|
||||
}
|
||||
|
||||
func parseXMLFields(block string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
for _, match := range xmlFieldRE.FindAllStringSubmatch(block, -1) {
|
||||
out[match[1]] = strings.TrimSpace(match[2])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeBCertVersion(tag, value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.EqualFold(value, "NA") {
|
||||
return ""
|
||||
}
|
||||
switch tag {
|
||||
case "UBM3":
|
||||
if idx := strings.LastIndex(value, "/"); idx >= 0 && idx+1 < len(value) {
|
||||
return strings.TrimSpace(value[idx+1:])
|
||||
}
|
||||
case "IntegratedLights-OutVI":
|
||||
if idx := strings.Index(value, " - "); idx > 0 {
|
||||
return strings.TrimSpace(value[:idx])
|
||||
}
|
||||
case "U54":
|
||||
return value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeLooseVersion(value string) string {
|
||||
if match := versionFragmentRE.FindString(strings.TrimSpace(value)); match != "" {
|
||||
return match
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func slotLabelFromCode(code string) string {
|
||||
parts := strings.Split(code, ".")
|
||||
if len(parts) < 3 {
|
||||
@@ -1132,6 +1451,16 @@ func slotLabelFromCode(code string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func fabricIDFromPath(path string) string {
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
for i := 0; i+1 < len(parts); i++ {
|
||||
if parts[i] == "Fabrics" {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func inferSeverity(message string) models.Severity {
|
||||
lower := strings.ToLower(message)
|
||||
switch {
|
||||
@@ -1261,6 +1590,24 @@ func asInt64(v any) int64 {
|
||||
}
|
||||
}
|
||||
|
||||
func asFloat64(v any) float64 {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case float32:
|
||||
return float64(t)
|
||||
case int:
|
||||
return float64(t)
|
||||
case int64:
|
||||
return float64(t)
|
||||
case json.Number:
|
||||
f, _ := t.Float64()
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func asOptionalInt(v any) *int {
|
||||
switch value := v.(type) {
|
||||
case float64:
|
||||
@@ -1274,6 +1621,11 @@ func asOptionalInt(v any) *int {
|
||||
}
|
||||
}
|
||||
|
||||
func asBool(v any) bool {
|
||||
b, ok := v.(bool)
|
||||
return ok && b
|
||||
}
|
||||
|
||||
func valueOr(v, fallback string) string {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
@@ -1281,6 +1633,73 @@ func valueOr(v, fallback string) string {
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
func stringSlice(v any) []string {
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
value := strings.TrimSpace(asString(item))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstDurableName(doc map[string]any) string {
|
||||
items, ok := doc["Identifiers"].([]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
for _, item := range items {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if value := strings.TrimSpace(asString(entry["DurableName"])); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mergeDetailMaps(base, extra map[string]any) map[string]any {
|
||||
if len(extra) == 0 {
|
||||
return base
|
||||
}
|
||||
if base == nil {
|
||||
base = make(map[string]any, len(extra))
|
||||
}
|
||||
for key, value := range extra {
|
||||
if _, exists := base[key]; !exists || isZeroValue(base[key]) {
|
||||
base[key] = value
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func isZeroValue(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return strings.TrimSpace(t) == ""
|
||||
case int:
|
||||
return t == 0
|
||||
case int64:
|
||||
return t == 0
|
||||
case float64:
|
||||
return t == 0
|
||||
case bool:
|
||||
return !t
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
out := v
|
||||
return &out
|
||||
|
||||
@@ -27,6 +27,7 @@ func TestParseAHSInventory(t *testing.T) {
|
||||
content := makeAHSArchive(t, []ahsTestEntry{
|
||||
{Name: "CUST_INFO.DAT", Payload: make([]byte, 16)},
|
||||
{Name: "0000088-2026-03-30.zbb", Payload: gzipBytes(t, []byte(sampleInventoryBlob()))},
|
||||
{Name: "bcert.pkg", Payload: []byte(sampleBCertBlob())},
|
||||
})
|
||||
|
||||
result, err := p.Parse([]parser.ExtractedFile{{
|
||||
@@ -73,6 +74,9 @@ func TestParseAHSInventory(t *testing.T) {
|
||||
if result.Hardware.PowerSupply[0].SerialNumber != "5XUWB0C4DJG4BV" {
|
||||
t.Fatalf("unexpected PSU serial: %q", result.Hardware.PowerSupply[0].SerialNumber)
|
||||
}
|
||||
if result.Hardware.PowerSupply[0].Firmware != "2.00" {
|
||||
t.Fatalf("unexpected PSU firmware: %q", result.Hardware.PowerSupply[0].Firmware)
|
||||
}
|
||||
|
||||
if len(result.Hardware.Storage) != 1 {
|
||||
t.Fatalf("expected one physical drive, got %d", len(result.Hardware.Storage))
|
||||
@@ -93,6 +97,8 @@ func TestParseAHSInventory(t *testing.T) {
|
||||
}
|
||||
foundILO := false
|
||||
foundControllerFW := false
|
||||
foundNICFW := false
|
||||
foundBackplaneFW := false
|
||||
for _, item := range result.Hardware.Firmware {
|
||||
if item.DeviceName == "iLO 6" && item.Version == "v1.63p20" {
|
||||
foundILO = true
|
||||
@@ -100,6 +106,12 @@ func TestParseAHSInventory(t *testing.T) {
|
||||
if item.DeviceName == "HPE MR408i-o Gen11" && item.Version == "52.26.3-5379" {
|
||||
foundControllerFW = true
|
||||
}
|
||||
if item.DeviceName == "BCM 5719 1Gb 4p BASE-T OCP Adptr" && item.Version == "20.28.41" {
|
||||
foundNICFW = true
|
||||
}
|
||||
if item.DeviceName == "8 SFF 24G x1NVMe/SAS UBM3 BC BP" && item.Version == "1.24" {
|
||||
foundBackplaneFW = true
|
||||
}
|
||||
}
|
||||
if !foundILO {
|
||||
t.Fatalf("expected iLO firmware entry")
|
||||
@@ -107,6 +119,31 @@ func TestParseAHSInventory(t *testing.T) {
|
||||
if !foundControllerFW {
|
||||
t.Fatalf("expected controller firmware entry")
|
||||
}
|
||||
if !foundNICFW {
|
||||
t.Fatalf("expected broadcom firmware entry")
|
||||
}
|
||||
if !foundBackplaneFW {
|
||||
t.Fatalf("expected backplane firmware entry")
|
||||
}
|
||||
|
||||
broadcomFound := false
|
||||
backplaneFound := false
|
||||
for _, nic := range result.Hardware.NetworkAdapters {
|
||||
if nic.SerialNumber == "1CH0150001" && nic.Firmware == "20.28.41" {
|
||||
broadcomFound = true
|
||||
}
|
||||
}
|
||||
for _, dev := range result.Hardware.Devices {
|
||||
if dev.DeviceClass == "storage_backplane" && dev.Firmware == "1.24" {
|
||||
backplaneFound = true
|
||||
}
|
||||
}
|
||||
if !broadcomFound {
|
||||
t.Fatalf("expected broadcom adapter firmware to be enriched")
|
||||
}
|
||||
if !backplaneFound {
|
||||
t.Fatalf("expected backplane canonical device")
|
||||
}
|
||||
|
||||
if len(result.Hardware.Devices) < 6 {
|
||||
t.Fatalf("expected canonical devices, got %d", len(result.Hardware.Devices))
|
||||
@@ -146,17 +183,35 @@ func TestParseExampleAHS(t *testing.T) {
|
||||
if len(result.Hardware.Storage) < 2 {
|
||||
t.Fatalf("expected at least two drives, got %d", len(result.Hardware.Storage))
|
||||
}
|
||||
if len(result.Hardware.PowerSupply) != 2 {
|
||||
t.Fatalf("expected exactly two PSUs, got %d: %+v", len(result.Hardware.PowerSupply), result.Hardware.PowerSupply)
|
||||
}
|
||||
|
||||
foundController := false
|
||||
foundBackplaneFW := false
|
||||
foundNICFW := false
|
||||
for _, device := range result.Hardware.Devices {
|
||||
if device.Model == "HPE MR408i-o Gen11" && device.SerialNumber == "PXSFQ0BBIJY3B3" {
|
||||
foundController = true
|
||||
break
|
||||
}
|
||||
if device.DeviceClass == "storage_backplane" && device.Firmware == "1.24" {
|
||||
foundBackplaneFW = true
|
||||
}
|
||||
}
|
||||
if !foundController {
|
||||
t.Fatalf("expected MR408i-o controller in canonical devices")
|
||||
}
|
||||
for _, fw := range result.Hardware.Firmware {
|
||||
if fw.DeviceName == "BCM 5719 1Gb 4p BASE-T OCP Adptr" && fw.Version == "20.28.41" {
|
||||
foundNICFW = true
|
||||
}
|
||||
}
|
||||
if !foundBackplaneFW {
|
||||
t.Fatalf("expected backplane device in canonical devices")
|
||||
}
|
||||
if !foundNICFW {
|
||||
t.Fatalf("expected broadcom firmware from bcert/pkg lockdown")
|
||||
}
|
||||
}
|
||||
|
||||
type ahsTestEntry struct {
|
||||
@@ -239,11 +294,17 @@ func sampleInventoryBlob() string {
|
||||
"03/30/2026 09:47:33",
|
||||
"iLO network link down.",
|
||||
`{"@odata.id":"/redfish/v1/Systems/1/Storage/DE00A000/Controllers/0","@odata.type":"#StorageController.v1_7_0.StorageController","Id":"0","Name":"HPE MR408i-o Gen11","FirmwareVersion":"52.26.3-5379","Manufacturer":"HPE","Model":"HPE MR408i-o Gen11","PartNumber":"P58543-001","SKU":"P58335-B21","SerialNumber":"PXSFQ0BBIJY3B3","Status":{"State":"Enabled","Health":"OK"},"Location":{"PartLocation":{"ServiceLabel":"Slot=14","LocationType":"Slot","LocationOrdinalValue":14}},"PCIeInterface":{"PCIeType":"Gen4","LanesInUse":8}}`,
|
||||
`{"@odata.id":"/redfish/v1/Fabrics/DE00A000","@odata.type":"#Fabric.v1_3_0.Fabric","Id":"DE00A000","Name":"8 SFF 24G x1NVMe/SAS UBM3 BC BP","FabricType":"MultiProtocol"}`,
|
||||
`{"@odata.id":"/redfish/v1/Fabrics/DE00A000/Switches/1","@odata.type":"#Switch.v1_9_1.Switch","Id":"1","Name":"Direct Attached","Model":"UBM3","FirmwareVersion":"1.24","SupportedProtocols":["SAS","SATA","NVMe"],"SwitchType":"MultiProtocol","Status":{"State":"Enabled","Health":"OK"}}`,
|
||||
`{"@odata.id":"/redfish/v1/Chassis/DE00A000/Drives/0","@odata.type":"#Drive.v1_17_0.Drive","Id":"0","Name":"480GB 6G SATA SSD","Status":{"State":"StandbyOffline","Health":"OK"},"PhysicalLocation":{"PartLocation":{"ServiceLabel":"Slot=14:Port=1:Box=3:Bay=1","LocationType":"Bay","LocationOrdinalValue":1}},"CapacityBytes":480103981056,"MediaType":"SSD","Model":"SAMSUNGMZ7L3480HCHQ-00A07","Protocol":"SATA","Revision":"JXTC604Q","SerialNumber":"S664NC0Y502720","PredictedMediaLifeLeftPercent":100}`,
|
||||
`{"@odata.id":"/redfish/v1/Chassis/DE00A000/Drives/64515","@odata.type":"#Drive.v1_17_0.Drive","Id":"64515","Name":"Empty Bay","Status":{"State":"Absent","Health":"OK"}}`,
|
||||
)
|
||||
}
|
||||
|
||||
func sampleBCertBlob() string {
|
||||
return `<BC><MfgRecord><PowerSupplySlot id="0"><Present>Yes</Present><SerialNumber>5XUWB0C4DJG4BV</SerialNumber><FirmwareVersion>2.00</FirmwareVersion><SparePartNumber>P44412-001</SparePartNumber></PowerSupplySlot><FirmwareLockdown><SystemProgrammableLogicDevice>0x12</SystemProgrammableLogicDevice><ServerPlatformServicesSPSFirmware>6.1.4.47</ServerPlatformServicesSPSFirmware><STMicroGen11TPM>1.512</STMicroGen11TPM><HPEMR408i-oGen11>52.26.3-5379</HPEMR408i-oGen11><UBM3>UBM3/1.24</UBM3><BCM57191Gb4pBASE-TOCP3>20.28.41</BCM57191Gb4pBASE-TOCP3></FirmwareLockdown></MfgRecord></BC>`
|
||||
}
|
||||
|
||||
func stringsJoin(parts ...string) string {
|
||||
return string(bytes.Join(func() [][]byte {
|
||||
out := make([][]byte, 0, len(parts))
|
||||
|
||||
@@ -81,7 +81,7 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
|
||||
}
|
||||
|
||||
for _, mem := range hw.Memory {
|
||||
if !mem.Present || mem.SizeMB == 0 {
|
||||
if !mem.IsInstalledInventory() {
|
||||
continue
|
||||
}
|
||||
present := mem.Present
|
||||
|
||||
@@ -90,6 +90,63 @@ func TestBuildHardwareDevices_MemorySameSerialDifferentSlots_NotDeduped(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHardwareDevices_ZeroSizeMemoryWithInventoryIsIncluded(t *testing.T) {
|
||||
hw := &models.HardwareConfig{
|
||||
Memory: []models.MemoryDIMM{
|
||||
{
|
||||
Slot: "PROC 1 DIMM 3",
|
||||
Location: "PROC 1 DIMM 3",
|
||||
Present: true,
|
||||
SizeMB: 0,
|
||||
Manufacturer: "Hynix",
|
||||
SerialNumber: "2B5F92C6",
|
||||
PartNumber: "HMCG88AEBRA115N",
|
||||
Status: "ok",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
devices := BuildHardwareDevices(hw)
|
||||
memoryCount := 0
|
||||
for _, d := range devices {
|
||||
if d.Kind != models.DeviceKindMemory {
|
||||
continue
|
||||
}
|
||||
memoryCount++
|
||||
if d.Slot != "PROC 1 DIMM 3" || d.PartNumber != "HMCG88AEBRA115N" || d.SerialNumber != "2B5F92C6" {
|
||||
t.Fatalf("unexpected memory device: %+v", d)
|
||||
}
|
||||
}
|
||||
if memoryCount != 1 {
|
||||
t.Fatalf("expected 1 installed zero-size memory record, got %d", memoryCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSpecification_ZeroSizeMemoryWithInventoryIsShown(t *testing.T) {
|
||||
hw := &models.HardwareConfig{
|
||||
Memory: []models.MemoryDIMM{
|
||||
{
|
||||
Slot: "PROC 1 DIMM 3",
|
||||
Present: true,
|
||||
SizeMB: 0,
|
||||
Manufacturer: "Hynix",
|
||||
PartNumber: "HMCG88AEBRA115N",
|
||||
SerialNumber: "2B5F92C6",
|
||||
Status: "ok",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
spec := buildSpecification(hw)
|
||||
for _, line := range spec {
|
||||
if line.Category == "Память" && line.Name == "Hynix HMCG88AEBRA115N (size unknown)" && line.Quantity == 1 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("expected memory spec line for zero-size identified DIMM, got %+v", spec)
|
||||
}
|
||||
|
||||
func TestBuildHardwareDevices_DuplicateSerials_AreAnnotated(t *testing.T) {
|
||||
hw := &models.HardwareConfig{
|
||||
Memory: []models.MemoryDIMM{
|
||||
|
||||
@@ -530,11 +530,21 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
|
||||
continue
|
||||
}
|
||||
present := mem.Present != nil && *mem.Present
|
||||
// Skip empty slots (not present or 0 size)
|
||||
if !present || mem.SizeMB == 0 {
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
// Include frequency if available
|
||||
|
||||
if mem.SizeMB == 0 {
|
||||
name := strings.TrimSpace(strings.Join(nonEmptyStrings(mem.Manufacturer, mem.PartNumber, mem.Type), " "))
|
||||
if name == "" {
|
||||
name = "Installed DIMM (size unknown)"
|
||||
} else {
|
||||
name += " (size unknown)"
|
||||
}
|
||||
memGroups[name]++
|
||||
continue
|
||||
}
|
||||
|
||||
key := ""
|
||||
currentSpeed := intFromDetails(mem.Details, "current_speed_mhz")
|
||||
if currentSpeed > 0 {
|
||||
@@ -626,6 +636,18 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
|
||||
return spec
|
||||
}
|
||||
|
||||
func nonEmptyStrings(values ...string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
||||
result := s.GetResult()
|
||||
if result == nil {
|
||||
|
||||
Reference in New Issue
Block a user