feat(rest-api/vm): add PATCH /vms/{id} to partially update a VM (#9835)

This commit is contained in:
Mathieu
2026-05-28 10:17:15 +02:00
committed by GitHub
parent 5c555170f7
commit 5089be9998
10 changed files with 248 additions and 32 deletions

View File

@@ -22,6 +22,7 @@ import type {
VBD_TYPE,
VDI_TYPE,
VIF_LOCKING_MODE,
VM_OPERATIONS,
} from '../common.mjs'
import type { PassThrough, Readable } from 'node:stream'
import type {
@@ -40,6 +41,54 @@ import type {
XoVmSnapshot,
} from '../xo.mjs'
/**
* Properties accepted by {@link Xapi.editVm}.
*
* Field names match the canonical (camelCase) properties defined by the
* underlying `_editVm` (via `makeEditObject`). `_editVm` still resolves the
* snake_case and legacy aliases, but the canonical names are used here.
*/
export interface EditVmProps {
affinityHost?: string | null
autoPoweron?: boolean
blockedOperations?: Partial<Record<VM_OPERATIONS, boolean | string | null>>
coresPerSocket?: number | string | null
cpuCap?: number | null
cpuMask?: number[]
cpuWeight?: number | null
cpus?: number
cpusStaticMax?: number | string
/**
* Update VM creation metadata stored under `other_config.xo:*`. The object is
* merged with the existing data.
*/
creation?: { user?: string }
expNestedHvm?: boolean
hasVendorDevice?: boolean
highAvailability?: 'best-effort' | 'restart' | ''
hvmBootFirmware?: string | null
memory?: number | string
memoryMax?: number | string
memoryMin?: number | string
memoryStaticMax?: number | string
nameDescription?: string
nameLabel?: string
nestedVirt?: boolean
nicType?: string | null
notes?: string | null
PV_args?: string
secureBoot?: boolean
startDelay?: number
suspendSr?: string | null
tags?: string[]
uefiMode?: 'setup' | 'user'
vga?: 'std' | 'cirrus'
videoram?: 1 | 2 | 4 | 8 | 16
viridian?: boolean
virtualizationMode?: 'pv' | 'hvm'
xenStoreData?: Record<string, string | null>
}
export type XcpPatches = {
changelog?: {
author: string
@@ -174,6 +223,11 @@ export interface Xapi {
}
): Promise<XenApiVdi['$ref']>
SR_reclaimSpace(ref: XenApiSr['$ref']): Promise<void>
editVm(
id: XoVm['id'],
props: EditVmProps,
checkLimits?: (limits: Record<string, number>, vm: XenApiVmWrapped) => Promise<void>
): Promise<void>
startVm(
id: XoVm['id'],
opts?: {

View File

@@ -298,6 +298,8 @@ export type XoApp = {
): Promise<XoServer>
rollingPoolReboot(pool: XoPool, opts?: { parentTask?: VatesTask }): Promise<void>
rollingPoolUpdate(pool: XoPool, opts?: { rebootVm?: boolean; parentTask?: VatesTask }): Promise<void>
setVmResourceSet(vmId: XoVm['id'], resourceSetId: string | null, force?: boolean): Promise<void>
shareVmResourceSet(vmId: XoVm['id']): Promise<void>
removeUserFromGroup(userId: XoUser['id'], id: XoGroup['id']): Promise<void>
runJob(job: AnyXoJob, schedule: XoSchedule): void
runWithApiContext: (user: XoUser | undefined, fn: () => void) => Promise<unknown>

View File

@@ -18,7 +18,42 @@ export default {
suspend: true,
unpause: true,
update: {
affinityHost: true,
autoPoweron: true,
blockedOperations: true,
coresPerSocket: true,
cpuCap: true,
cpuMask: true,
cpuWeight: true,
cpus: true,
cpusStaticMax: true,
creation: true,
datasources: true,
expNestedHvm: true,
hasVendorDevice: true,
highAvailability: true,
hvmBootFirmware: true,
memory: true,
memoryMax: true,
memoryMin: true,
memoryStaticMax: true,
nameDescription: true,
nameLabel: true,
nestedVirt: true,
nicType: true,
notes: true,
PV_args: true,
resourceSet: true,
secureBoot: true,
share: true,
startDelay: true,
suspendSr: true,
tags: true,
uefiMode: true,
vga: true,
videoram: true,
viridian: true,
virtualizationMode: true,
xenStoreData: true,
},
}

View File

@@ -70,7 +70,7 @@ If an endpoint does not have a middleware ACL, it will be accessible **ONLY** to
It is sometimes necessary to check ACLs based on the body of the request sent by the user (for example, for a PATCH endpoint). For this, you can use `actions` (which allows you to pass multiple actions) and `actionsFromBody` (a function exported from `acl.middleware.mts`).
`actionsFromBody(['update:name_label', 'update:name_description'])` checks if `name_label` is present in the request body, and then applies the ACL check. The same applies to `name_description`.
`actionsFromBody(['update:nameLabel', 'update:nameDescription'])` checks if `nameLabel` is present in the request body, and then applies the ACL check. The same applies to `nameDescription`.
`actionIfNotSelfUser('read')` returns the given action only if the current user is **not** the target user. If the current user is the target (self), no action is returned and the ACL check is skipped entirely.
@@ -128,11 +128,11 @@ It is sometimes necessary to check ACLs based on the body of the request sent by
*
* Required privileges:
* - resource: vm, action: update (grants all fields)
* - resource: vm, action: update:name_label (if name_label is passed)
* - resource: vm, action: update:name_description (if name_description is passed)
* - resource: vm, action: update:nameLabel (if nameLabel is passed)
* - resource: vm, action: update:nameDescription (if nameDescription is passed)
*/
@Patch('{id}')
@Middlewares(acl({resource: 'vm', actions: actionsFromBody(['update:name_label', 'update:name_description']), objectId: 'params.id'}))
@Middlewares(acl({resource: 'vm', actions: actionsFromBody(['update:nameLabel', 'update:nameDescription']), objectId: 'params.id'}))
@Response(403)
createVdi(@Path() id: string, @Body() body: patchBody) {
updateVm(id, body)

View File

@@ -5,6 +5,7 @@ import {
Extension,
Get,
Middlewares,
Patch,
Path,
Post,
Put,
@@ -39,7 +40,9 @@ import type {
} from '@vates/types'
import { PassThrough, Readable } from 'node:stream'
import { acl } from '../middlewares/acl.middleware.mjs'
import { SUPPORTED_ACTIONS_BY_RESOURCE, type SupportedActions } from '@xen-orchestra/acl'
import { acl, actionsFromBody } from '../middlewares/acl.middleware.mjs'
import {
asynchronousActionResp,
badRequestResp,
@@ -66,12 +69,18 @@ import { BackupJobService } from '../backup-jobs/backup-job.service.mjs'
import type { UnbrandXoVmBackupJob } from '../backup-jobs/backup-job.type.mjs'
import { partialVmBackupJobs, vmBackupJobIds } from '../open-api/oa-examples/backup-job.oa-example.mjs'
import { messageIds, partialMessages } from '../open-api/oa-examples/message.oa-example.mjs'
import type { UnbrandedVmDashboard } from './vm.type.mjs'
import type { UnbrandedVmDashboard, UpdateVmRequestBody } from './vm.type.mjs'
import type { CreateActionReturnType } from '../abstract-classes/base-controller.mjs'
import { Task } from '@vates/task'
const IGNORED_VDIS_TAG = '[NOSNAP]'
// `datasources` is managed through the dedicated `/vms/{id}/stats/data_source`
// endpoints, not as a direct VM property, so it cannot be updated via PATCH /vms.
const UPDATE_VM_ACTIONS = Object.keys(SUPPORTED_ACTIONS_BY_RESOURCE.vm.update)
.filter(action => action !== 'datasources')
.map(k => `update:${k}` as SupportedActions<'vm'>)
@Route('vms')
@Security('*')
@Response(badRequestResp.status, badRequestResp.description)
@@ -166,6 +175,44 @@ export class VmController extends XapiXoController<XoVm> {
return this.getObject(id as XoVm['id'])
}
/**
* Partial update of a VM. Only the fields present in the body are modified;
* everything else is left untouched.
*
* Operations are applied sequentially: if one fails, previously applied
* changes are not rolled back.
*
* Required privilege per field provided in the body:
* - resource: vm, action: update:&lt;field&gt; (e.g. update:nameLabel, update:cpus, ...)
*
* Special fields:
* - `xenStoreData` keys are automatically prefixed with `vm-data/` when missing
*
* @example id "f07ab729-c0e8-721c-45ec-f11276377030"
* @example body {
* "nameLabel": "web-prod-01",
* "nameDescription": "Production web frontend — managed by n8n",
* "notes": "Docker containers: nginx, app-1, app-2"
* }
*/
@Patch('{id}')
@Middlewares([
json(),
acl({
resource: 'vm',
actions: actionsFromBody(UPDATE_VM_ACTIONS),
objectId: 'params.id',
}),
])
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(invalidParametersResp.status, invalidParametersResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
async updateVm(@Path() id: string, @Body() body: UpdateVmRequestBody): Promise<void> {
await this.#vmService.updateVm(id as XoVm['id'], body)
}
/**
* Required privilege:
* - resource: vm, action: delete

View File

@@ -31,7 +31,7 @@ import { AlarmService } from '../alarms/alarm.service.mjs'
import { parseDateTime } from '@xen-orchestra/xapi'
import { BackupJobService } from '../backup-jobs/backup-job.service.mjs'
import groupBy from 'lodash/groupBy.js'
import { VmDashboard } from './vm.type.mjs'
import type { UpdateVmRequestBody, VmDashboard } from './vm.type.mjs'
import { BackupLogService } from '../backup-logs/backup-log.service.mjs'
const log = createLogger('xo:rest-api:vm-service')
@@ -217,6 +217,27 @@ export class VmService {
return alarms
}
async updateVm(id: XoVm['id'], body: UpdateVmRequestBody): Promise<void> {
const { resourceSet, share, ...editProps } = body
// Touch the object so 404 is raised before any side effect.
void this.#restApi.getObject<XoVm>(id, 'VM')
const xoApp = this.#restApi.xoApp
if (resourceSet !== undefined) {
await xoApp.setVmResourceSet(id, resourceSet, true)
} else if (share) {
// `share: false` is a no-op.
await xoApp.shareVmResourceSet(id)
}
if (Object.keys(editProps).length === 0) {
return
}
await xoApp.getXapi(id).editVm(id, editProps)
}
#getDashboardQuickInfo(id: XoVm['id']): VmDashboard['quickInfo'] {
const {
power_state,

View File

@@ -1,6 +1,22 @@
import type { XoAlarm, XoHost, XoSr, XoUser, XoVm, XoVmBackupArchive, XoVmBackupJob } from '@vates/types'
import type { EditVmProps, XoAlarm, XoHost, XoSr, XoUser, XoVm, XoVmBackupArchive, XoVmBackupJob } from '@vates/types'
import { Unbrand } from '../open-api/common/response.common.mjs'
/**
* Body of `PATCH /vms/{id}`.
*
* Extends {@link EditVmProps} with two REST-only properties that the xo-server
* resource-set mixin handles outside of `editVm`.
*/
export interface UpdateVmRequestBody extends EditVmProps {
/** Moves the VM in/out of a resource set. */
resourceSet?: string | null
/**
* When `true` and the VM is in a resource set, share the VM with all members
* of that resource set. `false` is a no-op.
*/
share?: boolean
}
type VmDashboardRun = { backupJobId: XoVmBackupJob['id']; timestamp: number; status: string }
type VmDashboardBackupArchive = {
id: XoVmBackupArchive['id']

View File

@@ -15,6 +15,7 @@
- [VM] Add possibility to create a VDI on tab VDI (PR [#9848](https://github.com/vatesfr/xen-orchestra/pull/9848))
- [VIF] Add a new "General" tab to the VIF page (PR [#9831](https://github.com/vatesfr/xen-orchestra/pull/9831))
- [i18n] Update Chinese (Simplified Han script), Czech, Dutch, German, Korean, Slovak, Spanish and Swedish translations (PR [#9780](https://github.com/vatesfr/xen-orchestra/pull/9780))
- [REST API] Add `PATCH /vms/{id}` to partially update a VM (PR [#9835](https://github.com/vatesfr/xen-orchestra/pull/9835))
### Bug fixes
@@ -37,7 +38,10 @@
<!--packages-start-->
- @vates/types minor
- @xen-orchestra/acl minor
- @xen-orchestra/rest-api minor
- @xen-orchestra/web minor
- @xen-orchestra/web-core minor
- xo-server minor
<!--packages-end-->

View File

@@ -103,30 +103,30 @@ Actions are written using the exact string you pass in a privilege. A parent act
### Infrastructure resources
| Resource | Available actions |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `vm` | `read`, `delete`, `export`, `pause`, `start`, `resume`, `revert-snapshot`, `snapshot`, `suspend`, `unpause`, `reboot:clean`, `reboot:hard`, `shutdown:clean`, `shutdown:hard`, `update:datasources`, `update:tags` |
| `vm-snapshot` | `read`, `delete`, `export`, `update:tags` |
| `vm-template` | `read`, `delete`, `export`, `instantiate`, `update:tags` |
| `vm-controller` | `read`, `update:tags` |
| `vdi` | `read`, `create`, `delete`, `boot`, `export-content`, `import-content`, `update:tags` |
| `vdi-snapshot` | `read` |
| `vdi-unmanaged` | `read` |
| `vif` | `read`, `create` |
| `vbd` | `read` |
| `sr` | `read`, `delete`, `import:vdi`, `import:vm`, `update:tags` |
| `host` | `read`, `allow-vm`, `export:logs`, `update:tags`, `disable`, `enable`, `evacuate` |
| `pool` | `read`, `emergency-shutdown`, `rolling-reboot`, `rolling-update`, `create:network`, `create:vm`, `update:tags` |
| `network` | `read`, `create`, `delete`, `update:tags` |
| `pif` | `read`, `update:management` |
| `pbd` | `read` |
| `pci` | `read` |
| `pgpu` | `read` |
| `vgpu` | `read` |
| `vgpuType` | `read` |
| `vtpm` | `read` |
| `sm` | `read` |
| `gpuGroup` | `read` |
| Resource | Available actions |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `vm` | `read`, `delete`, `export`, `pause`, `start`, `resume`, `revert-snapshot`, `snapshot`, `suspend`, `unpause`, `reboot:clean`, `reboot:hard`, `shutdown:clean`, `shutdown:hard`, `update:affinityHost`, `update:autoPoweron`, `update:blockedOperations`, `update:coresPerSocket`, `update:cpuCap`, `update:cpuMask`, `update:cpuWeight`, `update:cpus`, `update:cpusStaticMax`, `update:creation`, `update:datasources`, `update:expNestedHvm`, `update:hasVendorDevice`, `update:highAvailability`, `update:hvmBootFirmware`, `update:memory`, `update:memoryMax`, `update:memoryMin`, `update:memoryStaticMax`, `update:nameDescription`, `update:nameLabel`, `update:nestedVirt`, `update:nicType`, `update:notes`, `update:PV_args`, `update:resourceSet`, `update:secureBoot`, `update:share`, `update:startDelay`, `update:suspendSr`, `update:tags`, `update:uefiMode`, `update:vga`, `update:videoram`, `update:viridian`, `update:virtualizationMode`, `update:xenStoreData` |
| `vm-snapshot` | `read`, `delete`, `export`, `update:tags` |
| `vm-template` | `read`, `delete`, `export`, `instantiate`, `update:tags` |
| `vm-controller` | `read`, `update:tags` |
| `vdi` | `read`, `create`, `delete`, `boot`, `export-content`, `import-content`, `update:tags` |
| `vdi-snapshot` | `read` |
| `vdi-unmanaged` | `read` |
| `vif` | `read`, `create` |
| `vbd` | `read` |
| `sr` | `read`, `delete`, `import:vdi`, `import:vm`, `update:tags` |
| `host` | `read`, `allow-vm`, `export:logs`, `update:tags`, `disable`, `enable`, `evacuate` |
| `pool` | `read`, `emergency-shutdown`, `rolling-reboot`, `rolling-update`, `create:network`, `create:vm`, `update:tags` |
| `network` | `read`, `create`, `delete`, `update:tags` |
| `pif` | `read`, `update:management` |
| `pbd` | `read` |
| `pci` | `read` |
| `pgpu` | `read` |
| `vgpu` | `read` |
| `vgpuType` | `read` |
| `vtpm` | `read` |
| `sm` | `read` |
| `gpuGroup` | `read` |
### XO management resources

View File

@@ -13,6 +13,7 @@ import { createLogger } from '@xen-orchestra/log'
import { decorateObject } from '@vates/decorate-with'
import { defer as deferrable } from 'golike-defer'
import { ignoreErrors, pCatch } from 'promise-toolbox'
import { invalidParameters } from 'xo-common/api-errors.js'
import { Ref } from 'xen-api'
import { parseSize } from '../../utils.mjs'
@@ -542,6 +543,42 @@ const methods = {
vm.update_platform('device-model', 'qemu-upstream-' + (firmware === 'uefi' ? 'uefi' : 'compat')),
]),
},
suspendSr: {
get: 'suspend_SR',
set(value, vm) {
return this.call('VM.set_suspend_SR', vm.$ref, value === null ? Ref.EMPTY : this.getObject(value).$ref)
},
},
uefiMode: {
set(value, vm) {
return this.call('VM.set_uefi_mode', vm.$ref, value)
},
},
xenStoreData: {
set(value, vm) {
const prefixed = {}
for (const [key, val] of Object.entries(value)) {
// Sandbox the xenstore namespace: strip any client-supplied `vm-data/` prefix
// and reject path-traversal segments before re-applying the prefix.
const stripped = key.replace(/^(vm-data\/)+/, '')
if (stripped === '' || stripped.includes('..') || stripped.startsWith('/')) {
throw invalidParameters(`invalid xenstore key: ${key}`)
}
prefixed['vm-data/' + stripped] = val
}
return vm.update_xenstore_data(prefixed)
},
},
creation: {
set(value, vm) {
const existing = xoData.extract(vm)
return xoData.set(vm, { creation: { ...existing?.creation, ...value } })
},
},
}),
async editVm(id, props, checkLimits) {