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:
co-authored by
Claude Sonnet 5
parent
eb6cc207ce
commit
eaaf5c09d3
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user