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
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user