From eaaf5c09d3528dc473e275519bfa9ccb1fa57ad4 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 11 Aug 2026 11:48:21 +0300 Subject: [PATCH] feat(hardware): collect and export licenses via Dell iDRAC10 Redfish walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bible-local/06-parsers.md | 7 + bible-local/10-decisions.md | 43 ++++++ bible-local/docs/hardware-ingest-contract.md | 141 ++++++++++++++++-- internal/chart | 2 +- internal/collector/redfish_replay.go | 2 + internal/collector/redfish_replay_licenses.go | 87 +++++++++++ .../collector/redfish_replay_licenses_test.go | 138 +++++++++++++++++ internal/exporter/reanimator_converter.go | 55 +++++++ .../exporter/reanimator_converter_test.go | 72 +++++++++ internal/exporter/reanimator_models.go | 21 +++ internal/models/models.go | 22 +++ internal/parser/vendors/dell/parser.go | 1 + internal/parser/vendors/dell/redfish_walk.go | 1 + 13 files changed, 582 insertions(+), 10 deletions(-) create mode 100644 internal/collector/redfish_replay_licenses.go create mode 100644 internal/collector/redfish_replay_licenses_test.go diff --git a/bible-local/06-parsers.md b/bible-local/06-parsers.md index 8c5e762..69fa946 100644 --- a/bible-local/06-parsers.md +++ b/bible-local/06-parsers.md @@ -233,6 +233,13 @@ and `LogDump/` trees. `collector.ReplayRedfishFromRawPayloads` (`internal/parser/vendors/dell/redfish_walk.go`), then append-merged into the same `Hardware`/`Sensors`/`FRU`/`Events` slices the DCIM-XML path fills, so the existing dedupe passes resolve any overlap in favor of DCIM-derived data. See ADL-048. +- Licenses (`hardware.licenses[]`, contract v2.12): the Redfish-walk replay reads the standard DMTF + `/redfish/v1/LicenseService/Licenses` collection (`internal/collector/redfish_replay_licenses.go`), + present on iDRAC10-generation firmware — feature-on-demand/advanced licenses such as + "iDRAC10 17G Enterprise License", "Secure Enterprise Key Manager". System-level licenses have no + `component_ref`; a license with `AuthorizationScope: "Device"` gets `component_ref` from + `Links.AuthorizedDevices`. Not sourced from DCIM-XML — only available on the Redfish-walk path. + See ADL-050. --- diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index 3eb957e..475ece7 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -1310,3 +1310,46 @@ given the field it needs from a re-imported export. `parseUploadedSnapshot` must actually be populated by its `convert*FromDevices` function — the struct declaring the field is not enough. `Storage` was the only category doing this correctly before this fix; worth auditing PCIe/NIC/GPU conversion the same way if a similar round-trip gap is reported for them. + +--- + +## ADL-050 — Added `hardware.licenses[]` collection/export (contract v2.12), Dell iDRAC10 first + +**Date:** 2026-08-11 +**Context:** Reanimator's hardware ingest contract added an optional `hardware.licenses[]` section in +v2.12 (2026-08-11) for software/firmware licenses and feature-on-demand activations (BMC advanced +licenses, vGPU, CPU FoD, RAID feature unlocks, etc). LOGPile's local copy of +`bible-local/docs/hardware-ingest-contract.md` was still v2.11 and was refreshed from +`reanimator/core/bible-local/docs/hardware-ingest-contract.md`. Scoped the first implementation to the +Dell iDRAC10 Redfish-walk path (`vendors/dell` + `vendors/redfishwalk`, see ADL-048/049), since that's +the only resolver currently producing a full captured Redfish tree with a `LicenseService` collection +in it — other vendor parsers have no comparable source for this data yet. +**Decision:** +- `models.License` (`internal/models/models.go`) added, mirroring the contract's field set 1:1 + (`name`, `license_key`, `vendor`, `type`, `feature`, `component_ref`, `activated_at`, `expires_at`, + `present`, status fields). `HardwareConfig.Licenses []License` added. +- `internal/collector/redfish_replay_licenses.go`: `collectLicenses()` reads the standard DMTF + `/redfish/v1/LicenseService/Licenses` collection (`License.v1_x` schema) via the existing + `redfishSnapshotReader.getCollectionMembers` helper — no Dell-specific parsing needed, this is a + generic DMTF resource, so any future vendor whose Redfish walk includes it gets license collection + for free through `ReplayRedfishFromRawPayloads`. `AuthorizationScope: "Device"` licenses get + `component_ref` from `Links.AuthorizedDevices[0]`; `Service`-scoped licenses stay system-level (no + `component_ref`). `LicenseOrigin != "Installed"` maps to `Present: false`. +- `internal/parser/vendors/dell/redfish_walk.go`'s `mergeRedfishReplay` appends replayed `Licenses` + into `result.Hardware.Licenses` like every other category. +- `internal/exporter/reanimator_converter.go`: `convertLicenses` + `dedupeLicenses` (dedup key: + `license_key`, falling back to `component_ref|name`) added, wired into `ConvertToReanimator` + directly from `hw.Licenses` — licenses don't go through the canonical-devices merge/dedup pipeline + used for PCIe/GPU/NIC, since they carry no physical identity to merge on. `ReanimatorLicense.Present` + is set on every emitted record (learned from ADL-049 — declaring the field isn't enough, it must + actually be populated for the reanimator round-trip to survive). +- `internal/chart/viewer/render.go`: added a `licenses` section (between `power_supplies` and + `sensors`) so licenses show up in the `/chart/current` web view like every other hardware category. +**Consequences:** +- Verified end-to-end on the PowerEdge R7715 (1TVFYL4) TSR archive: 3 licenses extracted from + `redfishidracwalk.tar.gz` ("Secure Enterprise Key Manager", "Secured Component Verification", + "iDRAC10 17G Enterprise License"), all system-level (`AuthorizationScope: "Service"`), correctly + exported with `present: true` and visible in the live `/chart/current` page. +- No other vendor parser populates `Hardware.Licenses` yet. If a future TSR/log source carries license + data outside a Redfish walk (e.g. embedded in a vendor-specific XML/JSON file), it needs its own + parsing — `collectLicenses()` only covers the generic Redfish `LicenseService` path. diff --git a/bible-local/docs/hardware-ingest-contract.md b/bible-local/docs/hardware-ingest-contract.md index b99654a..7aefc7d 100644 --- a/bible-local/docs/hardware-ingest-contract.md +++ b/bible-local/docs/hardware-ingest-contract.md @@ -1,7 +1,7 @@ --- title: Hardware Ingest JSON Contract -version: "2.11" -updated: "2026-06-19" +version: "2.12" +updated: "2026-08-11" maintainer: Reanimator Core audience: external-integrators, ai-agents language: ru @@ -9,7 +9,7 @@ language: ru # Интеграция с Reanimator: контракт JSON-импорта аппаратного обеспечения -Версия: **2.11** · Дата: **2026-06-19** +Версия: **2.12** · Дата: **2026-08-11** Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения). Предназначен для разработчиков смежных систем (Redfish-коллекторов, агентов мониторинга, CMDB-экспортёров) и может быть включён в документацию интегрируемых проектов. @@ -22,6 +22,7 @@ language: ru | Версия | Дата | Изменения | |--------|------|-----------| +| 2.12 | 2026-08-11 | Добавлена необязательная секция `hardware.licenses[]` для лицензий на ПО/прошивку и связанного софтверного функционала (feature-on-demand, vGPU/iDRAC/iLO-style advanced-лицензии и т.п.). Лицензия может быть системной (без `component_ref`) либо привязанной к конкретному компоненту (`component_ref`). Секция использует те же общие поля статуса/истории, что и остальные компонентные секции | | 2.11 | 2026-06-19 | В `pcie_devices[]` добавлен необязательный массив `sfp_modules[]` с идентификацией и DOM telemetry SFP/QSFP-модулей. Скалярные поля `sfp_temperature_c` / `sfp_tx_power_dbm` / `sfp_rx_power_dbm` / `sfp_voltage_v` / `sfp_bias_ma` помечены как deprecated (принимаются, но `sfp_modules[]` имеет приоритет) | | 2.10 | 2026-04-29 | Для `hardware.storage[]` добавлены необязательные числовые поля `logical_block_size_bytes`, `physical_block_size_bytes`, `metadata_bytes_per_block` для нормализованного описания формата блока накопителя | | 2.9 | 2026-03-19 | Добавлена необязательная секция `hardware.platform_config` — произвольный объект с настройками платформы (BIOS/Redfish); хранится как latest-snapshot per machine | @@ -44,6 +45,11 @@ language: ru 2. **Идемпотентность** — повторная отправка идентичного payload не создаёт дублей (дедупликация по хешу). 3. **Частичность** — можно передавать только те секции, данные по которым доступны. Пустой массив и отсутствие секции эквивалентны. 4. **Строгая схема** — endpoint использует строгий JSON-декодер; неизвестные поля приводят к `400 Bad Request`. + > **Известное расхождение (2026-07-28):** на `POST /ingest/hardware` строгий декодер + > (`DisallowUnknownFields`) пока не включён — неизвестные поля сейчас тихо + > отбрасываются, а не отклоняются `400`. Включение отложено намеренно: рискованно + > для внешних интеграторов без предварительного аудита реальных payload на + > недокументированные поля. См. `bible-local/decisions/2026-07-28-ingest-contract-audit.md`. 5. **Event-driven** — импорт создаёт события в timeline (LOG_COLLECTED, INSTALLED, REMOVED, FIRMWARE_CHANGED и др.). 6. **Без синтеза со стороны интегратора** — сборщик передаёт только фактически собранные значения. Нельзя придумывать `serial_number`, `component_ref`, `message`, `message_id` или другие идентификаторы/атрибуты, если источник их не предоставил или парсер не смог их надёжно извлечь. @@ -60,7 +66,7 @@ Content-Type: application/json ```json { "status": "accepted", - "job_id": "job_01J..." + "job_id": "job-1784799375786854143" } ``` @@ -69,12 +75,31 @@ Content-Type: application/json GET /ingest/hardware/jobs/{job_id} ``` -**Ответ при успехе задачи:** +**Форма ответа поллинга** — `job.status` проходит `queued` -> `running` -> `success`/`failed`; пока задача не завершена, `result`/`job.result` отсутствуют: +```json +{ + "status": "ok", + "job": { + "id": "job-1784799375786854143", + "status": "success", + "filename": "21D634101", + "created_at": "2026-02-10T15:30:00Z", + "started_at": "2026-02-10T15:30:00Z", + "finished_at": "2026-02-10T15:30:01Z", + "result_code": 201, + "result": { "...": "см. ниже" } + }, + "result_code": 201, + "result": { "...": "см. ниже" } +} +``` + +**`result` при успехе (result_code 201, или 200 для дубликата):** ```json { "status": "success", "bundle_id": "lb_01J...", - "asset_id": "mach_01J...", + "asset_id": "ME-0000090", "collected_at": "2026-02-10T15:30:00Z", "duplicate": false, "summary": { @@ -89,7 +114,7 @@ GET /ingest/hardware/jobs/{job_id} } ``` -**Ответ при дубликате:** +**`result` при дубликате:** ```json { "status": "success", @@ -137,7 +162,8 @@ GET /ingest/hardware/jobs/{job_id} "power_supplies": [ ... ], "sensors": { ... }, "event_logs": [ ... ], - "platform_config": { ... } + "platform_config": { ... }, + "licenses": [ ... ] } } ``` @@ -475,6 +501,14 @@ GET /ingest/hardware/jobs/{job_id} **Ключ дедупликации:** `(pcie_devices[].slot, sfp_modules[].port)`. +> **Известное ограничение (2026-07-28):** `sfp_modules[]` не имеет отдельной +> history-first проекции — "предыдущее" состояние читается из `observations` +> (raw ingest trace) исключительно для детектирования замены модуля и генерации +> события `COMPONENT_CHANGED`. Нет UI/API, читающего текущий список SFP-модулей. +> Использование `observations` здесь для сравнения — нарушение общего архитектурного +> правила «observations не источник истины для текущего состояния» (см. +> `runtime-flows.md`). См. `bible-local/decisions/2026-07-28-ingest-contract-audit.md`. + **Правила ingest:** - При каждом импорте — полная замена `sfp_modules[]` для данного `pcie_devices[].slot` (upsert всего массива целиком). - Если `sfp_modules` отсутствует или `null` — существующие данные SFP не трогать. @@ -758,6 +792,86 @@ PSU без `serial_number` игнорируется. --- +## Секция licenses + +Лицензии на ПО/прошивку и связанный с ними софтверный функционал: feature-on-demand активации (например, Intel FoD), advanced-лицензии BMC/удалённого управления (iDRAC/iLO Advanced), vGPU-лицензии, лицензии RAID-контроллеров (RAID60, кэширование и т.п.) и подобные программные разблокировки возможностей. + +Секция необязательная. Лицензия может относиться: +- **к системе/серверу в целом** — `component_ref` не передаётся (например, лицензия BMC/redfish-сервиса); +- **к конкретному установленному компоненту** — `component_ref` указывает на этот компонент (см. правила ниже). + +| Поле | Тип | Обязательно | Описание | +|------|-----|-------------|----------| +| `name` | string | **да** | Название лицензии/фичи, например `iDRAC9 Enterprise`, `Intel Feature-on-Demand: AVX-512-SP`, `NVIDIA vGPU` | +| `license_key` | string | нет | Ключ/идентификатор лицензии из источника (может быть частично маскирован источником) | +| `vendor` | string | нет | Производитель/издатель лицензии | +| `type` | string | нет | Тип лицензии: `Perpetual`, `Subscription`, `Trial`, `FeatureOnDemand`, `NodeLocked` и т.п. Список открытый | +| `feature` | string | нет | Конкретная функция/возможность, которую отпирает лицензия (софтверный функционал), например `AVX-512`, `RAID60`, `NVMe Caching` | +| `component_ref` | string | нет | Ссылка на компонент (slot/serial), к которому привязана лицензия; отсутствие поля = лицензия системного уровня | +| `activated_at` | string RFC3339 | нет | Время активации лицензии | +| `expires_at` | string RFC3339 | нет | Время истечения лицензии, если применимо | +| `present` | bool | нет | Наличие лицензии (по умолчанию `true`) | +| + общие поля статуса | | | см. раздел «Общие поля статуса компонентов» выше (`status`, `status_checked_at`, `status_changed_at`, `status_history`, `error_description`) | + +Запись без `name` игнорируется. + +**Значения `status` для лицензий** (тот же механизм status/status_history, что и у прочих компонентных секций, со своей семантикой): + +| Значение | Смысл для лицензии | +|----------|---------------------| +| `OK` | Лицензия активна и валидна | +| `Warning` | Лицензия триальная либо истекает в ближайшее время | +| `Critical` | Лицензия просрочена, отозвана или невалидна | +| `Unknown` | Статус лицензии не удалось определить | +| `Empty` | Лицензия не обнаружена/не установлена — запись не создаётся | + +Изменение статуса лицензии создаёт те же события, что и для остальных компонентов (`COMPONENT_WARNING`, `COMPONENT_FAILED` + запись в `failure_events` для `Critical`, `COMPONENT_UNKNOWN`) — см. «Обработка статусов компонентов» ниже. + +**Генерация `vendor_serial` (ключ дедупликации) при отсутствии `license_key`:** +`{component_ref или board_serial}-LIC-{нормализованное имя лицензии}`. + +Если и `license_key`, и сгенерированный ключ совпадают у двух записей в одном payload — это трактуется как повторная передача одной и той же лицензии (не ошибка). + +**Правила ingest:** +- Как и другие компонентные секции, `licenses[]` — snapshot: лицензия, ранее переданная для сервера, но отсутствующая в новом payload, считается снятой/отозванной (аналогично `REMOVED` для физических компонентов). +- Интегратор не должен придумывать `license_key`, `name` или `expires_at`, если источник их не предоставил — см. общее правило «Без синтеза со стороны интегратора». + +```json +"licenses": [ + { + "name": "iDRAC9 Enterprise", + "vendor": "Dell", + "type": "Perpetual", + "feature": "Remote Console + vFlash + BMC OS-to-iDRAC Pass-through", + "license_key": "XXXXX-XXXXX-XXXXX-XXXXX-XXXXX", + "activated_at": "2025-01-10T00:00:00Z", + "status": "OK" + }, + { + "name": "Intel Feature-on-Demand: AVX-512-SP", + "vendor": "Intel", + "type": "FeatureOnDemand", + "feature": "AVX-512", + "component_ref": "CPU0", + "status": "OK" + }, + { + "name": "NVIDIA vGPU", + "vendor": "NVIDIA", + "type": "Subscription", + "component_ref": "0000:3b:00.0", + "expires_at": "2026-12-31T23:59:59Z", + "status": "Warning", + "status_history": [ + { "status": "OK", "changed_at": "2025-06-01T00:00:00Z" }, + { "status": "Warning", "changed_at": "2026-08-01T00:00:00Z", "details": "License expires in 30 days" } + ] + } +] +``` + +--- + ## Обработка статусов компонентов | Статус | Поведение | @@ -896,7 +1010,7 @@ PSU без `serial_number` игнорируется. ], "sensors": { "fans": [ - { "name": "FAN1", "location": "Front", "rpm": 4200, "status": "OK" } + { "name": "FAN1", "rpm": 4200, "status": "OK" } ], "power": [ { "name": "12V Rail", "voltage_v": 12.06, "status": "OK" } @@ -908,6 +1022,15 @@ PSU без `serial_number` игнорируется. { "name": "System Humidity", "value": 38.5, "unit": "%" } ] }, + "licenses": [ + { + "name": "iDRAC9 Enterprise", + "vendor": "Dell", + "type": "Perpetual", + "feature": "Remote Console + vFlash + BMC OS-to-iDRAC Pass-through", + "status": "OK" + } + ], "platform_config": { "SecureBoot": "Enabled", "BiosVersion": "06.08.05", diff --git a/internal/chart b/internal/chart index 8c80591..9552a48 160000 --- a/internal/chart +++ b/internal/chart @@ -1 +1 @@ -Subproject commit 8c805915311024e99cd9835e4e06ceebe5dce343 +Subproject commit 9552a483ae1d20991c2b8ffd5ed9b474457068b8 diff --git a/internal/collector/redfish_replay.go b/internal/collector/redfish_replay.go index 55cc516..bad6c9e 100644 --- a/internal/collector/redfish_replay.go +++ b/internal/collector/redfish_replay.go @@ -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, }, diff --git a/internal/collector/redfish_replay_licenses.go b/internal/collector/redfish_replay_licenses.go new file mode 100644 index 0000000..c9fe776 --- /dev/null +++ b/internal/collector/redfish_replay_licenses.go @@ -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"])) +} diff --git a/internal/collector/redfish_replay_licenses_test.go b/internal/collector/redfish_replay_licenses_test.go new file mode 100644 index 0000000..c0d3289 --- /dev/null +++ b/internal/collector/redfish_replay_licenses_test.go @@ -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") + } +} diff --git a/internal/exporter/reanimator_converter.go b/internal/exporter/reanimator_converter.go index d8d5898..c36d393 100644 --- a/internal/exporter/reanimator_converter.go +++ b/internal/exporter/reanimator_converter.go @@ -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 "" diff --git a/internal/exporter/reanimator_converter_test.go b/internal/exporter/reanimator_converter_test.go index 4e3be5a..afb24d8 100644 --- a/internal/exporter/reanimator_converter_test.go +++ b/internal/exporter/reanimator_converter_test.go @@ -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") + } +} diff --git a/internal/exporter/reanimator_models.go b/internal/exporter/reanimator_models.go index 3052961..b5d12d4 100644 --- a/internal/exporter/reanimator_models.go +++ b/internal/exporter/reanimator_models.go @@ -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"` diff --git a/internal/models/models.go b/internal/models/models.go index 8aef89d..76d6925 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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"` diff --git a/internal/parser/vendors/dell/parser.go b/internal/parser/vendors/dell/parser.go index f2f3d6b..0783744 100644 --- a/internal/parser/vendors/dell/parser.go +++ b/internal/parser/vendors/dell/parser.go @@ -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), }, } diff --git a/internal/parser/vendors/dell/redfish_walk.go b/internal/parser/vendors/dell/redfish_walk.go index 9024bcb..fe2139a 100644 --- a/internal/parser/vendors/dell/redfish_walk.go +++ b/internal/parser/vendors/dell/redfish_walk.go @@ -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...)