feat(ipmi): add get sensors route (#10003)

This commit is contained in:
Grandalf
2026-08-07 11:38:28 +02:00
committed by GitHub
parent 2e5ec8c9e2
commit a0193085f5
11 changed files with 290 additions and 15 deletions

View File

@@ -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'

View File

@@ -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
<!--packages-end-->

View File

@@ -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.

View File

@@ -165,6 +165,7 @@ export default {
'xo5/sdn_controller',
'xo5/restapi',
'xo5/mcp',
'xo5/ipmi-plugin',
],
},
{

View File

@@ -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',
}))

View File

@@ -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",

View File

@@ -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<FinalSensorData> {
async #fetchRawSensors(callIpmiPlugin: <T>(fn: string) => Promise<T>): Promise<ReturnedSensorData[]> {
const [stringifiedIpmiSensors, stringifiedIpmiLan] = await Promise.all([
callIpmiPlugin<string>('get_all_sensors'),
callIpmiPlugin<string>('get_ipmi_lan'),
])
return [
...(JSON.parse(stringifiedIpmiSensors) as ReturnedSensorData[]),
...(JSON.parse(stringifiedIpmiLan) as ReturnedSensorData[]),
]
}
#getIpmiContext(host: XoHost): {
productName: string
systemManufacturer: string
callIpmiPlugin: <T>(fn: string) => Promise<T>
} {
const xApiHost = this.#xo.getXapiObject<XoHost>(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 <T,>(fn: string): Promise<T> => {
return await xApiHost.$xapi.call<T>(this.#cache, 'host.call_plugin', xApiHost.$ref, 'ipmitool.py', fn, {})
}
return { productName, systemManufacturer, callIpmiPlugin }
}
async getIpmiSensors({ host }: { host: XoHost }): Promise<FinalSensorData> {
const { productName, callIpmiPlugin } = this.#getIpmiContext(host)
const data = this.#configuredRulesByProduct
const ipmiDeviceAvailable = await callIpmiPlugin<string>('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<string>('get_all_sensors'),
callIpmiPlugin<string>('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<AvailableIpmiSensors | false> {
const { productName, systemManufacturer, callIpmiPlugin } = this.#getIpmiContext(host)
const ipmiDeviceAvailable = (await callIpmiPlugin<string>('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.
*/

View File

@@ -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)
}

View File

@@ -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<XoHost>(req.params.id as XoHost['id'], 'host')
let result: Awaited<ReturnType<IpmiSensorsPlugin['getAvailableIpmiSensors']>> | 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
},
},
]
}

View File

@@ -44,3 +44,11 @@ export type ReturnedSensorData = {
}
export type FinalSensorData = Partial<Record<IPMI_SENSOR_DATA_TYPE_STRINGS, ReturnedSensorData | ReturnedSensorData[]>>
export type AvailableIpmiSensor = ReturnedSensorData & { dataType: IPMI_SENSOR_DATA_TYPE_STRINGS }
export type AvailableIpmiSensors = {
productName: string
systemManufacturer: string
sensors: AvailableIpmiSensor[]
}

View File

@@ -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",