From aac3a695268a977031aa0d43f86c98eed9f0acab Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Thu, 19 Mar 2026 15:07:21 +0300 Subject: [PATCH] Add platform config ingest, storage, API, and UI tab Auditors can now send BIOS/Redfish platform settings via POST /ingest/hardware as hardware.platform_config (map[string]any). Stored as latest-snapshot per machine with per-key change history. Exposed via GET /api/assets/{id}/platform-config and .../history. Asset page gets a third tab "Platform Config" with inline history expand. Contract bumped to v2.9, migration 0024 adds two new tables. Co-Authored-By: Claude Sonnet 4.6 --- bible-local/architecture/api-surface.md | 11 ++ .../ui-information-architecture.md | 15 ++- .../decisions/2026-03-19-platform-config.md | 29 ++++ bible-local/docs/hardware-ingest-contract.md | 43 +++++- internal/api/asset_component_actions_api.go | 67 ++++++++++ internal/api/assets_components.go | 13 +- internal/api/ingest.go | 5 +- internal/api/server.go | 14 +- internal/api/ui_assets.tmpl | 117 +++++++++++++++- internal/ingest/parser_hardware.go | 5 +- internal/ingest/service.go | 64 ++++++++- .../registry/asset_platform_config.go | 126 ++++++++++++++++++ .../0024_machine_platform_config/down.sql | 2 + .../0024_machine_platform_config/up.sql | 19 +++ 14 files changed, 502 insertions(+), 28 deletions(-) create mode 100644 bible-local/decisions/2026-03-19-platform-config.md create mode 100644 internal/repository/registry/asset_platform_config.go create mode 100644 migrations/0024_machine_platform_config/down.sql create mode 100644 migrations/0024_machine_platform_config/up.sql diff --git a/bible-local/architecture/api-surface.md b/bible-local/architecture/api-surface.md index e2d5372..1b93730 100644 --- a/bible-local/architecture/api-surface.md +++ b/bible-local/architecture/api-surface.md @@ -171,6 +171,17 @@ Rules: - `Content-Type: text/csv; charset=utf-8` - `Content-Disposition: attachment; filename="manual-import-template.csv"` +## Platform Config API + +``` +GET /api/assets/{asset_id}/platform-config + Response: { asset_id, collected_at, items: [{ key, value, change_count }] } + 404 if not yet collected + +GET /api/assets/{asset_id}/platform-config/{key}/history + Response: { asset_id, key, items: [{ collected_at, value, source }] } +``` + ## Routing Notes - API router is registered in `internal/api/server.go`. diff --git a/bible-local/architecture/ui-information-architecture.md b/bible-local/architecture/ui-information-architecture.md index 706926d..5865b6a 100644 --- a/bible-local/architecture/ui-information-architecture.md +++ b/bible-local/architecture/ui-information-architecture.md @@ -10,9 +10,10 @@ Required section order: `Previous Components` lists components that were previously installed on the asset and are currently removed. -`Current Components / Host Logs` is a single asset-page switcher section with two modes: +`Current Components / Host Logs / Platform Config` is a single asset-page switcher section with three modes: - `Current Components` - `Host Logs` +- `Platform Config` `Host Logs` mode is dedicated to operational logs ingested from `host`, `bmc`, and `redfish`. @@ -37,12 +38,22 @@ Required section order: - Row detail expands raw vendor payload / normalized fields without navigating away. - Host log rows must not be merged into `Movement & Events`. +`Platform Config` mode displays BIOS/Redfish platform settings collected via ingest. + +- Lazy loaded on first tab activation. +- Renders a table: Parameter | Value | Changes (change_count badge). +- Rows with `change_count > 0` are highlighted and clickable — click expands an inline history sub-table (Time / Value / Source). +- Repeated click on an expanded row collapses it. +- If no snapshot exists — shows "No platform config available." +- `Refresh` button reloads the snapshot. + `Current Components` interaction contract: - Section header uses the same right-aligned button row pattern as `Server Card`. - The left side of the header is a view switcher: - `Current Components` - `Host Logs` + - `Platform Config` - Buttons in header depend on active mode: - `Current Components` mode: - `Add` @@ -50,6 +61,8 @@ Required section order: - `Remove` - `Host Logs` mode: - `Refresh` + - `Platform Config` mode: + - `Refresh` - `Current Components` is a single filterable/selectable table (not grouped by type tables) to support bulk actions. - Table includes: - header `Select` checkbox (select all visible rows) diff --git a/bible-local/decisions/2026-03-19-platform-config.md b/bible-local/decisions/2026-03-19-platform-config.md new file mode 100644 index 0000000..7bc03f5 --- /dev/null +++ b/bible-local/decisions/2026-03-19-platform-config.md @@ -0,0 +1,29 @@ +# ADR: Platform Config — Storage and UI Tab + +**Date:** 2026-03-19 +**Status:** Accepted + +## Context + +Auditors collect platform settings (BIOS parameters from Redfish) and want to transmit them via the existing `POST /ingest/hardware` endpoint. These settings need to be stored and displayed on the asset page. + +## Decision + +- Add an optional `hardware.platform_config` field to the ingest contract: a free-form `map[string]any` object (keys are strings, values are strings/numbers/booleans). +- Store as a latest-snapshot per machine in `machine_platform_config` (upserted on each ingest). +- Track per-key change history in `machine_platform_config_history` — a new row is inserted whenever a key value changes or first appears. +- Expose via two new API endpoints: + - `GET /api/assets/{id}/platform-config` — current snapshot with per-key change counts + - `GET /api/assets/{id}/platform-config/{key}/history` — history for a specific key +- Display in a third tab "Platform Config" on the asset page (alongside Current Components and Host Logs). + +## Rationale + +- `map[string]any` keeps the contract simple and avoids premature schema design for BIOS parameters that vary by vendor. +- Latest-snapshot + change history separates two use cases: quick audit view (current state) vs. change tracking (what changed and when). +- Reuses the existing ingest transaction and tab UI pattern, minimizing new infrastructure. + +## Consequences + +- Platform config contents are not validated by the system — integrators are responsible for key naming consistency. +- History is append-only per key change; no compaction/TTL is applied initially. diff --git a/bible-local/docs/hardware-ingest-contract.md b/bible-local/docs/hardware-ingest-contract.md index 33a3ace..a9d04e6 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.8" -updated: "2026-03-15" +version: "2.9" +updated: "2026-03-19" maintainer: Reanimator Core audience: external-integrators, ai-agents language: ru @@ -9,7 +9,7 @@ language: ru # Интеграция с Reanimator: контракт JSON-импорта аппаратного обеспечения -Версия: **2.8** · Дата: **2026-03-15** +Версия: **2.9** · Дата: **2026-03-19** Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения). Предназначен для разработчиков смежных систем (Redfish-коллекторов, агентов мониторинга, CMDB-экспортёров) и может быть включён в документацию интегрируемых проектов. @@ -22,6 +22,7 @@ language: ru | Версия | Дата | Изменения | |--------|------|-----------| +| 2.9 | 2026-03-19 | Добавлена необязательная секция `hardware.platform_config` — произвольный объект с настройками платформы (BIOS/Redfish); хранится как latest-snapshot per machine | | 2.8 | 2026-03-15 | Поле `location` удалено из всех `sensors.*`; сенсоры передаются только по `name` и измеренным значениям | | 2.7 | 2026-03-15 | Явно запрещён синтез данных в `event_logs`; интеграторы не должны придумывать серийные номера компонентов, если источник их не отдал | | 2.6 | 2026-03-15 | Добавлена необязательная секция `event_logs` для dedup/upsert логов `host` / `bmc` / `redfish` вне history timeline | @@ -132,8 +133,9 @@ GET /ingest/hardware/jobs/{job_id} "storage": [ ... ], "pcie_devices": [ ... ], "power_supplies": [ ... ], - "sensors": { ... }, - "event_logs": [ ... ] + "sensors": { ... }, + "event_logs": [ ... ], + "platform_config": { ... } } } ``` @@ -653,6 +655,31 @@ PSU без `serial_number` игнорируется. --- +## Секция platform_config + +Необязательный объект с произвольными ключами — настройки платформы как есть из источника (BIOS, Redfish, IPMI). + +| Поле | Тип | Обязательно | Описание | +|------|-----|-------------|----------| +| `platform_config` | object | нет | Произвольный объект: ключи — строки, значения — строки, числа, булевы | + +**Правила platform_config:** +- Содержимое объекта не валидируется: передавайте параметры как есть. +- При каждом импорте хранится latest-snapshot per machine; история изменений по каждому ключу накапливается отдельно. +- Если секция отсутствует или равна `null` — данные платформы не обновляются. + +```json +"platform_config": { + "SecureBoot": "Enabled", + "BiosVersion": "06.08.05", + "TpmEnabled": true, + "NumaEnabled": false, + "HyperThreading": "Enabled" +} +``` + +--- + ## Обработка статусов компонентов | Статус | Поведение | @@ -785,6 +812,12 @@ PSU без `serial_number` игнорируется. "other": [ { "name": "System Humidity", "value": 38.5, "unit": "%" } ] + }, + "platform_config": { + "SecureBoot": "Enabled", + "BiosVersion": "06.08.05", + "TpmEnabled": true, + "HyperThreading": "Enabled" } } } diff --git a/internal/api/asset_component_actions_api.go b/internal/api/asset_component_actions_api.go index ebac9ea..c30a881 100644 --- a/internal/api/asset_component_actions_api.go +++ b/internal/api/asset_component_actions_api.go @@ -55,6 +55,10 @@ func (h assetComponentHandlers) handleAssetAPI(w http.ResponseWriter, r *http.Re h.handleAssetComponentsEditActionAPI(w, r) case strings.HasSuffix(r.URL.Path, "/components/actions/remove"): h.handleAssetComponentsRemoveActionAPI(w, r) + case strings.HasSuffix(r.URL.Path, "/platform-config"): + h.handleAssetPlatformConfigAPI(w, r) + case strings.Contains(r.URL.Path, "/platform-config/") && strings.HasSuffix(r.URL.Path, "/history"): + h.handleAssetPlatformConfigKeyHistoryAPI(w, r) default: w.WriteHeader(http.StatusNotFound) } @@ -567,3 +571,66 @@ func isDuplicateDBError(err error) bool { msg := strings.ToLower(err.Error()) return strings.Contains(msg, "duplicate") || strings.Contains(msg, "uniq_") } + +func (h assetComponentHandlers) handleAssetPlatformConfigAPI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if h.deps.PlatformConfig == nil { + writeError(w, http.StatusInternalServerError, "platform config unavailable") + return + } + assetID, ok := parseSubresourceID(r.URL.Path, "/api/assets/", "/platform-config") + if !ok { + writeError(w, http.StatusNotFound, "asset not found") + return + } + snapshot, err := h.deps.PlatformConfig.GetSnapshot(r.Context(), assetID) + if err != nil { + if err == registry.ErrNotFound { + writeError(w, http.StatusNotFound, "platform config not found") + return + } + writeError(w, http.StatusInternalServerError, "load platform config failed") + return + } + writeJSON(w, http.StatusOK, snapshot) +} + +func (h assetComponentHandlers) handleAssetPlatformConfigKeyHistoryAPI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if h.deps.PlatformConfig == nil { + writeError(w, http.StatusInternalServerError, "platform config unavailable") + return + } + // Path: /api/assets/{id}/platform-config/{key}/history + path := r.URL.Path + const prefix = "/api/assets/" + const suffix = "/history" + if !strings.HasPrefix(path, prefix) || !strings.HasSuffix(path, suffix) { + writeError(w, http.StatusNotFound, "not found") + return + } + inner := path[len(prefix) : len(path)-len(suffix)] + sep := strings.Index(inner, "/platform-config/") + if sep < 0 { + writeError(w, http.StatusNotFound, "not found") + return + } + assetID := inner[:sep] + key := inner[sep+len("/platform-config/"):] + if assetID == "" || key == "" { + writeError(w, http.StatusNotFound, "not found") + return + } + items, err := h.deps.PlatformConfig.GetKeyHistory(r.Context(), assetID, key) + if err != nil { + writeError(w, http.StatusInternalServerError, "load platform config history failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"asset_id": assetID, "key": key, "items": items}) +} diff --git a/internal/api/assets_components.go b/internal/api/assets_components.go index dcf78b6..39792a7 100644 --- a/internal/api/assets_components.go +++ b/internal/api/assets_components.go @@ -10,12 +10,13 @@ import ( ) type AssetComponentDependencies struct { - Assets *registry.AssetRepository - AssetLogs *registry.AssetEventLogRepository - Components *registry.ComponentRepository - Installations *registry.InstallationRepository - Timeline *timeline.EventRepository - History *history.Service + Assets *registry.AssetRepository + AssetLogs *registry.AssetEventLogRepository + Components *registry.ComponentRepository + Installations *registry.InstallationRepository + Timeline *timeline.EventRepository + History *history.Service + PlatformConfig *registry.AssetPlatformConfigRepository } type assetComponentHandlers struct { diff --git a/internal/api/ingest.go b/internal/api/ingest.go index 4d237d4..3776157 100644 --- a/internal/api/ingest.go +++ b/internal/api/ingest.go @@ -213,8 +213,9 @@ func (h ingestHandlers) handleHardware(w http.ResponseWriter, r *http.Request) { Board: req.Hardware.Board, Components: components, Firmware: firmware, - Sensors: req.Hardware.Sensors, - EventLogs: ingest.NormalizeAssetEventLogs(req.Hardware, collectedAt), + Sensors: req.Hardware.Sensors, + EventLogs: ingest.NormalizeAssetEventLogs(req.Hardware, collectedAt), + PlatformConfig: req.Hardware.PlatformConfig, } job := h.jsonJobs.create(boardSerial) diff --git a/internal/api/server.go b/internal/api/server.go index 0e4a757..ce45a3f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -103,6 +103,7 @@ func NewServer(addr string, readTimeout, writeTimeout time.Duration, db *sql.DB) failureRepo := failures.NewFailureRepository(db) assetRepo := registry.NewAssetRepository(db) assetLogRepo := registry.NewAssetEventLogRepository(db) + platformConfigRepo := registry.NewAssetPlatformConfigRepository(db) componentRepo := registry.NewComponentRepository(db) installationRepo := registry.NewInstallationRepository(db) timelineRepo := timeline.NewEventRepository(db) @@ -121,12 +122,13 @@ func NewServer(addr string, readTimeout, writeTimeout time.Duration, db *sql.DB) Service: ingest.NewService(db), }) RegisterAssetComponentRoutes(mux, AssetComponentDependencies{ - Assets: assetRepo, - AssetLogs: assetLogRepo, - Components: componentRepo, - Installations: installationRepo, - Timeline: timelineRepo, - History: historySvc, + Assets: assetRepo, + AssetLogs: assetLogRepo, + Components: componentRepo, + Installations: installationRepo, + Timeline: timelineRepo, + History: historySvc, + PlatformConfig: platformConfigRepo, }) RegisterFailureRoutes(mux, FailureDependencies{ Failures: failureRepo, diff --git a/internal/api/ui_assets.tmpl b/internal/api/ui_assets.tmpl index afe6f86..92a417d 100644 --- a/internal/api/ui_assets.tmpl +++ b/internal/api/ui_assets.tmpl @@ -97,6 +97,7 @@
+
@@ -106,6 +107,9 @@ +
@@ -115,6 +119,9 @@ +
@@ -428,8 +435,11 @@ const componentsMessage = document.getElementById('current-components-message'); const componentsActions = document.getElementById('current-components-actions'); const logsActions = document.getElementById('asset-host-logs-actions'); + const platformConfigPanel = document.getElementById('asset-platform-config-panel'); + const platformConfigActions = document.getElementById('asset-platform-config-actions'); const tabComponents = document.getElementById('asset-data-tab-components'); const tabLogs = document.getElementById('asset-data-tab-logs'); + const tabPlatformConfig = document.getElementById('asset-data-tab-platform-config'); const state = { items: [], available: { sources: [], severities: [] }, @@ -651,22 +661,121 @@ } } if (refreshBtn) refreshBtn.addEventListener('click', reloadHostLogs); + + // Platform Config + const pcState = { initialized: false, expandedKey: null }; + const pcRefreshBtn = document.getElementById('asset-platform-config-refresh'); + function renderPlatformConfig(snapshot) { + if (!platformConfigPanel) return; + if (!snapshot || !snapshot.items || snapshot.items.length === 0) { + platformConfigPanel.innerHTML = '
No platform config available.
'; + return; + } + const rows = snapshot.items.map((item) => { + const changed = item.change_count > 0; + const rowClass = changed ? 'clickable row--changed' : ''; + const badge = changed + ? `${esc(item.change_count)}` + : '—'; + return ` + ${esc(item.key)} + ${esc(String(item.value ?? ''))} + ${badge} + +
Loading…
`; + }).join(''); + platformConfigPanel.innerHTML = ` + + + ${rows} +
ParameterValueChanges
`; + platformConfigPanel.querySelectorAll('tr[data-key].row--changed').forEach((row) => { + row.addEventListener('click', () => togglePlatformConfigHistory(row.dataset.key)); + }); + } + async function togglePlatformConfigHistory(key) { + if (!platformConfigPanel) return; + const histRow = platformConfigPanel.querySelector(`.js-pc-history-row[data-key="${CSS.escape(key)}"]`); + if (!histRow) return; + if (!histRow.hidden) { + histRow.hidden = true; + pcState.expandedKey = null; + return; + } + if (pcState.expandedKey && pcState.expandedKey !== key) { + const prev = platformConfigPanel.querySelector(`.js-pc-history-row[data-key="${CSS.escape(pcState.expandedKey)}"]`); + if (prev) prev.hidden = true; + } + pcState.expandedKey = key; + histRow.hidden = false; + const contentEl = histRow.querySelector('.js-pc-history-content'); + if (contentEl) contentEl.textContent = 'Loading…'; + try { + const res = await fetch(`/api/assets/${encodeURIComponent(assetID)}/platform-config/${encodeURIComponent(key)}/history`); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Failed to load history'); + const items = Array.isArray(body.items) ? body.items : []; + if (items.length === 0) { + if (contentEl) contentEl.textContent = 'No history.'; + return; + } + const histRows = items.map((h) => ` + ${esc(formatDate(h.collected_at))} + ${esc(String(h.value ?? ''))} + ${esc(h.source)} + `).join(''); + if (contentEl) contentEl.outerHTML = ` + + ${histRows} +
TimeValueSource
`; + } catch (e) { + if (contentEl) contentEl.textContent = (e && e.message) || 'Failed to load history'; + } + } + async function loadPlatformConfig() { + if (!platformConfigPanel) return; + platformConfigPanel.innerHTML = '
Loading platform config…
'; + try { + const res = await fetch(`/api/assets/${encodeURIComponent(assetID)}/platform-config`); + if (res.status === 404) { + platformConfigPanel.innerHTML = '
No platform config available.
'; + return; + } + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Failed to load platform config'); + pcState.expandedKey = null; + renderPlatformConfig(body); + } catch (e) { + platformConfigPanel.innerHTML = `
${esc((e && e.message) || 'Failed to load platform config')}
`; + } + } + if (pcRefreshBtn) pcRefreshBtn.addEventListener('click', loadPlatformConfig); + function setAssetDataTab(tab) { const showLogs = tab === 'logs'; + const showPlatformConfig = tab === 'platform-config'; + const showComponents = tab === 'components'; if (panel) panel.hidden = !showLogs; - if (componentsPanel) componentsPanel.hidden = showLogs; + if (componentsPanel) componentsPanel.hidden = !showComponents; + if (platformConfigPanel) platformConfigPanel.hidden = !showPlatformConfig; if (messageEl) messageEl.hidden = !showLogs; - if (componentsMessage) componentsMessage.hidden = showLogs; - if (componentsActions) componentsActions.hidden = showLogs; + if (componentsMessage) componentsMessage.hidden = !showComponents; + if (componentsActions) componentsActions.hidden = !showComponents; if (logsActions) logsActions.hidden = !showLogs; - if (tabComponents) tabComponents.className = showLogs ? 'button button-secondary' : 'button'; + if (platformConfigActions) platformConfigActions.hidden = !showPlatformConfig; + if (tabComponents) tabComponents.className = showComponents ? 'button' : 'button button-secondary'; if (tabLogs) tabLogs.className = showLogs ? 'button' : 'button button-secondary'; + if (tabPlatformConfig) tabPlatformConfig.className = showPlatformConfig ? 'button' : 'button button-secondary'; if (showLogs && !state.initialized) { loadHostLogs({ pageIndex: 0, cursor: null }); } + if (showPlatformConfig) { + loadPlatformConfig(); + } } if (tabComponents) tabComponents.addEventListener('click', () => setAssetDataTab('components')); if (tabLogs) tabLogs.addEventListener('click', () => setAssetDataTab('logs')); + if (tabPlatformConfig) tabPlatformConfig.addEventListener('click', () => setAssetDataTab('platform-config')); setAssetDataTab('components'); })(); diff --git a/internal/ingest/parser_hardware.go b/internal/ingest/parser_hardware.go index c263064..92fd04b 100644 --- a/internal/ingest/parser_hardware.go +++ b/internal/ingest/parser_hardware.go @@ -36,8 +36,9 @@ type HardwareSnapshot struct { Storage []HardwareStorage `json:"storage,omitempty"` PCIeDevices []HardwarePCIeDevice `json:"pcie_devices,omitempty"` PowerSupplies []HardwarePowerSupply `json:"power_supplies,omitempty"` - Sensors *HardwareSensors `json:"sensors,omitempty"` - EventLogs []HardwareEventLog `json:"event_logs,omitempty"` + Sensors *HardwareSensors `json:"sensors,omitempty"` + EventLogs []HardwareEventLog `json:"event_logs,omitempty"` + PlatformConfig map[string]any `json:"platform_config,omitempty"` } type HardwareSensors struct { diff --git a/internal/ingest/service.go b/internal/ingest/service.go index 4a8791a..8d4cf49 100644 --- a/internal/ingest/service.go +++ b/internal/ingest/service.go @@ -48,8 +48,9 @@ type HardwareInput struct { Board HardwareBoard Components []HardwareComponent Firmware []HardwareFirmwareRecord - Sensors *HardwareSensors - EventLogs []AssetEventLog + Sensors *HardwareSensors + EventLogs []AssetEventLog + PlatformConfig map[string]any } type HardwareSummary struct { @@ -295,6 +296,15 @@ func (s *Service) ingestHardware(ctx context.Context, input HardwareInput, defer if err := s.upsertAssetEventLogs(ctx, tx, assetID, ingestedAt, input.EventLogs); err != nil { return HardwareResult{}, err } + if input.PlatformConfig != nil { + source := input.TargetHost + if input.Filename != nil && *input.Filename != "" { + source = *input.Filename + } + if err := s.savePlatformConfig(ctx, tx, assetID, source, collectedAt, input.PlatformConfig); err != nil { + return HardwareResult{}, err + } + } if s.history != nil { if err := s.applyHistoryAssetLogCollectedInTx(ctx, tx, assetID, logBundleID, input, ingestedAt, collectedAt, deferred); err != nil { @@ -1858,6 +1868,56 @@ func observationDetailsPayload(component HardwareComponent) ([]byte, error) { return json.Marshal(detail) } +func (s *Service) savePlatformConfig(ctx context.Context, tx *sql.Tx, machineID, source string, collectedAt time.Time, config map[string]any) error { + // Load existing snapshot for change detection. + var existingJSON []byte + err := tx.QueryRowContext(ctx, + `SELECT config_json FROM machine_platform_config WHERE machine_id = ?`, machineID, + ).Scan(&existingJSON) + if err != nil && err != sql.ErrNoRows { + return err + } + existing := make(map[string]any) + if len(existingJSON) > 0 { + _ = json.Unmarshal(existingJSON, &existing) + } + + // Insert history rows for new/changed keys. + for k, v := range config { + prevVal, hasPrev := existing[k] + prevJSON, _ := json.Marshal(prevVal) + newJSON, _ := json.Marshal(v) + if hasPrev && string(prevJSON) == string(newJSON) { + continue + } + histID, err := s.idgen.Generate(ctx, idgen.MachineEventLog) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO machine_platform_config_history (id, machine_id, config_key, value_json, source, collected_at) + VALUES (?, ?, ?, ?, ?, ?)`, + histID, machineID, k, string(newJSON), source, collectedAt, + ); err != nil { + return err + } + } + + // Upsert snapshot. + configJSON, err := json.Marshal(config) + if err != nil { + return err + } + now := time.Now().UTC() + _, err = tx.ExecContext(ctx, + `INSERT INTO machine_platform_config (machine_id, config_json, collected_at, updated_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE config_json = VALUES(config_json), collected_at = VALUES(collected_at), updated_at = VALUES(updated_at)`, + machineID, string(configJSON), collectedAt, now, + ) + return err +} + func (s *Service) upsertHardwareFailureEvent(ctx context.Context, tx *sql.Tx, assetID, componentID string, failureAt time.Time, component HardwareComponent) error { externalID := fmt.Sprintf("hardware_ingest:%s:%s", component.VendorSerial, failureAt.UTC().Format(time.RFC3339)) details := map[string]any{ diff --git a/internal/repository/registry/asset_platform_config.go b/internal/repository/registry/asset_platform_config.go new file mode 100644 index 0000000..3da3bae --- /dev/null +++ b/internal/repository/registry/asset_platform_config.go @@ -0,0 +1,126 @@ +package registry + +import ( + "context" + "database/sql" + "encoding/json" + "time" +) + +type AssetPlatformConfigItem struct { + Key string `json:"key"` + Value any `json:"value"` + ChangeCount int `json:"change_count"` +} + +type AssetPlatformConfigSnapshot struct { + AssetID string `json:"asset_id"` + CollectedAt time.Time `json:"collected_at"` + Items []AssetPlatformConfigItem `json:"items"` +} + +type AssetPlatformConfigHistoryItem struct { + CollectedAt time.Time `json:"collected_at"` + Value any `json:"value"` + Source string `json:"source"` +} + +type AssetPlatformConfigRepository struct { + db *sql.DB +} + +func NewAssetPlatformConfigRepository(db *sql.DB) *AssetPlatformConfigRepository { + return &AssetPlatformConfigRepository{db: db} +} + +func (r *AssetPlatformConfigRepository) GetSnapshot(ctx context.Context, machineID string) (*AssetPlatformConfigSnapshot, error) { + var configJSON []byte + var collectedAt time.Time + err := r.db.QueryRowContext(ctx, + `SELECT config_json, collected_at FROM machine_platform_config WHERE machine_id = ?`, machineID, + ).Scan(&configJSON, &collectedAt) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + var raw map[string]any + if err := json.Unmarshal(configJSON, &raw); err != nil { + return nil, err + } + + counts, err := r.GetChangeCountsForMachine(ctx, machineID) + if err != nil { + return nil, err + } + + items := make([]AssetPlatformConfigItem, 0, len(raw)) + for k, v := range raw { + items = append(items, AssetPlatformConfigItem{ + Key: k, + Value: v, + ChangeCount: counts[k], + }) + } + + return &AssetPlatformConfigSnapshot{ + AssetID: machineID, + CollectedAt: collectedAt, + Items: items, + }, nil +} + +func (r *AssetPlatformConfigRepository) GetChangeCountsForMachine(ctx context.Context, machineID string) (map[string]int, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT config_key, COUNT(*) FROM machine_platform_config_history WHERE machine_id = ? GROUP BY config_key`, machineID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make(map[string]int) + for rows.Next() { + var k string + var c int + if err := rows.Scan(&k, &c); err != nil { + return nil, err + } + counts[k] = c + } + return counts, rows.Err() +} + +func (r *AssetPlatformConfigRepository) GetKeyHistory(ctx context.Context, machineID, key string) ([]AssetPlatformConfigHistoryItem, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT value_json, source, collected_at + FROM machine_platform_config_history + WHERE machine_id = ? AND config_key = ? + ORDER BY collected_at DESC`, + machineID, key, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []AssetPlatformConfigHistoryItem + for rows.Next() { + var valueJSON string + var source string + var collectedAt time.Time + if err := rows.Scan(&valueJSON, &source, &collectedAt); err != nil { + return nil, err + } + var v any + _ = json.Unmarshal([]byte(valueJSON), &v) + items = append(items, AssetPlatformConfigHistoryItem{ + CollectedAt: collectedAt, + Value: v, + Source: source, + }) + } + return items, rows.Err() +} diff --git a/migrations/0024_machine_platform_config/down.sql b/migrations/0024_machine_platform_config/down.sql new file mode 100644 index 0000000..a4ef666 --- /dev/null +++ b/migrations/0024_machine_platform_config/down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS machine_platform_config_history; +DROP TABLE IF EXISTS machine_platform_config; diff --git a/migrations/0024_machine_platform_config/up.sql b/migrations/0024_machine_platform_config/up.sql new file mode 100644 index 0000000..60d585b --- /dev/null +++ b/migrations/0024_machine_platform_config/up.sql @@ -0,0 +1,19 @@ +CREATE TABLE machine_platform_config ( + machine_id VARCHAR(36) NOT NULL, + config_json JSON NOT NULL, + collected_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (machine_id), + CONSTRAINT fk_mpc_machine FOREIGN KEY (machine_id) REFERENCES machines(id) +); + +CREATE TABLE machine_platform_config_history ( + id VARCHAR(36) NOT NULL, + machine_id VARCHAR(36) NOT NULL, + config_key VARCHAR(500) NOT NULL, + value_json TEXT NOT NULL, + source VARCHAR(500) NOT NULL, + collected_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + INDEX idx_mpchr_lookup (machine_id, config_key, collected_at DESC) +);