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
+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 ""