Files
logpile/internal/collector/redfish_replay_licenses.go
T
Mikhail ChusavitinandClaude Sonnet 5 eaaf5c09d3 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>
2026-08-11 11:48:21 +03:00

88 lines
2.4 KiB
Go

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"]))
}