feat(rest-api): add 7 rest actions on hosts (#9887)

This commit is contained in:
spacotte-vates
2026-06-30 11:37:17 +02:00
committed by GitHub
parent 3ad3ecb244
commit d43ca09b8b
10 changed files with 518 additions and 6 deletions

View File

@@ -179,6 +179,13 @@ export interface Xapi {
deleteVif(vifId: XoVif['id']): Promise<void>
disconnectVif(vifId: XoVif['id']): Promise<void>
exportVmOva(vmRef: XenApiVm['$ref']): Promise<PassThrough>
emergencyShutdownHost(hostId: XoHost['id']): Promise<void>
ejectHostFromPool(hostId: XoHost['id']): Promise<void>
host_smartReboot(
hostRef: XenApiHost['$ref'],
bypassBlockedSuspend?: boolean,
bypassCurrentVmCheck?: boolean
): Promise<void>
migrateVm(
vmId: XoVm['id'],
hostXapi: Xapi,
@@ -222,6 +229,10 @@ export interface Xapi {
}
): Promise<{ vm: XenApiVmWrapped }>
forgetSr(id: XoSr['id']): Promise<void>
forgetHost(hostId: XoHost['id']): Promise<void>
powerOnHost(hostId: XoHost['id']): Promise<void>
rebootHost(hostId: XoHost['id'], force?: boolean): Promise<void>
shutdownHost(hostId: XoHost['id'], opts?: { force?: boolean; bypassEvacuate?: boolean }): Promise<void>
SR_importVdi(
ref: XenApiSr['$ref'],
stream: Readable,

View File

@@ -493,7 +493,7 @@ export interface XenApiDrTask {
}
type XenApiHostCallMethods = TagCallMethods & {
<T>(method: 'call_plugin',plugin:string, fn: string, args: Record<string, string>): Promise<T>
<T>(method: 'call_plugin', plugin: string, fn: string, args: Record<string, string>): Promise<T>
}
export interface XenApiHost {
$ref: Branded<'host'>
@@ -571,7 +571,9 @@ export interface XenApiHost {
uuid: string
virtual_hardware_platform_versions: number[]
}
export type XenApiHostWrapped = WrapperXenApi<XenApiHost, 'host', XenApiHostCallMethods>
export interface XenApiHostWrapped extends WrapperXenApi<XenApiHost, 'host', XenApiHostCallMethods> {
$restartAgent(): Promise<void>
}
export interface XenApiHostCrashdump {
$ref: Branded<'host_crashdump'>

View File

@@ -68,6 +68,7 @@ type FeatureCode =
| 'RBAC'
| 'ROLLING_POOL_UPDATE'
| 'ROLLING_POOL_REBOOT'
| 'SMART_REBOOT'
| 'WARM_MIGRATION'
| 'PLUGIN.OPENMETRICS'
@@ -199,6 +200,7 @@ export type XoApp = {
userData?: { ip?: string },
opts?: { bypassOtp?: boolean; bypassTaskCreation?: boolean }
) => Promise<{ bypassOtp: boolean; expiration: number; user: XoUser }>
backupGuard(poolId: XoPool['id']): Promise<void>
/* Throw if no authorization */
checkFeatureAuthorization(featureCode: FeatureCode): Promise<void>
/* connect a server (XCP-ng/XenServer) */
@@ -236,6 +238,7 @@ export type XoApp = {
deleteAclV2Role(roleId: XoAclRole['id'], options?: { force?: boolean }): Promise<boolean>
deleteGroup(id: XoGroup['id']): Promise<void>
deleteUser(id: XoUser['id']): Promise<void>
detachHostFromPool(hostId: XoHost['id']): Promise<void>
createGroup(params: { name: string; provider?: string; providerGroup?: string }): Promise<XoGroup>
/* disconnect a server (XCP-ng/XenServer) */
disconnectXenServer(id: XoServer['id']): Promise<void>

View File

@@ -1,14 +1,26 @@
export default {
'allow-vm': true,
detach: true,
disable: true,
'migrate-receive': true,
enable: true,
evacuate: true,
export: {
logs: true,
},
forget: true,
'join-pool': true,
'migrate-receive': true,
read: true,
reboot: {
clean: true,
smart: true,
},
'restart-toolstack': true,
shutdown: {
clean: true,
emergency: true,
},
start: true,
update: {
tags: true,
},

View File

@@ -16,12 +16,13 @@ import {
SuccessResponse,
Tags,
} from 'tsoa'
import { HOST_POWER_STATE } from '@vates/types'
import { asyncEach } from '@vates/async-each'
import { type Defer, defer } from 'golike-defer'
import { json } from 'express'
import type { Request as ExRequest, Response as ExResponse } from 'express'
import { inject } from 'inversify'
import { invalidParameters } from 'xo-common/api-errors.js'
import { incorrectState, invalidParameters } from 'xo-common/api-errors.js'
import { pipeline } from 'node:stream/promises'
import { provide } from 'inversify-binding-decorators'
import type {
@@ -32,6 +33,7 @@ import type {
XoHost,
XoMessage,
XoPif,
XoPool,
XoTask,
XoVm,
XsPatches,
@@ -59,6 +61,7 @@ import {
forbiddenOperationResp,
internalServerErrorResp,
invalidParameters as invalidParametersResp,
incorrectStateResp,
noContentResp,
notFoundResp,
unauthorizedResp,
@@ -562,4 +565,359 @@ export class HostController extends XapiXoController<XoHost> {
},
})
}
/**
* Required privilege:
* - resource: host, action: start
*
* Start a host.
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
*/
@Example(taskLocation)
@Extension('x-mcp-exposure', 'confirm')
@Post('{id}/actions/start')
@Middlewares(acl({ resource: 'host', action: 'start', objectId: 'params.id' }))
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
startHost(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
const hostId = id as XoHost['id']
const action = async () => {
await this.getXapiObject(hostId).$xapi.powerOnHost(hostId)
}
return this.createAction<void>(action, {
sync,
statusCode: noContentResp.status,
taskProperties: {
name: 'start host',
objectId: hostId,
},
})
}
/**
* Required privilege:
* - resource: host, action: shutdown:clean
*
* Shutdown a host.
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
* @example body { "bypassBackupCheck": false, "bypassEvacuate": false }
*/
@Example(taskLocation)
@Extension('x-mcp-exposure', 'deny')
@Post('{id}/actions/clean_shutdown')
@Middlewares([json(), acl({ resource: 'host', action: 'shutdown:clean', objectId: 'params.id' })])
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
cleanShutdownHost(
@Path() id: string,
@Body()
body?: {
/** Skip the backup safety check before shutting down. Defaults to false. */
bypassBackupCheck?: boolean
/** Shut down without evacuating running VMs first. Defaults to false. */
bypassEvacuate?: boolean
},
@Query() sync?: boolean
): CreateActionReturnType<void> {
const hostId = id as XoHost['id']
const action = async () => {
await this.#hostService.cleanShutdownHost(hostId, body)
}
return this.createAction<void>(action, {
sync,
statusCode: noContentResp.status,
taskProperties: {
name: 'clean shutdown host',
objectId: hostId,
params: body,
},
})
}
/**
* Required privilege:
* - resource: host, action: reboot:clean
*
* Reboot a host by evacuating its VMs to other hosts first.
*
* Checks for active backup jobs and version compatibility before rebooting.
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
* @example body {
* "force": false,
* "bypassBackupCheck": false,
* "bypassVersionCheck": false
* }
*/
@Example(taskLocation)
@Extension('x-mcp-exposure', 'deny')
@Post('{id}/actions/clean_reboot')
@Middlewares([json(), acl({ resource: 'host', action: 'reboot:clean', objectId: 'params.id' })])
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
cleanRebootHost(
@Path() id: string,
@Body()
body?: {
/** Force the reboot, ignoring evacuation errors. Defaults to false. */
force?: boolean
/** Skip the backup safety check before rebooting. Defaults to false. */
bypassBackupCheck?: boolean
/** Skip the version/upgrade compatibility check before rebooting. Defaults to false. */
bypassVersionCheck?: boolean
},
@Query() sync?: boolean
): CreateActionReturnType<void> {
const force = body?.force ?? false
const opts = {
force,
bypassBackupCheck: body?.bypassBackupCheck ?? force,
bypassVersionCheck: body?.bypassVersionCheck ?? force,
}
const hostId = id as XoHost['id']
const action = async () => {
await this.#hostService.cleanRebootHost(hostId, opts)
}
return this.createAction<void>(action, {
sync,
statusCode: noContentResp.status,
taskProperties: {
name: 'clean reboot host',
objectId: hostId,
params: body,
},
})
}
/**
* Required privilege:
* - resource: host, action: reboot:smart
*
* Reboot a host by suspending its VMs in place.
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
* @example body {
* "bypassBackupCheck": false,
* "bypassVersionCheck": false,
* "bypassBlockedSuspend": false,
* "bypassCurrentVmCheck": false
* }
*/
@Example(taskLocation)
@Extension('x-mcp-exposure', 'deny')
@Post('{id}/actions/smart_reboot')
@Middlewares([json(), acl({ resource: 'host', action: 'reboot:smart', objectId: 'params.id' })])
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(featureUnauthorized.status, featureUnauthorized.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
smartRebootHost(
@Path() id: string,
@Body()
body?: {
/** Skip the backup safety check before rebooting. Defaults to false. */
bypassBackupCheck?: boolean
/** Skip the version/upgrade compatibility check before rebooting. Defaults to false. */
bypassVersionCheck?: boolean
/** Allow suspending VMs even if suspend is blocked. Defaults to false. */
bypassBlockedSuspend?: boolean
/** Skip the check that blocks smart reboot when XOA is running on this host. Defaults to false. */
bypassCurrentVmCheck?: boolean
},
@Query() sync?: boolean
): CreateActionReturnType<void> {
const opts = {
bypassBackupCheck: body?.bypassBackupCheck ?? false,
bypassVersionCheck: body?.bypassVersionCheck ?? false,
bypassBlockedSuspend: body?.bypassBlockedSuspend ?? false,
bypassCurrentVmCheck: body?.bypassCurrentVmCheck ?? false,
}
const hostId = id as XoHost['id']
const action = async () => {
await this.#hostService.smartRebootHost(hostId, opts)
}
return this.createAction<void>(action, {
sync,
statusCode: noContentResp.status,
taskProperties: {
name: 'smart reboot host',
objectId: hostId,
params: body,
},
})
}
/**
* Required privilege:
* - resource: host, action: restart-toolstack
*
* Restart a host's toolstack.
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
*/
@Example(taskLocation)
@Extension('x-mcp-exposure', 'confirm')
@Post('{id}/actions/restart_toolstack')
@Middlewares([json(), acl({ resource: 'host', action: 'restart-toolstack', objectId: 'params.id' })])
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
restartHostToolstack(
@Path() id: string,
@Body()
body?: {
/** Skip the backup safety check before restarting the toolstack. Defaults to false. */
bypassBackupCheck?: boolean
},
@Query() sync?: boolean
): CreateActionReturnType<void> {
const hostId = id as XoHost['id']
const action = async () => {
await this.#hostService.restartToolstack(hostId, body)
}
return this.createAction<void>(action, {
sync,
statusCode: noContentResp.status,
taskProperties: {
name: "restart host's toolstack",
objectId: hostId,
params: body,
},
})
}
/**
* Required privilege:
* - resource: host, action: shutdown:emergency
*
* Shut down a host by disabling it, suspending its VMs in place, and powering off without migrating them.
*
* Unlike `clean_shutdown`, VMs are not evacuated to other hosts, they are suspended
* on the same host (errors are ignored) before the host shuts down.
* No backup check is performed.
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
*/
@Example(taskLocation)
@Extension('x-mcp-exposure', 'deny')
@Post('{id}/actions/emergency_shutdown')
@Middlewares(acl({ resource: 'host', action: 'shutdown:emergency', objectId: 'params.id' }))
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
emergencyShutdownHost(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
const hostId = id as XoHost['id']
const action = async () => {
await this.getXapiObject(hostId).$xapi.emergencyShutdownHost(hostId)
}
return this.createAction<void>(action, {
sync,
statusCode: noContentResp.status,
taskProperties: {
name: 'emergency shutdown host',
objectId: hostId,
},
})
}
/**
* Required privilege:
* - resource: host, action: detach
*
* Detaches a host from its pool.
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
*/
@Example(taskLocation)
@Extension('x-mcp-exposure', 'deny')
@Post('{id}/actions/detach')
@Middlewares(acl({ resource: 'host', action: 'detach', objectId: 'params.id' }))
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
detachHost(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
const hostId = id as XoHost['id']
const action = async () => {
await this.restApi.xoApp.detachHostFromPool(hostId)
}
return this.createAction<void>(action, {
sync,
statusCode: noContentResp.status,
taskProperties: {
name: 'detach host',
objectId: hostId,
},
})
}
/**
* Required privilege:
* - resource: host, action: forget
*
* Forgets a host, host must not be running.
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
*/
@Example(taskLocation)
@Extension('x-mcp-exposure', 'deny')
@Post('{id}/actions/forget')
@Middlewares(acl({ resource: 'host', action: 'forget', objectId: 'params.id' }))
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(incorrectStateResp.status, incorrectStateResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
forgetHost(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
const hostId = id as XoHost['id']
const action = async () => {
const host = this.getObject(hostId)
if (host.power_state === HOST_POWER_STATE.RUNNING) {
throw incorrectState({
actual: host.power_state,
expected: HOST_POWER_STATE.HALTED,
object: host.id,
property: 'power_state',
})
}
await this.getXapiObject(hostId).$xapi.forgetHost(hostId)
}
return this.createAction<void>(action, {
sync,
statusCode: noContentResp.status,
taskProperties: {
name: 'forget host',
objectId: hostId,
},
})
}
}

View File

@@ -1,9 +1,11 @@
import { asyncEach } from '@vates/async-each'
import { createLogger } from '@xen-orchestra/log'
import { HOST_POWER_STATE, XcpPatches, XsPatches, type XoHost } from '@vates/types'
import { HOST_POWER_STATE, Xapi, XcpPatches, XenApiHostWrapped, XoPool, XsPatches, type XoHost } from '@vates/types'
import { incorrectState } from 'xo-common/api-errors.js'
import type { RestApi } from '../rest-api/rest-api.mjs'
import type { MissingPatchesInfo } from './host.type.mjs'
import semver from 'semver'
const log = createLogger('xo:rest-api:host-service')
@@ -91,4 +93,121 @@ export class HostService {
nPoolsWithMissingPatches: poolsWithMissingPatches.size,
}
}
async cleanShutdownHost(
hostId: XoHost['id'],
opts: {
bypassBackupCheck?: boolean
bypassEvacuate?: boolean
} = {}
): Promise<void> {
const host = this.#restApi.getObject<XoHost>(hostId)
if (opts?.bypassBackupCheck) {
log.warn('host clean_shutdown called with argument "bypassBackupCheck" set to true', { hostId })
} else {
await this.#restApi.xoApp.backupGuard(host.$pool)
}
await this.#restApi.getXapiObject(hostId, 'host').$xapi.shutdownHost(hostId, opts)
}
async cleanRebootHost(
hostId: XoHost['id'],
opts: {
force: boolean
bypassBackupCheck: boolean
bypassVersionCheck: boolean
}
): Promise<void> {
const { xapi } = await this.#rebootChecks(hostId, opts)
await xapi.rebootHost(hostId, opts.force)
}
async smartRebootHost(
hostId: XoHost['id'],
opts: {
bypassBackupCheck: boolean
bypassVersionCheck: boolean
bypassBlockedSuspend: boolean
bypassCurrentVmCheck: boolean
}
): Promise<void> {
await this.#restApi.xoApp.checkFeatureAuthorization('SMART_REBOOT')
const { xapi, xapiHost } = await this.#rebootChecks(hostId, opts)
await xapi.host_smartReboot(xapiHost.$ref, opts.bypassBlockedSuspend, opts.bypassCurrentVmCheck)
}
async restartToolstack(
hostId: XoHost['id'],
opts: {
bypassBackupCheck?: boolean
} = {}
): Promise<void> {
const host = this.#restApi.getObject<XoHost>(hostId)
if (opts?.bypassBackupCheck) {
log.warn('host.restartAgent called with argument "bypassBackupCheck" set to true', { hostId })
} else {
await this.#restApi.xoApp.backupGuard(host.$pool)
}
await this.#restApi.getXapiObject<XoHost>(hostId, 'host').$restartAgent()
}
async #rebootChecks(
hostId: XoHost['id'],
opts: {
bypassBackupCheck: boolean
bypassVersionCheck: boolean
}
): Promise<{ xapi: Xapi; xapiHost: XenApiHostWrapped }> {
const host = this.#restApi.getObject<XoHost>(hostId)
const xapiHost = this.#restApi.getXapiObject<XoHost>(hostId, 'host')
const poolId = host.$pool
const xapi = xapiHost.$xapi
if (opts.bypassBackupCheck) {
log.warn('host.reboot called with "bypassBackupCheck" set to true', { hostId })
} else {
await this.#restApi.xoApp.backupGuard(poolId)
}
if (opts.bypassVersionCheck) {
log.warn('host.reboot called with "bypassVersionCheck" set to true', { hostId })
} else {
const pool = this.#restApi.getObject<XoPool>(poolId, 'pool')
const master = this.#restApi.getObject<XoHost>(pool.master, 'host')
if (host.rebootRequired && host.id !== master.id) {
const throwError = () =>
incorrectState({
actual: host.rebootRequired,
expected: false,
object: master.id,
property: 'rebootRequired',
})
if (semver.lt(master.version, host.version)) {
log.error(`master version (${master.version}) is older than the host version (${host.version})`, {
masterId: master.id,
hostId,
})
throwError()
} else if (semver.eq(master.version, host.version)) {
if ((await xapi.listMissingPatches(master.id)).length > 0) {
log.error('master has missing patches', { masterId: master.id })
throwError()
}
if (master.rebootRequired) {
log.error('master needs to reboot', { masterId: master.id })
throwError()
}
}
}
}
return { xapi, xapiHost }
}
}

View File

@@ -13,6 +13,7 @@
- [REST API] Expose `GET /backup-repositories/:id/health` and `POST /backup-repositories/:id/actions/benchmark` routes (PR [#9847](https://github.com/vatesfr/xen-orchestra/pull/9847))
- [sdn-controller] Add `POST /rest/v0/plugins/sdn-controller/networks/:id/actions/update_traffic_rule` and `POST /rest/v0/plugins/sdn-controller/vifs/:id/actions/update_traffic_rule` (PR [#9936](https://github.com/vatesfr/xen-orchestra/pull/9936))
- [REST API] Added start, clean_shutdown, clean_reboot, smart_reboot, restart_toolstack, emergency_shutdown, detach and forget REST action route for hosts (PR [#9887](https://github.com/vatesfr/xen-orchestra/pull/9887))
### Bug fixes

View File

@@ -119,7 +119,7 @@ Actions are written using the exact string you pass in a privilege. A parent act
| `vif` | `connect`, `create`, `delete`, `disconnect`, `read`, `update:allowedIpv4Addresses`, `update:allowedIpv6Addresses`, `update:lockingMode`, `update:rateLimit`, `update:txChecksumming`,`update:other_config` |
| `vbd` | `read`, `create`, `delete`, `connect`, `disconnect` |
| `sr` | `read`, `delete`, `forget`, `migrate-receive`, `reclaim-space`, `scan`, `import:vdi`, `import:vm`, `update:tags` |
| `host` | `read`, `allow-vm`, `join-pool`, `export:logs`,`migrate-receive`, `update:tags`, `disable`, `enable`, `evacuate` |
| `host` | `read`, `allow-vm`, `join-pool`, `export:logs`, `update:tags`, `migrate-receive`, `disable`, `enable`, `evacuate`, `detach`, `shutdown:clean`, `shutdown:emergency`, `forget`, `reboot:clean`, `reboot:smart`, `restart-toolstack`, `start` |
| `pool` | `add-host`, `read`, `emergency-shutdown`, `rolling-reboot`, `rolling-update`, `create:network`, `create:vm`, `update:tags` |
| `network` | `read`, `create`, `delete`, `update:tags`,`update:other_config` |
| `pif` | `read`, `update:management` |

View File

@@ -70,6 +70,7 @@ const AUTHORIZATIONS = {
// features: https://vates.tech/en/pricing-and-support/
const BUNDLE_AUTHORIZATIONS = {
RBAC: [BUNDLE_ESSENTIAL_PLUS, BUNDLE_PRO, BUNDLE_ENTERPRISE],
SMART_REBOOT: [BUNDLE_ESSENTIAL_PLUS, BUNDLE_ENTERPRISE],
}
export default class Authorization {

View File

@@ -13,6 +13,7 @@ import patch from '../../patch.mjs'
import { serializeError } from '../../utils.mjs'
import executeCall from './execute-call.mjs'
import backupGuard from '../../api/_backupGuard.mjs'
// ===================================================================
@@ -371,4 +372,8 @@ export default class Jobs {
delete schedulesByJobId[job.id]
}
}
backupGuard(poolId) {
return backupGuard.call(this._app, poolId)
}
}