@xen-orchestra/rest-api 0.33.0 → 0.35.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.
package/README.md CHANGED
@@ -22,6 +22,29 @@ npm install --save @xen-orchestra/rest-api
22
22
 
23
23
  The REST API is based on the `TSOA` framework and therefore we use decorators a lot to define the behavior of a route or a group of routes. To keep things easily visible, it is best to always use the decorators in the same order.
24
24
 
25
+ At the request of the DevOps team, any REST API PR that updates the OpenAPI specification must include a reviewer from the DevOps team. (Non-blocking for merge.)
26
+
27
+ ### Resource consistency
28
+
29
+ For a given resource (VMs, VIFs, users, etc.), property names must be consistent across all endpoints.
30
+
31
+ > Example: a PATCH request on a VM must use the same property names as the VM representation returned by the REST API.
32
+
33
+ ```
34
+ GET /vms/:id
35
+ {
36
+ name_label: 'Foo',
37
+ name_description: 'Foo Bar',
38
+ ...
39
+ }
40
+
41
+ PATCH /vms/:id
42
+ {
43
+ name_label: 'Bar' // OK ✅
44
+ nameDescription: 'Bar Foo' // Not OK ❌
45
+ }
46
+ ```
47
+
25
48
  ### Class decorator
26
49
 
27
50
  ```ts
@@ -46,6 +69,7 @@ class Foo extends Controller {
46
69
  * @example id 1234
47
70
  */
48
71
  @Example(['foo', 'bar'])
72
+ @Extension('x-mcp-exposure', 'allow')
49
73
  @Get('{id}')
50
74
  @Security('*')
51
75
  @Middlewares(json())
@@ -162,6 +186,14 @@ getUser(@Path() id: string) { ... }
162
186
 
163
187
  If you need to use a privilege that doesn't exist yet (e.g., `resource: 'vm', action: 'foo'`), you must register it in ACL Definition: here `@xen-orchestra/acl/src/actions/vm.mts`, add: `foo: true`.
164
188
 
189
+ ### MCP exposure
190
+
191
+ All REST API endpoints must define an MCP exposure policy using the `@Extension` decorator.
192
+
193
+ - `@Extension('x-mcp-exposure', 'allow')` for all `GET` endpoints.
194
+ - `@Extension('x-mcp-exposure', 'confirm')` for all non-`GET` endpoints (`POST`, `PATCH`, `PUT`, `DELETE`, etc.).
195
+ - `@Extension('x-mcp-exposure', 'deny')` only for exceptional cases.
196
+
165
197
  ## Contributions
166
198
 
167
199
  Contributions are _very_ welcomed, either on the documentation or on
@@ -45,6 +45,10 @@ export class BackupJobService {
45
45
  if (schedule.enabled) {
46
46
  return true;
47
47
  }
48
+ const scheduleSequence = await this.#restApi.xoApp.findEnabledScheduleSequenceFromSchedule(schedule.id);
49
+ if (scheduleSequence !== undefined) {
50
+ return true;
51
+ }
48
52
  }
49
53
  catch (error) {
50
54
  if (!noSuchObject.is(error, { id: maybeScheduleId, type: 'schedule' })) {
@@ -7,17 +7,24 @@ 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 { Example, Extension, Get, Middlewares, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa';
10
+ import { Body, Example, Extension, Get, Middlewares, Patch, Path, Post, Query, Request, Response, Route, Security, SuccessResponse, Tags, } from 'tsoa';
11
11
  import { inject } from 'inversify';
12
12
  import { provide } from 'inversify-binding-decorators';
13
- import { acl } from '../middlewares/acl.middleware.mjs';
14
- import { badRequestResp, forbiddenOperationResp, notFoundResp, unauthorizedResp, } from '../open-api/common/response.common.mjs';
15
- import { backupRepositoryIds, partialBackupRepositories, backupRepository, } from '../open-api/oa-examples/backup-repository.oa-example.mjs';
13
+ import { json } from 'express';
14
+ import { forbiddenOperation } from 'xo-common/api-errors.js';
15
+ import { acl, actionsFromBody } from '../middlewares/acl.middleware.mjs';
16
+ import { asynchronousActionResp, badRequestResp, createdResp, forbiddenOperationResp, invalidParameters, noContentResp, internalServerErrorResp, notFoundResp, unauthorizedResp, } from '../open-api/common/response.common.mjs';
17
+ import { backupRepositoryIds, partialBackupRepositories, backupRepository, backupRepositoryId, backupRepositoryBenchmark, backupRepositoryHeath, } from '../open-api/oa-examples/backup-repository.oa-example.mjs';
16
18
  import { XoController } from '../abstract-classes/xo-controller.mjs';
17
19
  import { RestApi } from '../rest-api/rest-api.mjs';
20
+ import { BackupRepositoryService } from './backup-repository.service.mjs';
21
+ import { taskLocation } from '../open-api/oa-examples/task.oa-example.mjs';
22
+ import { ApiError } from '../helpers/error.helper.mjs';
18
23
  let BackupRepositoryController = class BackupRepositoryController extends XoController {
19
- constructor(restApi) {
24
+ #backupRepositoryService;
25
+ constructor(restApi, backupRepositoryService) {
20
26
  super('backup-repository', restApi);
27
+ this.#backupRepositoryService = backupRepositoryService;
21
28
  }
22
29
  // --- abstract methods
23
30
  getAllCollectionObjects() {
@@ -40,6 +47,16 @@ let BackupRepositoryController = class BackupRepositoryController extends XoCont
40
47
  privilege: { action: 'read', resource: 'backup-repository' },
41
48
  });
42
49
  }
50
+ /**
51
+ * Required privilege:
52
+ * - resource: backup-repository, action: create
53
+ *
54
+ * @example body { "name": "NFS Remote", "options": "vers=4", "proxy": "722d17b9-699b-59d2-8193-be1ac573d3de", "url": "nfs://192.168.100.225:/media/nfs" }
55
+ */
56
+ async createBackupRepository(body) {
57
+ const backupRepository = await this.restApi.xoApp.createRemote(body);
58
+ return { id: backupRepository.id };
59
+ }
43
60
  /**
44
61
  * Required privilege:
45
62
  * - resource: backup-repository, action: read
@@ -49,6 +66,93 @@ let BackupRepositoryController = class BackupRepositoryController extends XoCont
49
66
  getRepository(id) {
50
67
  return this.getObject(id);
51
68
  }
69
+ /**
70
+ * Forgets a backup repository configuration.
71
+ *
72
+ * A backup repository cannot be forgotten if it is referenced by any backup job (enabled or disabled).
73
+ *
74
+ * Required privilege:
75
+ * - resource: backup-repository, action: forget
76
+ *
77
+ * @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
78
+ */
79
+ async forgetBackupRepository(id, sync) {
80
+ const repositoryId = id;
81
+ const action = async () => {
82
+ const referencingJobs = await this.#backupRepositoryService.getReferencingJobs(repositoryId);
83
+ if (referencingJobs.length > 0) {
84
+ throw forbiddenOperation('forget backup repository', `repository is referenced by ${referencingJobs.length} backup job(s): ${referencingJobs.join(', ')}`);
85
+ }
86
+ await this.restApi.xoApp.removeRemote(repositoryId);
87
+ };
88
+ return this.createAction(action, {
89
+ sync,
90
+ statusCode: noContentResp.status,
91
+ taskProperties: {
92
+ name: 'forget backup repository',
93
+ objectId: repositoryId,
94
+ },
95
+ });
96
+ }
97
+ /** Required privileges:
98
+ * - resource: backup-repository, action: update (grants all fields)
99
+ * - resource: backup-repository, action: update:enabled (if enabled is passed)
100
+ * - resource: backup-repository, action: update:name (if name is passed)
101
+ * - resource: backup-repository, action: update:options (if options is passed)
102
+ * - resource: backup-repository, action: update:proxy (if proxy is passed)
103
+ * - resource: backup-repository, action: update:url (if url is passed)
104
+ *
105
+ * @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
106
+ * @example body { "enabled": true, "name": "NFS Remote", "options": "vers=4", "proxy": "722d17b9-699b-59d2-8193-be1ac573d3de", "url": "nfs://192.168.100.225:/media/nfs" }
107
+ */
108
+ async updateBackupRepository(id, body) {
109
+ await this.restApi.xoApp.updateRemote(id, body);
110
+ }
111
+ /**
112
+ * Pings the backup-repository to check its health
113
+ *
114
+ * Required privilege:
115
+ * - resource: backup-repository, action: read
116
+ *
117
+ * @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
118
+ */
119
+ async getBackupRepositoryHealth(id) {
120
+ return this.restApi.xoApp.pingRemote(id);
121
+ }
122
+ /**
123
+ * Runs a benchmark for write and read speed on the Backup-repository.
124
+ * Saves the benchmark result on the BR and returns the results, speeds are in bytes/sec.
125
+ * 502 if the BR cannot be reached, 400 if there was a problem during the benchmark with the error.
126
+ *
127
+ * Required privilege:
128
+ * - resource: backup-repository, action: benchmark
129
+ *
130
+ * @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
131
+ */
132
+ benchmarkBackupRepository(id, sync) {
133
+ const backupRepositoryId = id;
134
+ const action = async () => {
135
+ let result;
136
+ try {
137
+ result = await this.restApi.xoApp.testRemote(backupRepositoryId);
138
+ }
139
+ catch (error) {
140
+ throw new ApiError('Backup repository unreachable', 502);
141
+ }
142
+ if (!result.success) {
143
+ throw new ApiError('Benchmark failed', 400, { data: result });
144
+ }
145
+ return result;
146
+ };
147
+ return this.createAction(action, {
148
+ sync,
149
+ statusCode: 200,
150
+ taskProperties: {
151
+ name: 'benchmark backup repository',
152
+ objectId: backupRepositoryId,
153
+ },
154
+ });
155
+ }
52
156
  };
53
157
  __decorate([
54
158
  Example(backupRepositoryIds),
@@ -63,6 +167,22 @@ __decorate([
63
167
  __param(4, Query()),
64
168
  __param(5, Query())
65
169
  ], BackupRepositoryController.prototype, "getRepositories", null);
170
+ __decorate([
171
+ Example(backupRepositoryId),
172
+ Post(''),
173
+ Middlewares([
174
+ json(),
175
+ acl({
176
+ resource: 'backup-repository',
177
+ action: 'create',
178
+ object: ({ req }) => req.body,
179
+ }),
180
+ ]),
181
+ SuccessResponse(createdResp.status, createdResp.description),
182
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
183
+ Response(invalidParameters.status, invalidParameters.description),
184
+ __param(0, Body())
185
+ ], BackupRepositoryController.prototype, "createBackupRepository", null);
66
186
  __decorate([
67
187
  Example(backupRepository),
68
188
  Extension('x-mcp-exposure', 'allow'),
@@ -77,6 +197,76 @@ __decorate([
77
197
  Response(notFoundResp.status, notFoundResp.description),
78
198
  __param(0, Path())
79
199
  ], BackupRepositoryController.prototype, "getRepository", null);
200
+ __decorate([
201
+ Example(taskLocation),
202
+ Extension('x-mcp-exposure', 'confirm'),
203
+ Post('{id}/actions/forget'),
204
+ Middlewares(acl({
205
+ resource: 'backup-repository',
206
+ action: 'forget',
207
+ objectId: 'params.id',
208
+ getObject: ({ restApi }) => restApi.xoApp.getRemote,
209
+ })),
210
+ SuccessResponse(noContentResp.status, noContentResp.description),
211
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
212
+ Response(notFoundResp.status, notFoundResp.description),
213
+ __param(0, Path()),
214
+ __param(1, Query())
215
+ ], BackupRepositoryController.prototype, "forgetBackupRepository", null);
216
+ __decorate([
217
+ Patch('{id}'),
218
+ Middlewares([
219
+ json(),
220
+ acl({
221
+ resource: 'backup-repository',
222
+ actions: actionsFromBody(['update:enabled', 'update:name', 'update:options', 'update:proxy', 'update:url']),
223
+ objectId: 'params.id',
224
+ getObject: ({ restApi }) => restApi.xoApp.getRemote,
225
+ }),
226
+ ]),
227
+ SuccessResponse(noContentResp.status, noContentResp.description),
228
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
229
+ Response(notFoundResp.status, notFoundResp.description),
230
+ Response(invalidParameters.status, invalidParameters.description),
231
+ __param(0, Path()),
232
+ __param(1, Body())
233
+ ], BackupRepositoryController.prototype, "updateBackupRepository", null);
234
+ __decorate([
235
+ Example(backupRepositoryHeath),
236
+ Extension('x-mcp-exposure', 'allow'),
237
+ Get('{id}/health'),
238
+ Middlewares(acl({
239
+ resource: 'backup-repository',
240
+ action: 'read',
241
+ objectId: 'params.id',
242
+ getObject: ({ restApi }) => restApi.xoApp.getRemote,
243
+ })),
244
+ SuccessResponse(200, 'OK'),
245
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
246
+ Response(internalServerErrorResp.status, internalServerErrorResp.description),
247
+ __param(0, Path())
248
+ ], BackupRepositoryController.prototype, "getBackupRepositoryHealth", null);
249
+ __decorate([
250
+ Example(taskLocation),
251
+ Example(backupRepositoryBenchmark),
252
+ Extension('x-mcp-exposure', 'confirm'),
253
+ Post('{id}/actions/benchmark'),
254
+ Middlewares(acl({
255
+ resource: 'backup-repository',
256
+ action: 'benchmark',
257
+ objectId: 'params.id',
258
+ getObject: ({ restApi }) => restApi.xoApp.getRemote,
259
+ })),
260
+ SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
261
+ Response(200, 'Ok'),
262
+ Response(400, 'Benchmark failed'),
263
+ Response(notFoundResp.status, notFoundResp.description),
264
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
265
+ Response(internalServerErrorResp.status, internalServerErrorResp.description),
266
+ Response(502, 'Backup repository unreachable'),
267
+ __param(0, Path()),
268
+ __param(1, Query())
269
+ ], BackupRepositoryController.prototype, "benchmarkBackupRepository", null);
80
270
  BackupRepositoryController = __decorate([
81
271
  Route('backup-repositories'),
82
272
  Security('*'),
@@ -84,6 +274,7 @@ BackupRepositoryController = __decorate([
84
274
  Response(unauthorizedResp.status, unauthorizedResp.description),
85
275
  Tags('backup-repositories'),
86
276
  provide(BackupRepositoryController),
87
- __param(0, inject(RestApi))
277
+ __param(0, inject(RestApi)),
278
+ __param(1, inject(BackupRepositoryService))
88
279
  ], BackupRepositoryController);
89
280
  export { BackupRepositoryController };
@@ -0,0 +1,46 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
+ return function (target, key) { decorator(target, key, paramIndex); }
9
+ };
10
+ import { RestApi } from '../rest-api/rest-api.mjs';
11
+ import { inject } from 'inversify';
12
+ let BackupRepositoryService = class BackupRepositoryService {
13
+ #restApi;
14
+ constructor(restApi) {
15
+ this.#restApi = restApi;
16
+ }
17
+ isBackupRepositoryReferenced(idsToCheck, repositoryId) {
18
+ if (idsToCheck === undefined) {
19
+ return false;
20
+ }
21
+ const { id } = idsToCheck;
22
+ const ids = typeof id === 'string' ? [id] : id.__or;
23
+ return ids.includes(repositoryId);
24
+ }
25
+ async getReferencingJobs(repositoryId) {
26
+ const allJobs = await this.#restApi.xoApp.getAllJobs();
27
+ const referencingJobs = [];
28
+ for (const job of allJobs) {
29
+ if (job.type === 'backup' || job.type === 'metadataBackup') {
30
+ if (this.isBackupRepositoryReferenced(job.remotes, repositoryId)) {
31
+ referencingJobs.push(job.id);
32
+ }
33
+ }
34
+ else if (job.type === 'mirrorBackup') {
35
+ if (job.sourceRemote === repositoryId || this.isBackupRepositoryReferenced(job.remotes, repositoryId)) {
36
+ referencingJobs.push(job.id);
37
+ }
38
+ }
39
+ }
40
+ return referencingJobs;
41
+ }
42
+ };
43
+ BackupRepositoryService = __decorate([
44
+ __param(0, inject(RestApi))
45
+ ], BackupRepositoryService);
46
+ export { BackupRepositoryService };