mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
feat(rest-api): add 7 rest actions on hosts (#9887)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user