@xen-orchestra/rest-api 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,13 +7,14 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
- import { Body, Delete, Example, Extension, Get, Middlewares, Path, Post, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags, } from 'tsoa';
10
+ import { Body, Delete, Example, Extension, Get, Middlewares, Patch, Path, Post, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags, } from 'tsoa';
11
11
  import { json } from 'express';
12
12
  import { inject } from 'inversify';
13
13
  import { incorrectState, invalidParameters } from 'xo-common/api-errors.js';
14
14
  import { provide } from 'inversify-binding-decorators';
15
15
  import { PassThrough } from 'node:stream';
16
- import { acl } from '../middlewares/acl.middleware.mjs';
16
+ import { SUPPORTED_ACTIONS_BY_RESOURCE } from '@xen-orchestra/acl';
17
+ import { acl, actionsFromBody } from '../middlewares/acl.middleware.mjs';
17
18
  import { asynchronousActionResp, badRequestResp, createdResp, forbiddenOperationResp, incorrectStateResp, internalServerErrorResp, invalidParameters as invalidParametersResp, noContentResp, notFoundResp, unauthorizedResp, } from '../open-api/common/response.common.mjs';
18
19
  import { BASE_URL } from '../index.mjs';
19
20
  import { limitAndFilterArray, NDJSON_CONTENT_TYPE } from '../helpers/utils.helper.mjs';
@@ -27,7 +28,13 @@ import { BackupJobService } from '../backup-jobs/backup-job.service.mjs';
27
28
  import { partialVmBackupJobs, vmBackupJobIds } from '../open-api/oa-examples/backup-job.oa-example.mjs';
28
29
  import { messageIds, partialMessages } from '../open-api/oa-examples/message.oa-example.mjs';
29
30
  import { Task } from '@vates/task';
31
+ import { vmExportCompressDeprecated } from '../middlewares/deprecated.middleware.mjs';
30
32
  const IGNORED_VDIS_TAG = '[NOSNAP]';
33
+ // `datasources` is managed through the dedicated `/vms/{id}/stats/data_source`
34
+ // endpoints, not as a direct VM property, so it cannot be updated via PATCH /vms.
35
+ const UPDATE_VM_ACTIONS = Object.keys(SUPPORTED_ACTIONS_BY_RESOURCE.vm.update)
36
+ .filter(action => action !== 'datasources')
37
+ .map(k => `update:${k}`);
31
38
  let VmController = class VmController extends XapiXoController {
32
39
  #vmService;
33
40
  #backupJobService;
@@ -74,6 +81,29 @@ let VmController = class VmController extends XapiXoController {
74
81
  getVm(id) {
75
82
  return this.getObject(id);
76
83
  }
84
+ /**
85
+ * Partial update of a VM. Only the fields present in the body are modified;
86
+ * everything else is left untouched.
87
+ *
88
+ * Operations are applied sequentially: if one fails, previously applied
89
+ * changes are not rolled back.
90
+ *
91
+ * Required privilege per field provided in the body:
92
+ * - resource: vm, action: update:<field> (e.g. update:nameLabel, update:cpus, ...)
93
+ *
94
+ * Special fields:
95
+ * - `xenStoreData` keys are automatically prefixed with `vm-data/` when missing
96
+ *
97
+ * @example id "f07ab729-c0e8-721c-45ec-f11276377030"
98
+ * @example body {
99
+ * "nameLabel": "web-prod-01",
100
+ * "nameDescription": "Production web frontend — managed by n8n",
101
+ * "notes": "Docker containers: nginx, app-1, app-2"
102
+ * }
103
+ */
104
+ async updateVm(id, body) {
105
+ await this.#vmService.updateVm(id, body);
106
+ }
77
107
  /**
78
108
  * Required privilege:
79
109
  * - resource: vm, action: delete
@@ -418,6 +448,8 @@ let VmController = class VmController extends XapiXoController {
418
448
  });
419
449
  }
420
450
  /**
451
+ * Required privilege:
452
+ * - resource: vm, action: clone
421
453
  *
422
454
  * - For fast clone on the same SR, omit `srId` and set `fast` to `true`.
423
455
  * - For full copy on the same SR, omit `srId` and set `fast` to `false`.
@@ -601,6 +633,10 @@ let VmController = class VmController extends XapiXoController {
601
633
  }
602
634
  }
603
635
  /**
636
+ * Required privileges:
637
+ * - resource: vm, action: migrate-send
638
+ * - resource: host, action: migrate-receive (on the destination host)
639
+ *
604
640
  * VIF mapping is not allowed for intra-pool migration
605
641
  *
606
642
  * Networks and SRs must belong to the same pool as the destination host
@@ -620,6 +656,7 @@ let VmController = class VmController extends XapiXoController {
620
656
  migrationNetworkId: migrationNetworkId,
621
657
  sr: 'srId' in body ? body.srId : undefined,
622
658
  });
659
+ return;
623
660
  };
624
661
  return this.createAction(action, {
625
662
  sync,
@@ -644,7 +681,7 @@ __decorate([
644
681
  __decorate([
645
682
  Extension('x-mcp-exposure', 'deny'),
646
683
  Get('{id}.{format}'),
647
- Middlewares(acl({ resource: 'vm', action: 'export', objectId: 'params.id' })),
684
+ Middlewares([acl({ resource: 'vm', action: 'export', objectId: 'params.id' }), vmExportCompressDeprecated]),
648
685
  SuccessResponse(200, 'Download started', 'application/octet-stream'),
649
686
  Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
650
687
  Response(notFoundResp.status, notFoundResp.description),
@@ -663,6 +700,24 @@ __decorate([
663
700
  Response(notFoundResp.status, notFoundResp.description),
664
701
  __param(0, Path())
665
702
  ], VmController.prototype, "getVm", null);
703
+ __decorate([
704
+ Patch('{id}'),
705
+ Middlewares([
706
+ json(),
707
+ acl({
708
+ resource: 'vm',
709
+ actions: actionsFromBody(UPDATE_VM_ACTIONS),
710
+ objectId: 'params.id',
711
+ }),
712
+ ]),
713
+ SuccessResponse(noContentResp.status, noContentResp.description),
714
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
715
+ Response(notFoundResp.status, notFoundResp.description),
716
+ Response(invalidParametersResp.status, invalidParametersResp.description),
717
+ Response(internalServerErrorResp.status, internalServerErrorResp.description),
718
+ __param(0, Path()),
719
+ __param(1, Body())
720
+ ], VmController.prototype, "updateVm", null);
666
721
  __decorate([
667
722
  Extension('x-mcp-exposure', 'confirm'),
668
723
  Delete('{id}'),
@@ -872,8 +927,9 @@ __decorate([
872
927
  Example(taskLocation),
873
928
  Extension('x-mcp-exposure', 'confirm'),
874
929
  Post('{id}/actions/clone'),
875
- Middlewares(json()),
930
+ Middlewares([json(), acl({ resource: 'vm', action: 'clone', objectId: 'params.id' })]),
876
931
  SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
932
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
877
933
  Response(createdResp.status, createdResp.description),
878
934
  Response(notFoundResp.status, notFoundResp.description),
879
935
  Response(internalServerErrorResp.status, internalServerErrorResp.description),
@@ -994,8 +1050,17 @@ __decorate([
994
1050
  Example(taskLocation),
995
1051
  Extension('x-mcp-exposure', 'confirm'),
996
1052
  Post('{id}/actions/migrate'),
997
- Middlewares(json()),
1053
+ Middlewares([
1054
+ json(),
1055
+ // Two separate checks allow independent control so a user can be allowed to migrate a VM away
1056
+ // without being allowed to place VMs on any specific host, and vice versa.
1057
+ acl([
1058
+ { resource: 'vm', action: 'migrate-send', objectId: 'params.id' },
1059
+ { resource: 'host', action: 'migrate-receive', objectId: 'body.hostId' },
1060
+ ]),
1061
+ ]),
998
1062
  SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
1063
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
999
1064
  Response(noContentResp.status, noContentResp.description),
1000
1065
  Response(notFoundResp.status, notFoundResp.description),
1001
1066
  Response(invalidParametersResp.status, invalidParametersResp.description),
@@ -120,7 +120,7 @@ export class VmService {
120
120
  }
121
121
  return vdis;
122
122
  }
123
- async export(id, vmType, { compress, format, response }) {
123
+ async export(id, vmType, { compress, format, response, }) {
124
124
  const xapiVm = this.#restApi.getXapiObject(id, vmType);
125
125
  let stream;
126
126
  if (format === 'xva') {
@@ -146,6 +146,23 @@ export class VmService {
146
146
  });
147
147
  return alarms;
148
148
  }
149
+ async updateVm(id, body) {
150
+ const { resourceSet, share, ...editProps } = body;
151
+ // Touch the object so 404 is raised before any side effect.
152
+ void this.#restApi.getObject(id, 'VM');
153
+ const xoApp = this.#restApi.xoApp;
154
+ if (resourceSet !== undefined) {
155
+ await xoApp.setVmResourceSet(id, resourceSet, true);
156
+ }
157
+ else if (share) {
158
+ // `share: false` is a no-op.
159
+ await xoApp.shareVmResourceSet(id);
160
+ }
161
+ if (Object.keys(editProps).length === 0) {
162
+ return;
163
+ }
164
+ await xoApp.getXapi(id).editVm(id, editProps);
165
+ }
149
166
  #getDashboardQuickInfo(id) {
150
167
  const { power_state, uuid, name_description, CPUs, mainIpAddress, os_version, memory, creation, $pool, virtualizationMode, tags, $container, startTime, pvDriversDetected, pvDriversVersion, pvDriversUpToDate, } = this.#restApi.getObject(id, 'VM');
151
168
  return {
@@ -179,23 +196,24 @@ export class VmService {
179
196
  }
180
197
  #getLastReplication(id) {
181
198
  const vm = this.#restApi.getObject(id, 'VM');
182
- const replicatedVms = this.#restApi.getObjectsByType('VM', {
183
- filter: obj => obj.other['xo:backup:vm'] === vm.id,
199
+ const snapshotReplicas = this.#restApi.getObjectsByType('VM-snapshot', {
200
+ filter: obj => obj.other['xo:backup:vm'] === vm.id && obj.$snapshot_of !== vm.id,
184
201
  });
185
202
  let lastTimestamp;
186
- let lastReplica;
187
- for (const id in replicatedVms) {
188
- const replica = replicatedVms[id];
189
- const timestamp = parseDateTime(replica.other['xo:backup:datetime']);
203
+ let lastReplicaId;
204
+ for (const id in snapshotReplicas) {
205
+ const snapshot = snapshotReplicas[id];
206
+ const timestamp = parseDateTime(snapshot.other['xo:backup:datetime']);
190
207
  if (lastTimestamp === undefined || lastTimestamp < timestamp) {
191
208
  lastTimestamp = timestamp;
192
- lastReplica = replica;
209
+ lastReplicaId = snapshot.$snapshot_of;
193
210
  }
194
211
  }
195
- if (lastReplica === undefined) {
212
+ if (lastReplicaId === undefined) {
196
213
  return {};
197
214
  }
198
- const vdis = this.getVmVdis(id, 'VM');
215
+ const replica = this.#restApi.getObject(lastReplicaId, 'VM');
216
+ const vdis = this.getVmVdis(replica.id, 'VM');
199
217
  let sr = undefined;
200
218
  for (const vdi of vdis) {
201
219
  if (sr === undefined) {
@@ -209,7 +227,7 @@ export class VmService {
209
227
  }
210
228
  }
211
229
  return {
212
- id: lastReplica.id,
230
+ id: replica.id,
213
231
  timestamp: lastTimestamp * 1000,
214
232
  sr,
215
233
  };