@xen-orchestra/rest-api 0.30.1 → 0.31.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.
@@ -0,0 +1,29 @@
1
+ // Build OpenApi schema from our FieldDefinition
2
+ export function buildOpenApiSchema(def) {
3
+ const schema = {
4
+ type: 'object',
5
+ properties: {},
6
+ };
7
+ const required = [];
8
+ for (const [key, field] of Object.entries(def)) {
9
+ const property = {};
10
+ if (field.type === 'enum') {
11
+ property.type = 'string';
12
+ property.enum = field.enum;
13
+ }
14
+ else {
15
+ property.type = field.type;
16
+ }
17
+ if (field.example !== undefined) {
18
+ property.example = field.example;
19
+ }
20
+ schema.properties[key] = property;
21
+ if (!field.optional) {
22
+ required.push(key);
23
+ }
24
+ }
25
+ if (required.length) {
26
+ schema.required = required;
27
+ }
28
+ return schema;
29
+ }
@@ -0,0 +1,260 @@
1
+ import { pipeline } from 'node:stream/promises';
2
+ import { Readable } from 'node:stream';
3
+ import { Router, json, urlencoded, text, raw } from 'express';
4
+ import { invalidParameters } from 'xo-common/api-errors.js';
5
+ import { createLogger } from '@xen-orchestra/log';
6
+ import { z, ZodError } from 'zod';
7
+ import { createSchema } from 'zod-openapi';
8
+ import { buildOpenApiSchema } from '../open-api/schema/build-openapi-schema.mjs';
9
+ import { makeJsonStream, makeNdJsonStream } from '../helpers/stream.helper.mjs';
10
+ import { expressAuthentication } from '../middlewares/authentication.middleware.mjs';
11
+ import { acl } from '../middlewares/acl.middleware.mjs';
12
+ import { iocContainer } from '../ioc/ioc.mjs';
13
+ import { RestApi } from '../rest-api/rest-api.mjs';
14
+ import { CONTENT_TYPE_BY_MIDDLEWARE_NAME, } from './types.mjs';
15
+ const log = createLogger('xo:rest-api:external-router');
16
+ // Returns a mountExternalRoute function that allows routes to be added dynamically to the express router and a reference to the router
17
+ export function createExternalRouter(swaggerOpenApiSpec) {
18
+ const externalRouter = Router();
19
+ const mountExternalRoute = (route) => {
20
+ const restApi = iocContainer.get(RestApi);
21
+ const tags = route.tags ?? [];
22
+ // Build zod schema for input validation
23
+ const paramsSchema = route.params && buildZodSchema(route.params);
24
+ const querySchema = route.query && buildZodSchema(route.query);
25
+ const bodySchema = route.body && buildZodSchema(route.body);
26
+ // Format route params for express
27
+ const expressEndpoint = route.endpoint.replace(/{(\w+)}/g, ':$1');
28
+ // Resolve middleware descriptors to actual Express RequestHandlers
29
+ const middlewareDescriptors = route.middlewares ?? [];
30
+ const middlewares = middlewareDescriptors.map(resolveMiddleware);
31
+ // Set the body content type based on the used middleware
32
+ const bodyContentType = middlewareDescriptors
33
+ .map(descriptor => CONTENT_TYPE_BY_MIDDLEWARE_NAME[descriptor.name])
34
+ .find(Boolean);
35
+ // Add route to router
36
+ externalRouter[route.method](expressEndpoint, ...middlewares, async (req, res, next) => {
37
+ try {
38
+ // Handle authentication if required
39
+ if (route.security === undefined)
40
+ route.security = '*';
41
+ await expressAuthentication(req, route.security, route.scope === 'acl' ? ['acl'] : []);
42
+ // Coerces query boolean from string to boolean
43
+ coerceBooleanQueryParams(req.query, route.query);
44
+ // Validate inputs, throws if invalid
45
+ paramsSchema?.parse(req.params);
46
+ querySchema?.parse(req.query);
47
+ bodySchema?.parse(req.body);
48
+ // Per-request createAction helper pre-wired with res and restApi.tasks
49
+ const createAction = async (cb, { sync = false, statusCode = 200, taskProperties, }) => {
50
+ taskProperties.name = 'REST API: ' + taskProperties.name;
51
+ taskProperties.type = 'xo:rest-api:action';
52
+ const task = restApi.tasks.create(taskProperties);
53
+ const pResult = task.run(() => cb(task));
54
+ if (sync) {
55
+ const result = await pResult;
56
+ res.status(statusCode);
57
+ return result;
58
+ }
59
+ else {
60
+ pResult.catch(() => { });
61
+ res.status(202).set('Location', `/rest/v0/tasks/${task.id}`).json({ taskId: task.id });
62
+ return undefined;
63
+ }
64
+ };
65
+ // Call the route callback with the right context and parameters
66
+ const result = await route.callback({ req, res, next, restApi, createAction });
67
+ // Handle result formatting if the callback didn't already send a response
68
+ if (!res.headersSent) {
69
+ if (result === undefined) {
70
+ res.status(204).end();
71
+ }
72
+ else {
73
+ const isIterable = result != null &&
74
+ typeof result !== 'string' &&
75
+ !Buffer.isBuffer(result) &&
76
+ typeof (result[Symbol.iterator] ?? result[Symbol.asyncIterator]) === 'function';
77
+ if (isIterable) {
78
+ await sendObjects(result, req, res);
79
+ }
80
+ else {
81
+ res.json(result);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ catch (error) {
87
+ // Handle zod validation error to run with existing error middleware
88
+ if (error instanceof ZodError) {
89
+ try {
90
+ throw invalidParameters(undefined, error.issues);
91
+ }
92
+ catch (xoError) {
93
+ return next(xoError);
94
+ }
95
+ }
96
+ return next(error);
97
+ }
98
+ });
99
+ // Add route to the swagger documentation in memory
100
+ addPathToSwagger(route.method, route.endpoint, tags, paramsSchema, querySchema, bodySchema, bodyContentType, route.responses, swaggerOpenApiSpec, route.description);
101
+ // Return an unregister function to remove the route when they are not needed anymore
102
+ return () => {
103
+ const index = externalRouter.stack.findIndex(layer => {
104
+ // layer.route.methods is not exposed so we need to redefine the type
105
+ const layerRoutes = layer.route;
106
+ return layerRoutes?.path === expressEndpoint && layerRoutes.methods[route.method] === true;
107
+ });
108
+ if (index !== -1) {
109
+ externalRouter.stack.splice(index, 1);
110
+ removePathFromSwagger(route.endpoint, route.method, swaggerOpenApiSpec);
111
+ }
112
+ else {
113
+ log.warn(`Failed to unregister external route ${route.method.toUpperCase()} ${route.endpoint} from REST API.`);
114
+ }
115
+ };
116
+ };
117
+ return { mountExternalRoute, externalRouter };
118
+ }
119
+ // Add the rest route path to the rest api swagger with the input schema and response definitions
120
+ function addPathToSwagger(method, fullPath, tags, paramsSchema, querySchema, bodySchema, bodyContentType, responseDefinitions = [], swaggerOpenApiSpec, description) {
121
+ if (!swaggerOpenApiSpec.paths[fullPath]) {
122
+ swaggerOpenApiSpec.paths[fullPath] = {};
123
+ }
124
+ const operation = {
125
+ tags: [...tags, 'external'],
126
+ description,
127
+ parameters: [],
128
+ responses: {},
129
+ };
130
+ if (paramsSchema)
131
+ operation.parameters?.push(...extractParametersFromZod(paramsSchema, 'path'));
132
+ if (querySchema)
133
+ operation.parameters?.push(...extractParametersFromZod(querySchema, 'query'));
134
+ if (bodySchema) {
135
+ const { schema: bodyJson } = createSchema(bodySchema, { io: 'input' });
136
+ operation.requestBody = {
137
+ required: true,
138
+ content: {
139
+ [bodyContentType ?? 'text/plain']: { schema: bodyJson },
140
+ },
141
+ };
142
+ }
143
+ if (responseDefinitions?.length) {
144
+ for (const responseDefinition of responseDefinitions) {
145
+ const response = {
146
+ description: responseDefinition.description,
147
+ };
148
+ if (responseDefinition.schema) {
149
+ response.content = {
150
+ 'application/json': {
151
+ schema: buildOpenApiSchema(responseDefinition.schema),
152
+ },
153
+ };
154
+ }
155
+ operation.responses[responseDefinition.status] = response;
156
+ }
157
+ }
158
+ else {
159
+ // default 200 response
160
+ operation.responses['200'] = { description: 'Success' };
161
+ }
162
+ swaggerOpenApiSpec.paths[fullPath][method] = operation;
163
+ }
164
+ // Remove the rest route path from the rest api swagger
165
+ function removePathFromSwagger(path, method, swaggerOpenApiSpec) {
166
+ if (swaggerOpenApiSpec.paths?.[path]) {
167
+ delete swaggerOpenApiSpec.paths[path][method];
168
+ if (Object.keys(swaggerOpenApiSpec.paths[path]).length === 0) {
169
+ delete swaggerOpenApiSpec.paths[path];
170
+ }
171
+ }
172
+ }
173
+ // Resolve a middleware descriptor to an actual Express RequestHandler
174
+ function resolveMiddleware(descriptor) {
175
+ switch (descriptor.name) {
176
+ case 'json':
177
+ return json(descriptor.options);
178
+ case 'urlencoded':
179
+ return urlencoded(descriptor.options);
180
+ case 'text':
181
+ return text(descriptor.options);
182
+ case 'raw':
183
+ return raw(descriptor.options);
184
+ case 'acl':
185
+ return acl(descriptor.acls);
186
+ }
187
+ }
188
+ // Build zod schema to allow easy input validation
189
+ function buildZodSchema(def) {
190
+ const shape = {};
191
+ for (const [key, field] of Object.entries(def)) {
192
+ let schema;
193
+ switch (field.type) {
194
+ case 'string':
195
+ schema = z.string();
196
+ break;
197
+ case 'boolean':
198
+ schema = z.boolean();
199
+ break;
200
+ case 'number':
201
+ schema = z.number();
202
+ break;
203
+ case 'enum':
204
+ schema = z.enum(field.enum);
205
+ break;
206
+ default:
207
+ throw new Error(`Unsupported type: ${field.type}`);
208
+ }
209
+ if (field.example)
210
+ schema = schema.meta({ example: field.example });
211
+ if (field.optional)
212
+ schema = schema.optional();
213
+ shape[key] = schema;
214
+ }
215
+ return z.object(shape);
216
+ }
217
+ // Build OpenApi parameters from zod schema
218
+ function extractParametersFromZod(schema, location) {
219
+ const { schema: jsonSchema } = createSchema(schema, { io: 'input' });
220
+ // If it’s a $ref, we can’t expand properties
221
+ if ('$ref' in jsonSchema) {
222
+ return [
223
+ {
224
+ name: location,
225
+ in: location,
226
+ required: true,
227
+ schema: jsonSchema,
228
+ },
229
+ ];
230
+ }
231
+ // Otherwise, iterate properties
232
+ if (jsonSchema.properties) {
233
+ return Object.entries(jsonSchema.properties).map(([name, property]) => ({
234
+ name,
235
+ in: location,
236
+ required: (jsonSchema.required ?? []).includes(name),
237
+ schema: {
238
+ type: property.type,
239
+ },
240
+ example: property.example,
241
+ }));
242
+ }
243
+ return [];
244
+ }
245
+ function coerceBooleanQueryParams(query, def) {
246
+ if (!def)
247
+ return;
248
+ for (const [key, field] of Object.entries(def)) {
249
+ if (field.type === 'boolean' && typeof query[key] === 'string') {
250
+ query[key] = query[key] === 'true';
251
+ }
252
+ }
253
+ }
254
+ // Exported for retro-compatibility
255
+ // TODO: remove export when RestApi.registerRestApi is no longer used
256
+ export async function sendObjects(iterable, req, res) {
257
+ const jsonFormat = !Object.hasOwn(req.query, 'ndjson');
258
+ res.setHeader('content-type', jsonFormat ? 'application/json' : 'application/x-ndjson');
259
+ return pipeline(Readable.from((jsonFormat ? makeJsonStream : makeNdJsonStream)(iterable)), res);
260
+ }
@@ -0,0 +1,7 @@
1
+ // Maps middleware descriptor names to their OpenAPI content type
2
+ export const CONTENT_TYPE_BY_MIDDLEWARE_NAME = {
3
+ json: 'application/json',
4
+ urlencoded: 'application/x-www-form-urlencoded',
5
+ text: 'text/plain',
6
+ raw: 'application/octet-stream',
7
+ };
@@ -16,18 +16,21 @@ import { AlarmService } from '../alarms/alarm.service.mjs';
16
16
  import { BASE_URL } from '../index.mjs';
17
17
  import { escapeUnsafeComplexMatcher } from '../helpers/utils.helper.mjs';
18
18
  import { genericAlarmsExample } from '../open-api/oa-examples/alarm.oa-example.mjs';
19
- import { asynchronousActionResp, badRequestResp, createdResp, internalServerErrorResp, invalidParameters as invalidParametersResp, forbiddenOperationResp, noContentResp, notFoundResp, unauthorizedResp, } from '../open-api/common/response.common.mjs';
19
+ import { asynchronousActionResp, badRequestResp, createdResp, forbiddenOperationResp, internalServerErrorResp, invalidParameters as invalidParametersResp, noContentResp, notFoundResp, unauthorizedResp, } from '../open-api/common/response.common.mjs';
20
20
  import { partialSrs, sr, srIds } from '../open-api/oa-examples/sr.oa-example.mjs';
21
21
  import { vdiId } from '../open-api/oa-examples/vdi.oa-example.mjs';
22
22
  import { RestApi } from '../rest-api/rest-api.mjs';
23
23
  import { XapiXoController } from '../abstract-classes/xapi-xo-controller.mjs';
24
24
  import { messageIds, partialMessages } from '../open-api/oa-examples/message.oa-example.mjs';
25
25
  import { taskIds, partialTasks, taskLocation } from '../open-api/oa-examples/task.oa-example.mjs';
26
+ import { SrService } from './sr.service.mjs';
26
27
  let SrController = class SrController extends XapiXoController {
27
28
  #alarmService;
28
- constructor(restApi, alarmService) {
29
+ #srService;
30
+ constructor(restApi, alarmService, srService) {
29
31
  super('SR', restApi);
30
32
  this.#alarmService = alarmService;
33
+ this.#srService = srService;
31
34
  }
32
35
  /**
33
36
  * Returns all SRs that match the following privilege:
@@ -208,6 +211,15 @@ let SrController = class SrController extends XapiXoController {
208
211
  },
209
212
  });
210
213
  }
214
+ /**
215
+ * Required privilege:
216
+ * - resource: sr, action: delete
217
+ *
218
+ * @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
219
+ */
220
+ async deleteSr(id) {
221
+ await this.#srService.delete(id);
222
+ }
211
223
  };
212
224
  __decorate([
213
225
  Example(srIds),
@@ -338,6 +350,14 @@ __decorate([
338
350
  __param(0, Path()),
339
351
  __param(1, Query())
340
352
  ], SrController.prototype, "forgetSr", null);
353
+ __decorate([
354
+ Delete('{id}'),
355
+ Middlewares(acl({ resource: 'sr', action: 'delete', objectId: 'params.id' })),
356
+ SuccessResponse(noContentResp.status, noContentResp.description),
357
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
358
+ Response(notFoundResp.status, notFoundResp.description),
359
+ __param(0, Path())
360
+ ], SrController.prototype, "deleteSr", null);
341
361
  SrController = __decorate([
342
362
  Route('srs'),
343
363
  Security('*'),
@@ -346,6 +366,7 @@ SrController = __decorate([
346
366
  Tags('srs'),
347
367
  provide(SrController),
348
368
  __param(0, inject(RestApi)),
349
- __param(1, inject(AlarmService))
369
+ __param(1, inject(AlarmService)),
370
+ __param(2, inject(SrService))
350
371
  ], SrController);
351
372
  export { SrController };
@@ -0,0 +1,27 @@
1
+ import { LicenseService } from '../licenses/license.service.mjs';
2
+ export class SrService {
3
+ #restApi;
4
+ constructor(restApi) {
5
+ this.#restApi = restApi;
6
+ }
7
+ async delete(id) {
8
+ const sr = this.#restApi.getObject(id, 'SR');
9
+ const xapiSr = this.#restApi.getXapiObject(id, 'SR');
10
+ const xapi = xapiSr.$xapi;
11
+ if (sr.SR_type === 'linstor') {
12
+ const licenseService = this.#restApi.ioc.get(LicenseService);
13
+ const licenses = await licenseService.getXostorLicenses(id);
14
+ await Promise.all(licenses.map(({ licenseId, boundObjectId }) => this.#restApi.xoApp.unbindLicense({ licenseId, boundObjectId, productId: 'xostor' })));
15
+ try {
16
+ await xapi.xostor_delete(xapiSr.$ref);
17
+ }
18
+ catch (error) {
19
+ await Promise.all(licenses.map(({ licenseId, boundObjectId }) => this.#restApi.xoApp.bindLicense({ licenseId, boundObjectId })));
20
+ throw error;
21
+ }
22
+ }
23
+ else {
24
+ await xapi.destroySr(id);
25
+ }
26
+ }
27
+ }
@@ -108,7 +108,14 @@ let VifController = class VifController extends XapiXoController {
108
108
  /**
109
109
  * @example body {
110
110
  * "networkId": "6b6ca0f5-6611-0636-4b0a-1fb1c1e96414",
111
- * "vmId": "613f541c-4bed-fc77-7ca8-2db6b68f079c"
111
+ * "vmId": "613f541c-4bed-fc77-7ca8-2db6b68f079c",
112
+ * "other_config": {
113
+ *"ethtool-tx": "false"
114
+ * },
115
+ * "qos_algorithm_params": {
116
+ * "kbps": "42"
117
+ * },
118
+ * "qos_algorithm_type": "ratelimit"
112
119
  * }
113
120
  */
114
121
  async createVif(body) {
@@ -26,6 +26,7 @@ import { VmService } from './vm.service.mjs';
26
26
  import { BackupJobService } from '../backup-jobs/backup-job.service.mjs';
27
27
  import { partialVmBackupJobs, vmBackupJobIds } from '../open-api/oa-examples/backup-job.oa-example.mjs';
28
28
  import { messageIds, partialMessages } from '../open-api/oa-examples/message.oa-example.mjs';
29
+ import { Task } from '@vates/task';
29
30
  const IGNORED_VDIS_TAG = '[NOSNAP]';
30
31
  let VmController = class VmController extends XapiXoController {
31
32
  #vmService;
@@ -355,6 +356,40 @@ let VmController = class VmController extends XapiXoController {
355
356
  },
356
357
  });
357
358
  }
359
+ /**
360
+ * Required privilege:
361
+ * - resource: vm, action: revert-snapshot
362
+ * - resource: vm, action: snapshot (if `snapshotBefore: true`)
363
+ *
364
+ * @example id "f07ab729-c0e8-721c-45ec-f11276377030"
365
+ * @example body { "snapshotId": "f07ab729-c0e8-721c-45ec-f11276377030", "snapshotBefore": true }
366
+ */
367
+ async revertSnapshotVm(id, body, sync) {
368
+ const vmId = id;
369
+ const action = async () => {
370
+ const snapshotId = body.snapshotId;
371
+ // Ensure the snapshot belongs to this VM
372
+ const vm = this.getObject(vmId);
373
+ if (!vm.snapshots.includes(snapshotId)) {
374
+ throw invalidParameters(`snapshot ${snapshotId} does not belong to VM ${vmId}`);
375
+ }
376
+ if (body.snapshotBefore) {
377
+ await Task.run({ properties: { name: 'snapshot VM', objectId: vmId, objectType: 'VM' } }, () => this.getXapiObject(vmId).$snapshot({ ignoredVdisTag: IGNORED_VDIS_TAG }));
378
+ await Task.run({ properties: { name: 'revert snapshot', objectId: vmId, objectType: 'VM' } }, () => this.getXapi(vmId).revertVm(snapshotId));
379
+ }
380
+ else {
381
+ await this.getXapi(vmId).revertVm(snapshotId);
382
+ }
383
+ };
384
+ return this.createAction(action, {
385
+ sync,
386
+ statusCode: noContentResp.status,
387
+ taskProperties: {
388
+ name: 'revert VM snapshot',
389
+ objectId: vmId,
390
+ },
391
+ });
392
+ }
358
393
  /**
359
394
  * Required privilege:
360
395
  * - resource: vm, action: snapshot
@@ -778,6 +813,30 @@ __decorate([
778
813
  __param(0, Path()),
779
814
  __param(1, Query())
780
815
  ], VmController.prototype, "unpauseVm", null);
816
+ __decorate([
817
+ Example(taskLocation),
818
+ Post('{id}/actions/revert_snapshot'),
819
+ Middlewares([
820
+ json(),
821
+ acl([
822
+ { resource: 'vm', action: 'revert-snapshot', objectId: 'params.id' },
823
+ {
824
+ resource: 'vm',
825
+ action: ({ req }) => (req.body.snapshotBefore ? 'snapshot' : undefined),
826
+ objectId: 'params.id',
827
+ },
828
+ ]),
829
+ ]),
830
+ SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
831
+ Response(noContentResp.status, noContentResp.description),
832
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
833
+ Response(notFoundResp.status, notFoundResp.description),
834
+ Response(invalidParametersResp.status, invalidParametersResp.description),
835
+ Response(internalServerErrorResp.status, internalServerErrorResp.description),
836
+ __param(0, Path()),
837
+ __param(1, Body()),
838
+ __param(2, Query())
839
+ ], VmController.prototype, "revertSnapshotVm", null);
781
840
  __decorate([
782
841
  Example(taskLocation),
783
842
  Post('{id}/actions/snapshot'),