feat(hardware): collect and export licenses via Dell iDRAC10 Redfish walk

Implements the hardware.licenses[] contract section (v2.12, refreshed from
reanimator/core's hardware-ingest-contract.md — was v2.11 locally).

- models.License / HardwareConfig.Licenses mirror the contract field set.
- collector.collectLicenses() reads the standard DMTF
  /redfish/v1/LicenseService/Licenses collection during Redfish-walk replay;
  it's a generic DMTF resource, not Dell-specific, so any future vendor's
  Redfish walk gets license collection for free through
  ReplayRedfishFromRawPayloads.
- vendors/dell merges replayed Licenses like every other category.
- exporter.convertLicenses/dedupeLicenses wire hw.Licenses into the
  reanimator export directly (no canonical-devices merge — licenses have no
  physical identity to merge on), setting Present on every record from the
  start (per the ADL-049 round-trip lesson).
- chart viewer renders a licenses section in /chart/current.

Verified end-to-end on the PowerEdge R7715 (1TVFYL4) TSR: 3 system-level
licenses extracted and correctly exported/rendered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-08-11 11:48:21 +03:00
co-authored by Claude Sonnet 5
parent eb6cc207ce
commit eaaf5c09d3
13 changed files with 582 additions and 10 deletions
+2
View File
@@ -79,6 +79,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
emit(Progress{Status: "running", Progress: 80, Message: "Redfish snapshot: replay network/BMC..."})
}
psus := r.collectPSUs(chassisPaths)
licenses := r.collectLicenses()
pcieDevices := r.collectPCIeDevices(systemPaths, chassisPaths)
boardInfo := parseBoardInfoWithFallback(systemDoc, chassisDoc, fruDoc)
applyBoardInfoFallbackFromDocs(&boardInfo, boardFallbackDocs)
@@ -126,6 +127,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
PCIeDevices: pcieDevices,
GPUs: gpus,
PowerSupply: psus,
Licenses: licenses,
NetworkAdapters: nics,
Firmware: firmware,
},
@@ -0,0 +1,87 @@
package collector
import (
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
// collectLicenses reads the standard DMTF LicenseService/Licenses collection
// (Redfish License.v1_x schema). Seen first on Dell iDRAC10-generation
// firmware, which exposes it alongside the legacy Oem/Dell license resources.
// Entries are system-level licenses unless AuthorizationScope is "Device",
// in which case Links.AuthorizedDevices identifies the licensed component.
func (r redfishSnapshotReader) collectLicenses() []models.License {
memberDocs, err := r.getCollectionMembers("/redfish/v1/LicenseService/Licenses")
if err != nil || len(memberDocs) == 0 {
return nil
}
out := make([]models.License, 0, len(memberDocs))
for _, doc := range memberDocs {
lic, ok := parseRedfishLicense(doc)
if !ok {
continue
}
out = append(out, lic)
}
return out
}
func parseRedfishLicense(doc map[string]interface{}) (models.License, bool) {
name := strings.TrimSpace(firstNonEmpty(
asString(doc["Description"]),
asString(doc["Name"]),
asString(doc["Id"]),
))
if name == "" {
return models.License{}, false
}
origin := strings.ToLower(strings.TrimSpace(asString(doc["LicenseOrigin"])))
present := origin == "" || origin == "installed"
lic := models.License{
Name: name,
LicenseKey: strings.TrimSpace(asString(doc["EntitlementId"])),
Type: strings.TrimSpace(asString(doc["LicenseType"])),
ComponentRef: redfishLicenseComponentRef(doc),
Present: present,
Status: mapStatus(doc["Status"]),
ActivatedAt: parseRedfishLicenseTime(doc["InstallDate"]),
ExpiresAt: parseRedfishLicenseTime(doc["ExpirationDate"]),
}
return lic, true
}
func parseRedfishLicenseTime(v interface{}) time.Time {
raw := strings.TrimSpace(asString(v))
if raw == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339, time.RFC3339Nano} {
if ts, err := time.Parse(layout, raw); err == nil {
return ts.UTC()
}
}
return time.Time{}
}
func redfishLicenseComponentRef(doc map[string]interface{}) string {
if !strings.EqualFold(strings.TrimSpace(asString(doc["AuthorizationScope"])), "Device") {
return ""
}
links, ok := doc["Links"].(map[string]interface{})
if !ok {
return ""
}
devices, ok := links["AuthorizedDevices"].([]interface{})
if !ok || len(devices) == 0 {
return ""
}
first, ok := devices[0].(map[string]interface{})
if !ok {
return ""
}
return strings.TrimSpace(asString(first["@odata.id"]))
}
@@ -0,0 +1,138 @@
package collector
import (
"testing"
)
func TestParseRedfishLicense(t *testing.T) {
doc := map[string]interface{}{
"@odata.id": "/redfish/v1/LicenseService/Licenses/FD00000043163704",
"Description": "iDRAC10 17G Enterprise License",
"EntitlementId": "FD00000043163704",
"LicenseType": "Production",
"LicenseOrigin": "Installed",
"AuthorizationScope": "Service",
"InstallDate": nil,
"ExpirationDate": nil,
"Links": map[string]interface{}{},
"Status": map[string]interface{}{
"Health": "OK",
"State": "Enabled",
},
}
lic, ok := parseRedfishLicense(doc)
if !ok {
t.Fatal("expected license to parse")
}
if lic.Name != "iDRAC10 17G Enterprise License" {
t.Errorf("Name = %q, want %q", lic.Name, "iDRAC10 17G Enterprise License")
}
if lic.LicenseKey != "FD00000043163704" {
t.Errorf("LicenseKey = %q, want %q", lic.LicenseKey, "FD00000043163704")
}
if lic.Type != "Production" {
t.Errorf("Type = %q, want %q", lic.Type, "Production")
}
if !lic.Present {
t.Error("expected Present = true for LicenseOrigin=Installed")
}
if lic.ComponentRef != "" {
t.Errorf("ComponentRef = %q, want empty for Service-scoped license", lic.ComponentRef)
}
if lic.Status != "OK" {
t.Errorf("Status = %q, want %q", lic.Status, "OK")
}
if !lic.ActivatedAt.IsZero() {
t.Errorf("expected zero ActivatedAt for null InstallDate, got %v", lic.ActivatedAt)
}
}
func TestParseRedfishLicense_DeviceScoped(t *testing.T) {
doc := map[string]interface{}{
"Description": "NVIDIA vGPU",
"EntitlementId": "ABC123",
"AuthorizationScope": "Device",
"LicenseOrigin": "Installed",
"InstallDate": "2025-06-01T00:00:00Z",
"ExpirationDate": "2026-12-31T23:59:59Z",
"Links": map[string]interface{}{
"AuthorizedDevices": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/System.Embedded.1/PCIeDevices/0-193-0"},
},
},
"Status": map[string]interface{}{"Health": "Warning"},
}
lic, ok := parseRedfishLicense(doc)
if !ok {
t.Fatal("expected license to parse")
}
if lic.ComponentRef != "/redfish/v1/Chassis/System.Embedded.1/PCIeDevices/0-193-0" {
t.Errorf("ComponentRef = %q, want the linked device path", lic.ComponentRef)
}
if lic.Status != "Warning" {
t.Errorf("Status = %q, want %q", lic.Status, "Warning")
}
if lic.ActivatedAt.IsZero() || lic.ExpiresAt.IsZero() {
t.Errorf("expected non-zero ActivatedAt/ExpiresAt, got %v / %v", lic.ActivatedAt, lic.ExpiresAt)
}
}
func TestParseRedfishLicense_NotInstalledIsNotPresent(t *testing.T) {
doc := map[string]interface{}{
"Description": "Available Feature",
"LicenseOrigin": "NotInstalled",
}
lic, ok := parseRedfishLicense(doc)
if !ok {
t.Fatal("expected license to parse")
}
if lic.Present {
t.Error("expected Present = false for LicenseOrigin=NotInstalled")
}
}
func TestParseRedfishLicense_MissingNameSkipped(t *testing.T) {
if _, ok := parseRedfishLicense(map[string]interface{}{}); ok {
t.Error("expected license without any name field to be skipped")
}
}
func TestReplayRedfishFromRawPayloads_CollectsLicenses(t *testing.T) {
rawPayloads := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{"Id": "1"},
"/redfish/v1/LicenseService/Licenses": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/LicenseService/Licenses/A"},
},
},
"/redfish/v1/LicenseService/Licenses/A": map[string]interface{}{
"Description": "iDRAC10 17G Enterprise License",
"EntitlementId": "A",
"LicenseType": "Production",
"LicenseOrigin": "Installed",
"AuthorizationScope": "Service",
"Status": map[string]interface{}{"Health": "OK"},
},
},
}
result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil)
if err != nil {
t.Fatalf("ReplayRedfishFromRawPayloads() failed: %v", err)
}
if len(result.Hardware.Licenses) != 1 {
t.Fatalf("expected 1 license, got %+v", result.Hardware.Licenses)
}
if result.Hardware.Licenses[0].Name != "iDRAC10 17G Enterprise License" {
t.Errorf("license name = %q, want %q", result.Hardware.Licenses[0].Name, "iDRAC10 17G Enterprise License")
}
}
+55
View File
@@ -53,6 +53,7 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
Sensors: convertSensors(result.Sensors),
BMCEventSummary: buildBMCEventSummary(result.Events, collectedAt),
EventLogs: convertEventLogs(result.Events, collectedAt),
Licenses: dedupeLicenses(convertLicenses(result.Hardware.Licenses, collectedAt)),
},
}
@@ -1858,6 +1859,60 @@ func buildStatusMeta(
return meta
}
// convertLicenses converts software/firmware licenses (BMC advanced licenses,
// feature-on-demand activations, vGPU, etc). Unlike PCIe/GPU/NIC, licenses do
// not go through the canonical devices merge/dedup pipeline: they carry no
// physical identity to merge on and are reported directly from the source.
func convertLicenses(licenses []models.License, collectedAt string) []ReanimatorLicense {
result := make([]ReanimatorLicense, 0, len(licenses))
for _, lic := range licenses {
name := strings.TrimSpace(lic.Name)
if name == "" || !lic.Present {
continue
}
status := normalizeStatus(lic.Status, false)
meta := buildStatusMeta(status, lic.StatusCheckedAt, lic.StatusChangedAt, lic.StatusHistory, lic.ErrorDescription, collectedAt)
present := lic.Present
result = append(result, ReanimatorLicense{
Name: name,
LicenseKey: strings.TrimSpace(lic.LicenseKey),
Vendor: strings.TrimSpace(lic.Vendor),
Type: strings.TrimSpace(lic.Type),
Feature: strings.TrimSpace(lic.Feature),
ComponentRef: strings.TrimSpace(lic.ComponentRef),
ActivatedAt: formatOptionalRFC3339(&lic.ActivatedAt),
ExpiresAt: formatOptionalRFC3339(&lic.ExpiresAt),
Present: &present,
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
return result
}
func dedupeLicenses(items []ReanimatorLicense) []ReanimatorLicense {
if len(items) < 2 {
return items
}
seen := make(map[string]struct{}, len(items))
result := make([]ReanimatorLicense, 0, len(items))
for _, item := range items {
key := strings.ToLower(strings.TrimSpace(item.LicenseKey))
if key == "" {
key = strings.ToLower(strings.TrimSpace(item.ComponentRef)) + "|" + strings.ToLower(strings.TrimSpace(item.Name))
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, item)
}
return result
}
func formatOptionalRFC3339(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
@@ -2077,3 +2077,75 @@ func TestConvertToReanimator_MemoryAndPSURoundTripSurvivesReimport(t *testing.T)
t.Fatalf("power supplies did not survive reanimator round trip, got %+v", reconverted.Hardware.PowerSupplies)
}
}
// TestConvertToReanimator_ExportsLicenses covers the hardware.licenses contract
// section (v2.12): system-level licenses (no component_ref) and component-scoped
// licenses, skipping records without a name and records the source marked absent.
func TestConvertToReanimator_ExportsLicenses(t *testing.T) {
activatedAt := time.Date(2025, 1, 10, 0, 0, 0, 0, time.UTC)
result := &models.AnalysisResult{
Filename: "test.zip",
CollectedAt: time.Date(2026, 8, 11, 8, 42, 18, 0, time.UTC),
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{
Manufacturer: "Dell Inc.",
ProductName: "PowerEdge R7715",
SerialNumber: "1TVFYL4",
},
Licenses: []models.License{
{
Name: "iDRAC10 17G Enterprise License",
LicenseKey: "FD00000043163704",
Type: "Production",
Present: true,
Status: "OK",
ActivatedAt: activatedAt,
},
{
Name: "NVIDIA vGPU",
ComponentRef: "0000:3b:00.0",
Present: true,
Status: "Warning",
},
{
// No name: source didn't provide one, must not be synthesized/kept.
LicenseKey: "NONAME",
Present: true,
Status: "OK",
},
{
Name: "Not Actually Installed",
Present: false,
Status: "OK",
},
},
},
}
exported, err := ConvertToReanimator(result)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(exported.Hardware.Licenses) != 2 {
t.Fatalf("expected 2 licenses (nameless and not-present filtered out), got %+v", exported.Hardware.Licenses)
}
system := exported.Hardware.Licenses[0]
if system.Name != "iDRAC10 17G Enterprise License" || system.ComponentRef != "" {
t.Errorf("system license = %+v, want system-level iDRAC10 entry", system)
}
if system.Present == nil || !*system.Present {
t.Errorf("expected system license present=true, got %+v", system.Present)
}
if system.ActivatedAt != "2025-01-10T00:00:00Z" {
t.Errorf("ActivatedAt = %q, want %q", system.ActivatedAt, "2025-01-10T00:00:00Z")
}
scoped := exported.Hardware.Licenses[1]
if scoped.Name != "NVIDIA vGPU" || scoped.ComponentRef != "0000:3b:00.0" {
t.Errorf("component license = %+v, want vGPU entry scoped to 0000:3b:00.0", scoped)
}
if scoped.Status != "Warning" {
t.Errorf("Status = %q, want %q", scoped.Status, "Warning")
}
}
+21
View File
@@ -23,6 +23,7 @@ type ReanimatorHardware struct {
BMCEventSummary []ReanimatorBMCEventRow `json:"bmc_event_summary,omitempty"`
EventLogs []ReanimatorEventLog `json:"event_logs,omitempty"`
PlatformConfig map[string]any `json:"platform_config,omitempty"`
Licenses []ReanimatorLicense `json:"licenses,omitempty"`
}
// ReanimatorBMCEventRow is one row in the BMC critical/warning event summary table.
@@ -218,6 +219,26 @@ type ReanimatorPSU struct {
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorLicense represents a software/firmware license or
// feature-on-demand activation (BMC advanced license, vGPU, CPU FoD, etc).
// ComponentRef is empty for system-level licenses.
type ReanimatorLicense struct {
Name string `json:"name"`
LicenseKey string `json:"license_key,omitempty"`
Vendor string `json:"vendor,omitempty"`
Type string `json:"type,omitempty"`
Feature string `json:"feature,omitempty"`
ComponentRef string `json:"component_ref,omitempty"`
ActivatedAt string `json:"activated_at,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Present *bool `json:"present,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
type ReanimatorEventLog struct {
Source string `json:"source"`
EventTime string `json:"event_time,omitempty"`
+22
View File
@@ -106,6 +106,7 @@ type HardwareConfig struct {
NetworkCards []NIC `json:"network_cards,omitempty"`
NetworkAdapters []NetworkAdapter `json:"network_adapters,omitempty"`
PowerSupply []PSU `json:"power_supplies,omitempty"`
Licenses []License `json:"licenses,omitempty"`
}
const (
@@ -357,6 +358,27 @@ type PSU struct {
ErrorDescription string `json:"error_description,omitempty"`
}
// License represents a software/firmware license or feature-on-demand
// activation (BMC advanced licenses, vGPU, RAID feature unlocks, CPU
// feature-on-demand, etc). ComponentRef is empty for system-level licenses.
type License struct {
Name string `json:"name"`
LicenseKey string `json:"license_key,omitempty"`
Vendor string `json:"vendor,omitempty"`
Type string `json:"type,omitempty"`
Feature string `json:"feature,omitempty"`
ComponentRef string `json:"component_ref,omitempty"`
ActivatedAt time.Time `json:"activated_at,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Present bool `json:"present"`
Status string `json:"status,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// GPU represents a graphics processing unit
type GPU struct {
Slot string `json:"slot"`
+1
View File
@@ -78,6 +78,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
NetworkAdapters: make([]models.NetworkAdapter, 0),
NetworkCards: make([]models.NIC, 0),
PowerSupply: make([]models.PSU, 0),
Licenses: make([]models.License, 0),
},
}
+1
View File
@@ -75,6 +75,7 @@ func mergeRedfishReplay(result *models.AnalysisResult, replayed *models.Analysis
hw.GPUs = append(hw.GPUs, rhw.GPUs...)
hw.NetworkAdapters = append(hw.NetworkAdapters, rhw.NetworkAdapters...)
hw.PowerSupply = append(hw.PowerSupply, rhw.PowerSupply...)
hw.Licenses = append(hw.Licenses, rhw.Licenses...)
}
result.Sensors = append(result.Sensors, replayed.Sensors...)