feat(rest-api): expose vms/:id/actions/migrate (#9414)

This commit is contained in:
Mathieu
2026-02-11 09:55:51 +01:00
committed by GitHub
parent 7399a9fec5
commit f203b1f1e1
4 changed files with 181 additions and 15 deletions

View File

@@ -87,6 +87,19 @@ export interface Xapi {
deleteNetwork(id: XoNetwork['id']): Promise<void>
deleteVif(vifId: XoVif['id']): Promise<void>
exportVmOva(vmRef: XenApiVm['$ref']): Promise<PassThrough>
migrateVm(
vmId: XoVm['id'],
hostXapi: Xapi,
hostId: XoHost['id'],
opts?: {
force?: boolean
mapVdisSrs?: Record<XoVdi['id'], XoSr['id']>
mapVifsNetworks?: Record<XoVif['id'], XoNetwork['id']>
migrationNetworkId?: XoNetwork['id']
sr?: XoSr['id']
bypassAssert?: boolean
}
): Promise<void>
listMissingPatches(host: XoHost['id']): Promise<XcpPatches[] | XsPatches[]>
pool_emergencyShutdown(): Promise<void>
resumeVm(id: XoVm['id']): Promise<void>

View File

@@ -31,6 +31,10 @@ import type {
XoVm,
XoVmSnapshot,
XoMessage,
XoSr,
XoNetwork,
XoVif,
XoPif,
} from '@vates/types'
import { PassThrough, Readable } from 'node:stream'
@@ -41,6 +45,7 @@ import {
forbiddenOperationResp,
incorrectStateResp,
internalServerErrorResp,
invalidParameters as invalidParametersResp,
noContentResp,
notFoundResp,
unauthorizedResp,
@@ -690,4 +695,57 @@ export class VmController extends XapiXoController<XoVm> {
stream?.end()
}
}
/**
* VIF mapping is not allowed for intra-pool migration
*
* Networks and SRs must belong to the same pool as the destination host
*
* @example id "613f541c-4bed-fc77-7ca8-2db6b68f079c"
* @example body { "hostId": "b61a5c92-700e-4966-a13b-00633f03eea8" }
*/
@Example(taskLocation)
@Post('{id}/actions/migrate')
@Middlewares(json())
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(invalidParametersResp.status, invalidParametersResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
migrateVm(
@Path() id: string,
@Body()
body:
| { hostId: string; migrationNetworkId?: string }
| {
hostId: string
migrationNetworkId?: string
srId?: string
srIdByVdiId?: { [key: string]: string }
networkIdByVifId?: { [key: string]: string }
},
@Query() sync?: boolean
): CreateActionReturnType<void> {
const vmId = id as XoVm['id']
const hostId = body.hostId as XoHost['id']
const action = async () => {
const { migrationNetworkId } = body
const xapiVm = this.getXapiObject(vmId)
await xapiVm.$xapi.migrateVm(vmId, this.getXapi(hostId), hostId, {
mapVdisSrs: 'srIdByVdiId' in body ? (body.srIdByVdiId as Record<XoVdi['id'], XoSr['id']>) : undefined,
mapVifsNetworks:
'networkIdByVifId' in body ? (body.networkIdByVifId as Record<XoVif['id'], XoNetwork['id']>) : undefined,
migrationNetworkId: migrationNetworkId as XoNetwork['id'] | undefined,
sr: 'srId' in body ? (body.srId as XoSr['id']) : undefined,
})
}
return this.createAction(action, {
sync,
statusCode: 204,
taskProperties: { name: 'Migrate VM', objectId: vmId, params: body },
})
}
}

View File

@@ -17,6 +17,7 @@
- [Warm Migration] the api call now return the new VM uuid (PR [#94653](https://github.com/vatesfr/xen-orchestra/pull/9465))
- [Warm Migration] stopped VM can be warm migrated (PR [#94653](https://github.com/vatesfr/xen-orchestra/pull/9465))
- [Plugins/load balancer] Add configurable VM migration cooldown to prevent oscillation (default 30min) (PR [#9388](https://github.com/vatesfr/xen-orchestra/pull/9388))
- [REST API] Expose `POST /rest/v0/vms/:id/actions/migrate` to migrate a VM (PR [#9414](https://github.com/vatesfr/xen-orchestra/pull/9414))
### Bug fixes
@@ -38,6 +39,7 @@
<!--packages-start-->
- @vates/types minor
- @xen-orchestra/fs patch
- @xen-orchestra/rest-api minor
- @xen-orchestra/web patch

View File

@@ -1,5 +1,10 @@
/* eslint eslint-comments/disable-enable-pair: [error, {allowWholeFile: true}] */
/* eslint-disable camelcase */
/**
* @import {XenApiNetworkWrapped, XenApiHostWrapped, XenApiSrWrapped } from "@vates/types"
*/
import fatfs from '@vates/fatfs'
import asyncMapSettled from '@xen-orchestra/async-map/legacy.js'
import filter from 'lodash/filter.js'
@@ -30,6 +35,7 @@ import { forbiddenOperation, operationFailed } from 'xo-common/api-errors.js'
import { parseDateTime, Xapi as XapiBase, XapiDiskSource } from '@xen-orchestra/xapi'
import { Ref } from 'xen-api'
import { synchronized } from 'decorator-synchronized'
import { Task } from '@xen-orchestra/mixins/Tasks.mjs'
import fatfsBuffer, { addMbr, init as fatfsBufferInit } from '../fatfs-buffer.mjs'
import { camelToSnakeCase, forEach, map, pDelay, promisifyAll } from '../utils.mjs'
@@ -512,7 +518,14 @@ export default class Xapi extends XapiBase {
bypassAssert = false,
}
) {
const srRef = sr !== undefined ? hostXapi.getObject(sr).$ref : undefined
Task.info('migration with storage motion')
const targetSr = sr !== undefined ? hostXapi.getObject(sr) : undefined
if (targetSr !== undefined) {
this.#vmMigrationSrPreCheck(targetSr, host)
}
const srRef = targetSr?.$ref
const getDefaultSrRef = once(() => {
const defaultSr = host.$pool.$default_SR
if (defaultSr === undefined) {
@@ -571,14 +584,23 @@ export default class Xapi extends XapiBase {
if (vdi.is_a_snapshot && vdi.$snapshot_of !== undefined) {
continue
}
vdis[vdi.$ref] = getMigrationSrRef(vdi)
const migrationSrRef = getMigrationSrRef(vdi)
this.#vmMigrationSrPreCheck(hostXapi.getObject(migrationSrRef), host)
vdis[vdi.$ref] = migrationSrRef
}
}
// VIFs/Networks mapping
const vifsMap = {}
if (vm.$pool === host.$pool && mapVifsNetworks !== undefined && !isEmpty(mapVifsNetworks)) {
// VIF mapping not allowed in intra-pool migration
Task.info('VIF mapping is ignored because this is an intra-pool migration')
}
if (vm.$pool !== host.$pool) {
const defaultNetworkRef = find(host.$PIFs, pif => pif.management).$network.$ref
const defaultNetwork = find(host.$PIFs, pif => pif.management).$network
// Add snapshots' VIFs which VM has no VIFs on these devices
const vmVifs = vm.$VIFs
const vifDevices = new Set(vmVifs.map(_ => _.device))
@@ -586,10 +608,10 @@ export default class Xapi extends XapiBase {
.filter(vif => !vifDevices.has(vif.device))
.concat(vmVifs)
for (const vif of vifs) {
vifsMap[vif.$ref] =
mapVifsNetworks && mapVifsNetworks[vif.$id]
? hostXapi.getObject(mapVifsNetworks[vif.$id]).$ref
: defaultNetworkRef
const migrationNetwork =
mapVifsNetworks && mapVifsNetworks[vif.$id] ? hostXapi.getObject(mapVifsNetworks[vif.$id]) : defaultNetwork
this.#vmMigrationNetworkPreCheck(migrationNetwork, host)
vifsMap[vif.$ref] = migrationNetwork.$ref
}
}
@@ -845,6 +867,68 @@ export default class Xapi extends XapiBase {
throw new Error(`unsupported type: '${type}'`)
}
/**
* @param {XenApiNetworkWrapped} network
* @param {XenApiHostWrapped} host
*/
#vmMigrationNetworkPreCheck(network, host) {
const targetXapi = host.$xapi
let targetPif
network.PIFs.forEach(pifRef => {
const pif = targetXapi.getObject(pifRef)
if (pif.host === host.$ref) {
targetPif = pif
}
})
if (targetPif === undefined) {
Task.warning('host have no PIF in the network', {
host: host.uuid,
network: network.uuid,
})
return
}
if (!targetPif.attached) {
Task.warning('PIF is not attached', {
pif: targetPif.uuid,
})
}
}
/**
* @param {XenApiSrWrapped} sr
* @param {XenApiHostWrapped} host
*/
#vmMigrationSrPreCheck(sr, host) {
const targetXapi = host.$xapi
let targetPbd
if (!sr.shared) {
sr.PBDs.forEach(pbdRef => {
const pbd = targetXapi.getObject(pbdRef)
if (pbd.host === host.$ref) {
targetPbd = pbd
}
})
}
if (targetPbd === undefined) {
Task.warning('host have no PBD in the SR', {
host: host.uuid,
sr: sr.uuid,
})
return
}
if (!targetPbd.currently_attached) {
Task.warning('PBD is not attached', {
pbd: targetPbd.uuid,
})
}
}
async migrateVm(
vmId,
hostXapi,
@@ -853,18 +937,26 @@ export default class Xapi extends XapiBase {
) {
const vm = this.getObject(vmId)
const host = hostXapi.getObject(hostId)
const targetPool = hostXapi.getObject(host.$pool)
const accrossPools = vm.$pool !== host.$pool
const useStorageMotion =
accrossPools ||
sr !== undefined ||
migrationNetworkId !== undefined ||
!isEmpty(mapVifsNetworks) ||
!isEmpty(mapVdisSrs)
const useStorageMotion = accrossPools || sr !== undefined || !isEmpty(mapVifsNetworks) || !isEmpty(mapVdisSrs)
const defaultMigrationNetworkId = targetPool.other_config['xo:migrationNetwork']
const migrationNetwork =
migrationNetworkId !== undefined
? hostXapi.getObject(migrationNetworkId)
: defaultMigrationNetworkId !== undefined
? hostXapi.getObject(defaultMigrationNetworkId)
: undefined
if (migrationNetwork !== undefined) {
this.#vmMigrationNetworkPreCheck(migrationNetwork, host)
}
if (useStorageMotion) {
await this._migrateVmWithStorageMotion(vm, hostXapi, host, {
migrationNetwork: migrationNetworkId && hostXapi.getObject(migrationNetworkId),
migrationNetwork,
sr,
mapVdisSrs,
mapVifsNetworks,
@@ -875,6 +967,7 @@ export default class Xapi extends XapiBase {
try {
await this.callAsync('VM.pool_migrate', vm.$ref, host.$ref, {
force: force ? 'true' : 'false',
network: migrationNetwork?.$ref,
})
} catch (error) {
if (error.code !== 'VM_REQUIRES_SR') {
@@ -882,7 +975,7 @@ export default class Xapi extends XapiBase {
}
// Retry using motion storage.
await this._migrateVmWithStorageMotion(vm, hostXapi, host, { force })
await this._migrateVmWithStorageMotion(vm, hostXapi, host, { force, migrationNetwork })
}
}
}