diff --git a/@xen-orchestra/rest-api/src/middlewares/generic-error-handler.middleware.mts b/@xen-orchestra/rest-api/src/middlewares/generic-error-handler.middleware.mts index 1b1989445a..069535d6aa 100644 --- a/@xen-orchestra/rest-api/src/middlewares/generic-error-handler.middleware.mts +++ b/@xen-orchestra/rest-api/src/middlewares/generic-error-handler.middleware.mts @@ -9,6 +9,7 @@ import { notImplemented, objectAlreadyExists, unauthorized, + serviceUnavailable, } from 'xo-common/api-errors.js' import type { HttpStatusCodeLiteral } from 'tsoa' import { NextFunction, Request, Response } from 'express' @@ -52,6 +53,8 @@ export default function genericErrorHandler(error: unknown, req: Request, res: R statusCode = 501 } else if (incorrectState.is(error)) { statusCode = 409 + } else if (serviceUnavailable.is(error)) { + statusCode = 503 } else { if (error.name === 'XapiError') { responseError.info = 'This is a XenServer/XCP-ng error, not an XO error' diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 9f56135edd..34281dde13 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -15,6 +15,8 @@ - [REST API] Add `hosts/:id/actions/scan_pifs` endpoint (PR [#10187](https://github.com/vatesfr/xen-orchestra/pull/10187)) - [XO6/Host] Add possibility to scan PIFs directly from the host (PR [#10191](https://github.com/vatesfr/xen-orchestra/pull/10191)) +- [IPMI-plugin] Add GET plugins/ipmi-sensors/hosts/{id}/ipmi to get IPMI sensors (PR [#10003](https://github.com/vatesfr/xen-orchestra/pull/10003)) + ### Bug fixes > Users must be able to say: "I had this issue, happy to know it's fixed" @@ -41,6 +43,8 @@ - @xen-orchestra/rest-api minor - @xen-orchestra/web minor - @xen-orchestra/web-core minor +- xo-common minor - xo-server patch +- xo-server-ipmi-sensors minor diff --git a/docs/docs/xo5/ipmi-plugin.md b/docs/docs/xo5/ipmi-plugin.md new file mode 100644 index 0000000000..0fd882c7e4 --- /dev/null +++ b/docs/docs/xo5/ipmi-plugin.md @@ -0,0 +1,115 @@ +# IPMI + +## Categorizing "unknown" IPMI sensors + +When the plugin returns a sensor tagged with `"dataType": "unknown"`, it means +the sensor name matched **none** of the regex rules configured for that host's +vendor. Unknown sensors are still listed by the raw inventory, but they are +**dropped** from the categorized `get_ipmi_sensors` output (consumed by the XO5 +UI), because the plugin only keeps sensors it knows how to classify. + +This guide explains how to map those unknown sensors to a known data type. + +## 1. Understand the pipeline + +- `get_ipmi_sensors` returns sensors **grouped by data type**, after filtering + out everything irrelevant/unknown. This is what the XO5 UI shows. (XO6 uses + the `GET /rest/v0/plugins/ipmi-sensors/hosts/{id}/ipmi` REST route instead.) +- `GET /rest/v0/plugins/ipmi-sensors/hosts/{id}/ipmi` returns **every raw sensor** with its resolved + `dataType` (or `"unknown"`). Use it to discover what needs a rule. + +Both resolve the vendor from the host BIOS strings (`system-product-name`, +lowercased). Note that all Dell hosts are collapsed to the vendor `dell` and all +Lenovo hosts to `lenovo`, regardless of model. + +## 2. Get the list of sensors to categorize + +Fetch the raw inventory for the host with `GET /rest/v0/plugins/ipmi-sensors/hosts/{id}/ipmi`. +You'll get output like: + +```json +{ + "productName": "dell", + "systemManufacturer": "dell inc.", + "sensors": [ + { "name": "Inlet Temp", "value": "22 degrees C", "event": "ok", "dataType": "inletTemp" }, + { "name": "Pwr Consumption", "value": "140 Watts", "event": "ok", "dataType": "totalPower" }, + { "name": "Current 1", "value": "0.60 Amps", "event": "ok", "dataType": "unknown" }, + { "name": "Current 2", "value": "0 Amps", "event": "ok", "dataType": "unknown" } + ] +} +``` + +Pick out the `unknown` sensors that carry data you actually want to surface. +Most `0x00` / `Not Readable` status flags are noise and can stay unknown — only +promote sensors that map to a real metric. In the example above, `Current 1` / +`Current 2` (PSU amperage) are good candidates; the dozens of `PG` / `Presence` +flags are not. + +## 3. Pick the target data type + +A rule maps a sensor **name** to one of these data types +([types.mts](https://github.com/vatesfr/xen-orchestra/blob/master/packages/xo-server-ipmi-sensors/src/types.mts), `IPMI_SENSOR_DATA_TYPE`): + +| Data type | Meaning | +| ------------ | ------------------------------- | +| `totalPower` | Total power consumption (Watts) | +| `inletTemp` | Inlet / ambient temperature | +| `outletTemp` | Outlet / exhaust temperature | +| `cpuTemp` | CPU temperature | +| `fanSpeed` | Fan speed (RPM) | +| `fanStatus` | Fan status | +| `psuPower` | PSU power / voltage | +| `psuStatus` | PSU status | +| `bmcStatus` | BMC status | +| `ip` | Management IP address | + +## 4. Write the regex + +Each rule is a `/pattern/flags` string keyed by data type, grouped under a +vendor. Patterns are matched against the sensor **name**. Anchor with `^…$` and +use the `i` flag so casing doesn't matter: + +```json +{ + "vendors": [ + { + "vendor": "dell", + "sensorRegexps": { + "fanSpeed": "/^fan[0-9]+(a|b)$/i", + "psuPower": "/^voltage [0-9]+$/i", + "psuStatus": "/^ps[0-9]+ pg fail$/i", + "cpuTemp": "/^temp$/i", + "totalPower": "/^pwr consumption$/i", + "inletTemp": "/^inlet temp$/i", + "outletTemp": "/^exhaust temp$/i", + "ip": "/^ip address$/i" + } + } + ] +} +``` + +> **Tip:** when several sensors share the same name (e.g. `Voltage 1` / `Voltage 2`), the plugin automatically +> groups them into an array under that data type. Write one pattern that matches +> all of them rather than one rule per sensor. + +## 5. Apply the configuration + +- **Per deployment:** edit the plugin configuration in the XO web interface + (Settings → Plugins → ipmi-sensors → `vendors`). This overrides the defaults + without touching the code. +- **As a new default preset:** add/extend the vendor entry in + `DEFAULT_IPMI_SENSOR_REGEX_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME` in + [default-rules.mts](https://github.com/vatesfr/xen-orchestra/blob/master/packages/xo-server-ipmi-sensors/src/default-rules.mts). These ship as the built-in + defaults for everyone. + +To support a brand-new vendor, add a new `{ vendor, sensorRegexps }` object. The +`vendor` must equal the lowercased `system-product-name` of the host (or `dell` +/ `lenovo`, which are normalized in [index.mts](https://github.com/vatesfr/xen-orchestra/blob/master/packages/xo-server-ipmi-sensors/src/index.mts)). + +## 6. Verify + +Re-run `GET /rest/v0/plugins/ipmi-sensors/hosts/{id}/ipmi` and confirm the previously-unknown sensors +now report the expected `dataType`, then check that the categorized +`get_ipmi_sensors` output groups them correctly. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 2b7d5cc566..8deeb38501 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -165,6 +165,7 @@ export default { 'xo5/sdn_controller', 'xo5/restapi', 'xo5/mcp', + 'xo5/ipmi-plugin', ], }, { diff --git a/packages/xo-common/api-errors.js b/packages/xo-common/api-errors.js index 6ec0aa0984..0de162b3a9 100644 --- a/packages/xo-common/api-errors.js +++ b/packages/xo-common/api-errors.js @@ -212,7 +212,7 @@ exports.featureUnauthorized = create(26, ({ featureCode, currentPlan, minPlan, c currentPlan, minPlan, currentBundle, - allowedBundles + allowedBundles, }, message: 'feature Unauthorized', })) @@ -225,3 +225,10 @@ exports.noMatchingVm = create(27, ({ jobId, runJobId, scheduleId }) => ({ }, message: 'no VMs match this pattern', })) + +exports.serviceUnavailable = create(28, ({ serviceName }) => ({ + data: { + serviceName, + }, + message: 'service unavailable', +})) diff --git a/packages/xo-server-ipmi-sensors/package.json b/packages/xo-server-ipmi-sensors/package.json index 5c483bbd04..28f4837754 100644 --- a/packages/xo-server-ipmi-sensors/package.json +++ b/packages/xo-server-ipmi-sensors/package.json @@ -29,7 +29,8 @@ "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@vates/types": "^1.30.1", - "@xen-orchestra/log": "^0.7.1" + "@xen-orchestra/log": "^0.7.1", + "xo-common": "^0.10.0" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/packages/xo-server-ipmi-sensors/src/index.mts b/packages/xo-server-ipmi-sensors/src/index.mts index 2ba12c0c88..7e67b6ba79 100644 --- a/packages/xo-server-ipmi-sensors/src/index.mts +++ b/packages/xo-server-ipmi-sensors/src/index.mts @@ -9,7 +9,13 @@ import type { XoApp, XoHost } from '@vates/types' import { createLogger } from '@xen-orchestra/log' import { DEFAULT_IPMI_SENSOR_REGEX_CONFIG_STRINGIFIED } from './default-rules.mjs' -import { addIpmiSensorDataType, containsDigit, isRelevantIpmiSensor, parseRegexConfig } from './ipmi-rules.mjs' +import { + addIpmiSensorDataType, + addIpmiSensorsDataType, + containsDigit, + isRelevantIpmiSensor, + parseRegexConfig, +} from './ipmi-rules.mjs' import { addCustomIpmiSensors } from './legacy-computed-rules.mjs' import { IPMI_SENSOR_DATA_TYPE, @@ -17,14 +23,16 @@ import { ReturnedSensorData, FinalSensorData, PluginConfiguration, + AvailableIpmiSensors, } from './types.mjs' import TTLCache from '@isaacs/ttlcache' +import { createIpmiRestRoutes } from './rest-api.mjs' // ============================================================================ // Constants // ============================================================================ const IPMI_CACHE_TTL = 6e4 -const logger = createLogger('xo:xo-server-ipmi-sensors') +export const logger = createLogger('xo:xo-server-ipmi-sensors') // ============================================================================ // Configuration Schema (exported for xo-server) @@ -73,7 +81,7 @@ export const configurationPresets = { // ============================================================================ // Plugin Class // ============================================================================ -class IpmiSensorsPlugin { +export class IpmiSensorsPlugin { #configuredRulesByProduct: SensorRegexByProduct[] readonly #xo: XoApp // cache is type any because it handles itself @@ -118,11 +126,27 @@ class IpmiSensorsPlugin { resolve: { host: ['id', 'host', 'administrate'] }, params: { id: { type: 'string' } }, } - ) + ), + this.#xo.registerRestRoutes(createIpmiRestRoutes(this)) ) } - async getIpmiSensors({ host }: { host: XoHost }): Promise { + async #fetchRawSensors(callIpmiPlugin: (fn: string) => Promise): Promise { + const [stringifiedIpmiSensors, stringifiedIpmiLan] = await Promise.all([ + callIpmiPlugin('get_all_sensors'), + callIpmiPlugin('get_ipmi_lan'), + ]) + return [ + ...(JSON.parse(stringifiedIpmiSensors) as ReturnedSensorData[]), + ...(JSON.parse(stringifiedIpmiLan) as ReturnedSensorData[]), + ] + } + + #getIpmiContext(host: XoHost): { + productName: string + systemManufacturer: string + callIpmiPlugin: (fn: string) => Promise + } { const xApiHost = this.#xo.getXapiObject(host, 'host') const biosStrings = xApiHost.bios_strings let productName = biosStrings['system-product-name']?.toLowerCase() || '' @@ -131,11 +155,18 @@ class IpmiSensorsPlugin { if (systemManufacturer.includes('dell')) productName = 'dell' if (systemManufacturer.includes('lenovo')) productName = 'lenovo' - const data = this.#configuredRulesByProduct const callIpmiPlugin = async (fn: string): Promise => { return await xApiHost.$xapi.call(this.#cache, 'host.call_plugin', xApiHost.$ref, 'ipmitool.py', fn, {}) } + return { productName, systemManufacturer, callIpmiPlugin } + } + + async getIpmiSensors({ host }: { host: XoHost }): Promise { + const { productName, callIpmiPlugin } = this.#getIpmiContext(host) + + const data = this.#configuredRulesByProduct + const ipmiDeviceAvailable = await callIpmiPlugin('is_ipmi_device_available') if (!data.some(s => s.vendor.toLowerCase() === productName) || ipmiDeviceAvailable === 'false') { @@ -146,14 +177,9 @@ class IpmiSensorsPlugin { return {} } - const [stringifiedIpmiSensors, stringifiedIpmiLan] = await Promise.all([ - callIpmiPlugin('get_all_sensors'), - callIpmiPlugin('get_ipmi_lan'), - ]) - const ipmiSensors = JSON.parse(stringifiedIpmiSensors) as ReturnedSensorData[] - const ipmiLan = JSON.parse(stringifiedIpmiLan) as ReturnedSensorData[] + const sensors = await this.#fetchRawSensors(callIpmiPlugin) const ipmiSensorsByDataType: FinalSensorData = {} - for (const ipmiSensor of [...ipmiSensors, ...ipmiLan]) { + for (const ipmiSensor of sensors) { if (!isRelevantIpmiSensor(ipmiSensor, productName, this.#configuredRulesByProduct)) { continue } @@ -179,6 +205,27 @@ class IpmiSensorsPlugin { return ipmiSensorsByDataType } + + async getAvailableIpmiSensors({ host }: { host: XoHost }): Promise { + const { productName, systemManufacturer, callIpmiPlugin } = this.#getIpmiContext(host) + + const ipmiDeviceAvailable = (await callIpmiPlugin('is_ipmi_device_available')) !== 'false' + if (!ipmiDeviceAvailable) { + return false + } + + const sensors = await this.#fetchRawSensors(callIpmiPlugin) + // tags each `sensor.dataType` with the matching data type, or 'unknown', + // and narrows `sensors` to `AvailableIpmiSensor[]` + addIpmiSensorsDataType(sensors, productName, this.#configuredRulesByProduct) + + return { + productName, + systemManufacturer, + sensors, + } + } + /** * Unload and stop the plugin. */ diff --git a/packages/xo-server-ipmi-sensors/src/ipmi-rules.mts b/packages/xo-server-ipmi-sensors/src/ipmi-rules.mts index 7e9d7863ed..cc75fafe7a 100644 --- a/packages/xo-server-ipmi-sensors/src/ipmi-rules.mts +++ b/packages/xo-server-ipmi-sensors/src/ipmi-rules.mts @@ -2,6 +2,7 @@ import { SensorRegexByProduct, SensorRegexByDataType, ReturnedSensorData, + AvailableIpmiSensor, IPMI_SENSOR_DATA_TYPE_STRINGS, IPMI_SENSOR_DATA_TYPE, SensorRegexByProductRaw, @@ -36,6 +37,16 @@ export function addIpmiSensorDataType( data.dataType = IPMI_SENSOR_DATA_TYPE.unknown as IPMI_SENSOR_DATA_TYPE_STRINGS } +export function addIpmiSensorsDataType( + sensors: ReturnedSensorData[], + productName: string, + configuredRules: SensorRegexByProduct[] +): asserts sensors is AvailableIpmiSensor[] { + for (const sensor of sensors) { + addIpmiSensorDataType(sensor, productName, configuredRules) + } +} + export function containsDigit(str: string): boolean { return /\d/.test(str) } diff --git a/packages/xo-server-ipmi-sensors/src/rest-api.mts b/packages/xo-server-ipmi-sensors/src/rest-api.mts new file mode 100644 index 0000000000..e9f8b97626 --- /dev/null +++ b/packages/xo-server-ipmi-sensors/src/rest-api.mts @@ -0,0 +1,77 @@ +import type { PluginRestRouteDefinition, XoHost } from '@vates/types' +import type { IpmiSensorsPlugin } from './index.mjs' +import { logger } from './index.mjs' +import { IPMI_SENSOR_DATA_TYPE } from './types.mjs' +import { serviceUnavailable } from 'xo-common/api-errors.js' + +export function createIpmiRestRoutes(plugin: IpmiSensorsPlugin): PluginRestRouteDefinition[] { + return [ + { + method: 'get', + description: [ + 'Get the IPMI inventory of a host: product context and the raw sensor list.', + '', + 'Required privilege:', + '- resource: host, action: read', + ].join('\n'), + endpoint: 'plugins/ipmi-sensors/hosts/{id}/ipmi', + + tags: ['ipmi-sensors', 'hosts'], + params: { id: { type: 'string', example: '5b2c9e6a-1d3f-4c7b-9f2e-8a1b0c4d5e6f' } }, + middlewares: [{ name: 'acl', acls: { resource: 'host', action: 'read', objectId: 'params.id' } }], + responses: [ + { + status: 200, + description: 'Raw IPMI sensor inventory with product context', + schema: { + productName: { type: 'string', example: 'poweredge r640' }, + systemManufacturer: { type: 'string', example: 'dell inc.' }, + sensors: { + type: 'array', + example: [ + { name: 'Inlet Temp', value: '23', event: 'ok', dataType: 'inletTemp' }, + { name: 'Fan1', value: '4800', event: 'ok', dataType: 'fanSpeed' }, + { name: 'Pwr Consumption', value: '140', event: 'ok', dataType: 'totalPower' }, + ], + items: { + type: 'object', + fields: { + name: { type: 'string', example: 'Inlet Temp' }, + value: { type: 'string', example: '23' }, + event: { type: 'string', example: 'ok' }, + dataType: { type: 'enum', enum: Object.keys(IPMI_SENSOR_DATA_TYPE), example: 'inletTemp' }, + }, + }, + }, + }, + }, + { + status: 403, + description: 'Access denied', + }, + { + status: 404, + description: 'Host not found', + }, + { + status: 503, + description: 'The host has no available IPMI device', + }, + ], + callback: async ({ req, restApi }) => { + const host = restApi.xoApp.getObject(req.params.id as XoHost['id'], 'host') + let result: Awaited> | undefined + try { + result = await plugin.getAvailableIpmiSensors({ host }) + } catch (error) { + logger.error(error instanceof Error ? error : JSON.stringify(error)) + } + + if (result === undefined || result === false) { + throw serviceUnavailable({ serviceName: 'IPMI device' }) + } + return result + }, + }, + ] +} diff --git a/packages/xo-server-ipmi-sensors/src/types.mts b/packages/xo-server-ipmi-sensors/src/types.mts index 8b63746eea..bc79395a3a 100644 --- a/packages/xo-server-ipmi-sensors/src/types.mts +++ b/packages/xo-server-ipmi-sensors/src/types.mts @@ -44,3 +44,11 @@ export type ReturnedSensorData = { } export type FinalSensorData = Partial> + +export type AvailableIpmiSensor = ReturnedSensorData & { dataType: IPMI_SENSOR_DATA_TYPE_STRINGS } + +export type AvailableIpmiSensors = { + productName: string + systemManufacturer: string + sensors: AvailableIpmiSensor[] +} diff --git a/packages/xo-server-ipmi-sensors/tsconfig.json b/packages/xo-server-ipmi-sensors/tsconfig.json index 6c62588624..f21782b111 100644 --- a/packages/xo-server-ipmi-sensors/tsconfig.json +++ b/packages/xo-server-ipmi-sensors/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "NodeNext", "types": ["node"], "strict": true, + "noImplicitAny": false, // otherwise it will throw an error when importing packages without type definition "noUncheckedIndexedAccess": true, "declaration": true, "outDir": "dist",