mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-09-10 22:14:48 -05:00
feat(rest-api/vm): add PATCH /vms/{id} to partially update a VM (#9835)
This commit is contained in:
@@ -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?: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:<field> (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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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-->
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user