mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-09-10 22:14:48 -05:00
feat(ipmi): add get sensors route (#10003)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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-->
|
||||
|
||||
115
docs/docs/xo5/ipmi-plugin.md
Normal file
115
docs/docs/xo5/ipmi-plugin.md
Normal 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.
|
||||
@@ -165,6 +165,7 @@ export default {
|
||||
'xo5/sdn_controller',
|
||||
'xo5/restapi',
|
||||
'xo5/mcp',
|
||||
'xo5/ipmi-plugin',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
77
packages/xo-server-ipmi-sensors/src/rest-api.mts
Normal file
77
packages/xo-server-ipmi-sensors/src/rest-api.mts
Normal 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
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user