@xen-orchestra/rest-api 0.20.0 → 0.21.1

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.
Files changed (32) hide show
  1. package/dist/abstract-classes/base-controller.mjs +4 -5
  2. package/dist/abstract-classes/listener.mjs +59 -0
  3. package/dist/alarms/alarm.service.mjs +3 -3
  4. package/dist/backup-archives/backup-archive.controller.mjs +1 -0
  5. package/dist/backup-jobs/backup-job.controller.mjs +20 -11
  6. package/dist/backup-jobs/backup-job.service.mjs +28 -2
  7. package/dist/backup-logs/backup-log.service.mjs +8 -1
  8. package/dist/events/event.class.mjs +127 -0
  9. package/dist/events/event.controller.mjs +101 -0
  10. package/dist/events/event.service.mjs +53 -0
  11. package/dist/events/event.type.mjs +1 -0
  12. package/dist/helpers/error.helper.mjs +6 -1
  13. package/dist/helpers/utils.helper.mjs +15 -1
  14. package/dist/ioc/ioc.mjs +8 -0
  15. package/dist/messages/message.controller.mjs +2 -2
  16. package/dist/open-api/common/response.common.mjs +0 -1
  17. package/dist/open-api/oa-examples/backup-archive.oa-example.mjs +2 -1
  18. package/dist/open-api/oa-examples/event.oa-example.mjs +3 -0
  19. package/dist/open-api/oa-examples/task.oa-example.mjs +1 -1
  20. package/dist/open-api/oa-examples/vm.oa-example.mjs +74 -0
  21. package/dist/open-api/routes/routes.js +195 -21
  22. package/dist/pools/pool.controller.mjs +5 -5
  23. package/dist/rest-api/rest-api.mjs +2 -2
  24. package/dist/schedules/schedule.controller.mjs +1 -1
  25. package/dist/servers/server.controller.mjs +2 -2
  26. package/dist/tasks/task.controller.mjs +3 -3
  27. package/dist/vms/vm.controller.mjs +44 -23
  28. package/dist/vms/vm.service.mjs +181 -0
  29. package/dist/vms/vm.type.mjs +1 -0
  30. package/dist/xoa/xoa.service.mjs +4 -1
  31. package/open-api/spec/swagger.json +939 -197
  32. package/package.json +4 -3
@@ -1,4 +1,3 @@
1
- import * as CM from 'complex-matcher';
2
1
  import { Controller } from 'tsoa';
3
2
  import { createGzip } from 'node:zlib';
4
3
  import { pipeline } from 'node:stream/promises';
@@ -6,7 +5,7 @@ import { Readable } from 'node:stream';
6
5
  import { BASE_URL } from '../index.mjs';
7
6
  import { makeNdJsonStream } from '../helpers/stream.helper.mjs';
8
7
  import { makeObjectMapper } from '../helpers/object-wrapper.helper.mjs';
9
- import { NDJSON_CONTENT_TYPE } from '../helpers/utils.helper.mjs';
8
+ import { NDJSON_CONTENT_TYPE, safeParseComplexMatcher } from '../helpers/utils.helper.mjs';
10
9
  const noop = () => { };
11
10
  export class BaseController extends Controller {
12
11
  restApi;
@@ -34,7 +33,7 @@ export class BaseController extends Controller {
34
33
  const objectFilter = (task) => task.properties.objectId === object.id || task.properties.params?.id === object.id;
35
34
  let userFilter = () => true;
36
35
  if (filter !== undefined) {
37
- userFilter = typeof filter === 'string' ? CM.parse(filter).createPredicate() : filter;
36
+ userFilter = typeof filter === 'string' ? safeParseComplexMatcher(filter).createPredicate() : filter;
38
37
  }
39
38
  for await (const task of this.restApi.tasks.list({ filter: objectFilter })) {
40
39
  if (limit === 0) {
@@ -64,8 +63,8 @@ export class BaseController extends Controller {
64
63
  const location = `${BASE_URL}/tasks/${task.id}`;
65
64
  this.setStatus(202);
66
65
  this.setHeader('Location', location);
67
- this.setHeader('Content-Type', 'text/plain');
68
- return location;
66
+ this.setHeader('Content-Type', 'application/json');
67
+ return { taskId: task.id };
69
68
  }
70
69
  }
71
70
  getXapi(maybeId) {
@@ -0,0 +1,59 @@
1
+ export class Listener {
2
+ #subscribers = new Map();
3
+ #eventEmitter;
4
+ #eventCallbacks = new Map();
5
+ #watchedEvent;
6
+ constructor(eventEmitter, watchedEvent) {
7
+ this.#eventEmitter = eventEmitter;
8
+ this.#watchedEvent = watchedEvent;
9
+ }
10
+ get subscribers() {
11
+ return this.#subscribers;
12
+ }
13
+ get eventEmitter() {
14
+ return this.#eventEmitter;
15
+ }
16
+ addSubscriber(subscriber, fields = '*') {
17
+ this.#subscribers.set(subscriber.id, { fields, subscriber });
18
+ if (this.#subscribers.size === 1) {
19
+ this.#watchedEvent.forEach(event => this.#addEventListener(event));
20
+ }
21
+ }
22
+ removeSubscriber(subscriberId) {
23
+ this.#subscribers.delete(subscriberId);
24
+ if (this.#subscribers.size === 0) {
25
+ this.removeAllEventListeners();
26
+ }
27
+ }
28
+ removeAllEventListeners() {
29
+ this.#eventCallbacks.forEach((cb, event) => {
30
+ this.#eventEmitter.off(event, cb);
31
+ });
32
+ this.#eventCallbacks.clear();
33
+ }
34
+ clear() {
35
+ this.removeAllEventListeners();
36
+ this.#subscribers.clear();
37
+ this.#watchedEvent = [];
38
+ }
39
+ #addEventListener(event) {
40
+ if (this.#eventCallbacks.has(event)) {
41
+ return;
42
+ }
43
+ const broadcastAllSubscriber = (...args) => {
44
+ this.#subscribers.forEach(conf => {
45
+ if (!conf.subscriber.isAlive) {
46
+ this.removeSubscriber(conf.subscriber.id);
47
+ return;
48
+ }
49
+ const data = this.handleData({ ...conf, event }, ...args);
50
+ if (data === undefined) {
51
+ return;
52
+ }
53
+ conf.subscriber.broadcast(event, data);
54
+ });
55
+ };
56
+ this.#eventCallbacks.set(event, broadcastAllSubscriber);
57
+ this.#eventEmitter.on(event, broadcastAllSubscriber);
58
+ }
59
+ }
@@ -1,10 +1,10 @@
1
- import * as CM from 'complex-matcher';
2
1
  import { BASE_URL } from '../index.mjs';
2
+ import { safeParseComplexMatcher } from '../helpers/utils.helper.mjs';
3
3
  // E.g: 'value: 0.6\nconfig:\n<variable>\n<name value="cpu_usage"/>\n<alarm_trigger_level value="0.4"/>\n<alarm_trigger_period value ="60"/>\n</variable>';
4
4
  const ALARM_BODY_REGEX = /^value:\s*(Infinity|NaN|-Infinity|\d+(?:\.\d+)?)\s*config:\s*<variable>\s*<name value="(.*?)"/;
5
5
  const ALARM_NAMES = ['ALARM', 'BOND_STATUS_CHANGED', 'MULTIPATH_PERIODIC_ALERT'];
6
6
  export const RAW_ALARM_FILTER = `name:|(${ALARM_NAMES.join(' ')})`;
7
- export const alarmPredicate = CM.parse(RAW_ALARM_FILTER).createPredicate();
7
+ export const alarmPredicate = safeParseComplexMatcher(RAW_ALARM_FILTER).createPredicate();
8
8
  export class AlarmService {
9
9
  #restApi;
10
10
  constructor(restApi) {
@@ -50,7 +50,7 @@ export class AlarmService {
50
50
  });
51
51
  let userFilter = () => true;
52
52
  if (filter !== undefined) {
53
- userFilter = typeof filter === 'string' ? CM.parse(filter).createPredicate() : filter;
53
+ userFilter = typeof filter === 'string' ? safeParseComplexMatcher(filter).createPredicate() : filter;
54
54
  }
55
55
  const alarms = {};
56
56
  for (const id in rawAlarms) {
@@ -30,6 +30,7 @@ let BackupArchiveController = class BackupArchiveController extends XoController
30
30
  }
31
31
  const backupArchivesByRemote = await this.restApi.xoApp.listVmBackupsNg(backupRepositoryIds);
32
32
  const vmBackupArchives = Object.values(backupArchivesByRemote)
33
+ .filter(backupsByVm => backupsByVm !== undefined)
33
34
  .map(backupsByVm => Object.values(backupsByVm))
34
35
  .flat(2);
35
36
  return vmBackupArchives;
@@ -7,7 +7,6 @@ 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 * as CM from 'complex-matcher';
11
10
  import { createLogger } from '@xen-orchestra/log';
12
11
  import { inject } from 'inversify';
13
12
  import { noSuchObject } from 'xo-common/api-errors.js';
@@ -17,21 +16,26 @@ import { backupLog, backupLogIds, partialBackupLogs } from '../open-api/oa-examp
17
16
  import { BackupLogService } from '../backup-logs/backup-log.service.mjs';
18
17
  import { badRequestResp, notFoundResp, unauthorizedResp } from '../open-api/common/response.common.mjs';
19
18
  import { RestApi } from '../rest-api/rest-api.mjs';
20
- import { limitAndFilterArray } from '../helpers/utils.helper.mjs';
19
+ import { limitAndFilterArray, safeParseComplexMatcher } from '../helpers/utils.helper.mjs';
21
20
  import { XoController } from '../abstract-classes/xo-controller.mjs';
22
21
  import { metadataBackupJob, metadataBackupJobIds, mirrorBackupJob, mirrorBackupJobIds, partialMetadataBackupJobs, partialMirrorBackupJobs, partialVmBackupJobs, vmBackupJob, vmBackupJobIds, } from '../open-api/oa-examples/backup-job.oa-example.mjs';
23
22
  import { BASE_URL } from '../index.mjs';
23
+ import { BackupJobService } from './backup-job.service.mjs';
24
24
  const log = createLogger('xo:rest-api:backupJob-controller');
25
25
  let BackupJobController = class BackupJobController extends XoController {
26
+ #backupJobService;
27
+ constructor(restApi, backupJobService) {
28
+ super(restApi);
29
+ this.#backupJobService = backupJobService;
30
+ }
26
31
  async getAllCollectionObjects() {
27
32
  const allJobs = await this.restApi.xoApp.getAllJobs();
28
- const backupJobs = allJobs.filter(job => 'type' in job);
33
+ const backupJobs = allJobs.filter(job => this.#backupJobService.isBackupJob(job));
29
34
  return backupJobs;
30
35
  }
31
36
  async getCollectionObject(id) {
32
37
  const job = await this.restApi.xoApp.getJob(id);
33
- if (!('type' in job)) {
34
- // not a backup job
38
+ if (!this.#backupJobService.isBackupJob(job)) {
35
39
  throw noSuchObject(id, 'backup-job');
36
40
  }
37
41
  return job;
@@ -75,23 +79,27 @@ BackupJobController = __decorate([
75
79
  Response(badRequestResp.status, badRequestResp.description),
76
80
  Response(unauthorizedResp.status, unauthorizedResp.description),
77
81
  Tags('backup-jobs'),
78
- provide(BackupJobController)
82
+ provide(BackupJobController),
83
+ __param(0, inject(RestApi)),
84
+ __param(1, inject(BackupJobService))
79
85
  ], BackupJobController);
80
86
  export { BackupJobController };
81
87
  // ----------- DEPRECATED TO BE REMOVED IN ONE YEAR (09-12-2026)--------------------
82
88
  let DeprecatedBackupController = class DeprecatedBackupController extends XoController {
83
89
  #backupLogService;
84
- constructor(restApi, backupLogService) {
90
+ #backupJobService;
91
+ constructor(restApi, backupLogService, backupJobService) {
85
92
  super(restApi);
86
93
  this.#backupLogService = backupLogService;
94
+ this.#backupJobService = backupJobService;
87
95
  }
88
96
  async getAllCollectionObjects() {
89
97
  const backupJobs = await this.restApi.xoApp.getAllJobs();
90
- return backupJobs.filter(job => 'type' in job);
98
+ return backupJobs.filter(job => this.#backupJobService.isBackupJob(job));
91
99
  }
92
100
  async getCollectionObject(id) {
93
101
  const backupJob = await this.restApi.xoApp.getJob(id);
94
- if (!('type' in backupJob)) {
102
+ if (!this.#backupJobService.isBackupJob(backupJob)) {
95
103
  throw noSuchObject(id, 'backup-job');
96
104
  }
97
105
  return backupJob;
@@ -162,7 +170,7 @@ let DeprecatedBackupController = class DeprecatedBackupController extends XoCont
162
170
  * @example limit 42
163
171
  */
164
172
  async getDeprecatedBackupLogs(req, fields, ndjson, filter, limit) {
165
- const userFilter = filter === undefined ? () => true : CM.parse(filter).createPredicate();
173
+ const userFilter = filter === undefined ? () => true : safeParseComplexMatcher(filter).createPredicate();
166
174
  const predicate = (log) => {
167
175
  if (!this.#backupLogService.isBackupLog(log)) {
168
176
  return false;
@@ -280,7 +288,8 @@ DeprecatedBackupController = __decorate([
280
288
  }),
281
289
  provide(DeprecatedBackupController),
282
290
  __param(0, inject(RestApi)),
283
- __param(1, inject(BackupLogService))
291
+ __param(1, inject(BackupLogService)),
292
+ __param(2, inject(BackupJobService))
284
293
  ], DeprecatedBackupController);
285
294
  export { DeprecatedBackupController };
286
295
  // ----------- DEPRECATED TO BE REMOVED IN ONE YEAR (09-12-2026)--------------------
@@ -1,15 +1,20 @@
1
1
  import { createPredicate } from 'value-matcher';
2
2
  import { extractIdsFromSimplePattern } from '@xen-orchestra/backups/extractIdsFromSimplePattern.mjs';
3
- const NO_BAK_TAG = 'xo:no-bak';
3
+ import { noSuchObject } from 'xo-common/api-errors.js';
4
+ import { vmContainsNoBakTag } from '../helpers/utils.helper.mjs';
4
5
  export class BackupJobService {
6
+ #backupJobTypes = ['backup', 'metadataBackup', 'mirrorBackup'];
5
7
  #restApi;
6
8
  constructor(restApi) {
7
9
  this.#restApi = restApi;
8
10
  }
11
+ isBackupJob(anyJob) {
12
+ return this.#backupJobTypes.includes(anyJob.type);
13
+ }
9
14
  async isVmInBackupJob(backupJobId, vmId) {
10
15
  const backupJob = await this.#restApi.xoApp.getJob(backupJobId);
11
16
  const vm = this.#restApi.getObject(vmId, 'VM');
12
- if (vm.tags.includes(NO_BAK_TAG)) {
17
+ if (vmContainsNoBakTag(vm)) {
13
18
  return false;
14
19
  }
15
20
  try {
@@ -22,4 +27,25 @@ export class BackupJobService {
22
27
  return predicate(vm);
23
28
  }
24
29
  }
30
+ async backupJobHasAtLeastOneScheduleEnabled(id) {
31
+ const backupJob = await this.#restApi.xoApp.getJob(id);
32
+ for (const maybeScheduleId in backupJob.settings) {
33
+ if (maybeScheduleId === '') {
34
+ continue;
35
+ }
36
+ try {
37
+ const schedule = await this.#restApi.xoApp.getSchedule(maybeScheduleId);
38
+ if (schedule.enabled) {
39
+ return true;
40
+ }
41
+ }
42
+ catch (error) {
43
+ if (!noSuchObject.is(error, { id: maybeScheduleId, type: 'schedule' })) {
44
+ console.error(error);
45
+ }
46
+ continue;
47
+ }
48
+ }
49
+ return false;
50
+ }
25
51
  }
@@ -1,5 +1,12 @@
1
1
  export class BackupLogService {
2
2
  isBackupLog(log) {
3
- return log.message === 'backup' || log.message === 'metadata';
3
+ return log.message === 'backup';
4
+ }
5
+ getVmBackupTaskLog(log, vmId) {
6
+ return log.tasks?.find(task => task.data?.id === vmId);
7
+ }
8
+ isVmInBackupLog(log, vmId) {
9
+ const vmIds = (log.infos?.find(info => info.message === 'vms')?.data).vms ?? [];
10
+ return vmIds.includes(vmId);
4
11
  }
5
12
  }
@@ -0,0 +1,127 @@
1
+ import pick from 'lodash/pick.js';
2
+ import isEqual from 'lodash/isEqual.js';
3
+ import { createLogger } from '@xen-orchestra/log';
4
+ import { EventEmitter } from 'node:events';
5
+ import { noSuchObject } from 'xo-common/api-errors.js';
6
+ import { Listener } from '../abstract-classes/listener.mjs';
7
+ const log = createLogger('xo:rest-api:event-service');
8
+ export class Subscriber {
9
+ #id;
10
+ #manager;
11
+ #connection;
12
+ #isAlive;
13
+ get id() {
14
+ return this.#id;
15
+ }
16
+ get isAlive() {
17
+ return this.#isAlive;
18
+ }
19
+ get connection() {
20
+ return this.#connection;
21
+ }
22
+ constructor(res, manager) {
23
+ this.#id = crypto.randomUUID();
24
+ const headers = new Headers({
25
+ 'Content-Type': 'text/event-stream',
26
+ Connection: 'keep-alive',
27
+ 'Cache-Control': 'no-cache, no-transform',
28
+ });
29
+ res.setHeaders(headers);
30
+ res.on('close', () => this.clear());
31
+ manager.addSubscriber(this);
32
+ this.#connection = res;
33
+ this.#manager = manager;
34
+ this.#isAlive = true;
35
+ }
36
+ broadcast(event, data) {
37
+ if (!this.#isAlive) {
38
+ log.warn('broadcast called on a subscriber that is not alive, but still in memory! Force clear and do nothing');
39
+ this.clear();
40
+ return;
41
+ }
42
+ this.#connection.write(`event:${event}\n`);
43
+ this.#connection.write(`data:${JSON.stringify(data)}\n\n`);
44
+ }
45
+ clear() {
46
+ this.#isAlive = false;
47
+ this.#manager.removeSubscriber(this.id);
48
+ }
49
+ }
50
+ export class XapiXoListener extends Listener {
51
+ #type;
52
+ #alarmService;
53
+ constructor(type, eventEmitter, alarmService) {
54
+ super(eventEmitter, ['add', 'update', 'remove']);
55
+ this.#type = type;
56
+ this.#alarmService = alarmService;
57
+ }
58
+ handleData({ fields, event }, object, previousObj) {
59
+ let _object = object;
60
+ let _prevObject = previousObj;
61
+ if (this.#type === 'alarm') {
62
+ if (object?.type === 'message' && this.#alarmService?.isAlarm(object)) {
63
+ _object = this.#alarmService.parseAlarm(object);
64
+ }
65
+ if (previousObj?.type === 'message' && this.#alarmService?.isAlarm(previousObj)) {
66
+ _prevObject = this.#alarmService.parseAlarm(previousObj);
67
+ }
68
+ }
69
+ if (fields !== '*') {
70
+ if (_object !== undefined) {
71
+ _object = pick(_object, fields);
72
+ }
73
+ if (_prevObject !== undefined) {
74
+ _prevObject = pick(_prevObject, fields);
75
+ }
76
+ }
77
+ if (event === 'update' && fields !== '*' && isEqual(_object, _prevObject)) {
78
+ // if no changes from user perspective, don't send update
79
+ return;
80
+ }
81
+ // if _object === undefined, this means we are on a remove event, so _prevObject will not be undefined
82
+ return _object ?? _prevObject;
83
+ }
84
+ }
85
+ export class PingListener extends Listener {
86
+ #intervalId;
87
+ constructor() {
88
+ super(new EventEmitter(), ['ping']);
89
+ this.#intervalId = setInterval(() => {
90
+ this.eventEmitter.emit('ping');
91
+ }, 1000 * 30);
92
+ }
93
+ handleData() {
94
+ return { ping: Date.now() };
95
+ }
96
+ clear() {
97
+ clearInterval(this.#intervalId);
98
+ super.clear();
99
+ }
100
+ }
101
+ export class SubscriberManager {
102
+ #subscribers = new Map();
103
+ get subscribers() {
104
+ return this.#subscribers;
105
+ }
106
+ getSubscriber(id) {
107
+ const subscriber = this.#subscribers.get(id);
108
+ if (subscriber === undefined) {
109
+ throw noSuchObject(id, 'event');
110
+ }
111
+ return subscriber;
112
+ }
113
+ addSubscriber(subscriber) {
114
+ this.#subscribers.set(subscriber.id, subscriber);
115
+ }
116
+ removeSubscriber(id) {
117
+ this.#subscribers.delete(id);
118
+ }
119
+ clear() {
120
+ this.#subscribers.forEach(subscriber => {
121
+ if (!subscriber.connection.closed) {
122
+ subscriber.connection.destroy();
123
+ }
124
+ this.#subscribers.delete(subscriber.id);
125
+ });
126
+ }
127
+ }
@@ -0,0 +1,101 @@
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 { Body, Controller, Example, Delete, Get, Middlewares, Path, Post, Request, Response, Route, Security, SuccessResponse, Tags, } from 'tsoa';
11
+ import { badRequestResp, createdResp, noContentResp, notFoundResp, unauthorizedResp, } from '../open-api/common/response.common.mjs';
12
+ import { provide } from 'inversify-binding-decorators';
13
+ import { inject } from 'inversify';
14
+ import { EventService } from './event.service.mjs';
15
+ import { json } from 'express';
16
+ import { addSubscription } from '../open-api/oa-examples/event.oa-example.mjs';
17
+ let EventController = class EventController extends Controller {
18
+ #eventService;
19
+ constructor(eventService) {
20
+ super();
21
+ this.#eventService = eventService;
22
+ }
23
+ /**
24
+ * Opens an SSE (Server-Sent Events) connection.
25
+ *
26
+ * By default, there are no active subscriptions in the stream.
27
+ * To add subscriptions, use the following endpoint:
28
+ *
29
+ * POST /rest/v0/events/:id/subscriptions
30
+ *
31
+ *
32
+ * Events you will receive:
33
+ * - **init**: The first event you will receive.
34
+ * Data: the connection ID.
35
+ *
36
+ * - **ping**: A simple event used to keep the connection alive between the server and the client.
37
+ * Data: the event timestamp.
38
+ *
39
+ * - **add**: Triggered when an object has been added.
40
+ * Data: the added object.
41
+ *
42
+ * - **update**: Triggered when an object has been updated.
43
+ * Data: the updated object.
44
+ *
45
+ * - **remove**: Triggered when an object has been removed.
46
+ * Data: the removed object.
47
+ */
48
+ openSseConnection(req) {
49
+ this.#eventService.createSseSubscriber(req.res);
50
+ }
51
+ /**
52
+ * Add a subscription
53
+ *
54
+ * @example id "0d8b28c6-e9bf-4c9d-a382-3c9e0d7cfbff"
55
+ * @example body {"collection": "VM", "fields": ["id", "name_label"]}
56
+ */
57
+ addSubscription(id, body) {
58
+ this.#eventService.addListenerFor(id, { ...body, type: body.collection });
59
+ return { id: body.collection };
60
+ }
61
+ /**
62
+ * Remove a subscription
63
+ *
64
+ * @example id "0d8b28c6-e9bf-4c9d-a382-3c9e0d7cfbff"
65
+ * @example subscriptionId: "VM"
66
+ */
67
+ removeSubscription(id, subscriptionId) {
68
+ this.#eventService.removeListenerFor(id, subscriptionId);
69
+ }
70
+ };
71
+ __decorate([
72
+ Get(''),
73
+ SuccessResponse(200, 'OK'),
74
+ __param(0, Request())
75
+ ], EventController.prototype, "openSseConnection", null);
76
+ __decorate([
77
+ Example(addSubscription),
78
+ Post('{id}/subscriptions'),
79
+ Middlewares(json()),
80
+ SuccessResponse(createdResp.status, createdResp.description),
81
+ Response(notFoundResp.status, notFoundResp.description),
82
+ __param(0, Path()),
83
+ __param(1, Body())
84
+ ], EventController.prototype, "addSubscription", null);
85
+ __decorate([
86
+ Delete('{id}/subscriptions/{subscriptionId}'),
87
+ SuccessResponse(noContentResp.status, noContentResp.description),
88
+ Response(notFoundResp.status, notFoundResp.description),
89
+ __param(0, Path()),
90
+ __param(1, Path())
91
+ ], EventController.prototype, "removeSubscription", null);
92
+ EventController = __decorate([
93
+ Route('events'),
94
+ Security('*'),
95
+ Response(badRequestResp.status, badRequestResp.description),
96
+ Response(unauthorizedResp.status, unauthorizedResp.description),
97
+ Tags('events'),
98
+ provide(EventController),
99
+ __param(0, inject(EventService))
100
+ ], EventController);
101
+ export { EventController };
@@ -0,0 +1,53 @@
1
+ import { createLogger } from '@xen-orchestra/log';
2
+ import { PingListener, Subscriber, SubscriberManager, XapiXoListener } from './event.class.mjs';
3
+ import { AlarmService } from '../alarms/alarm.service.mjs';
4
+ const log = createLogger('xo:rest-api:event-service');
5
+ export class EventService {
6
+ #alarmService;
7
+ #restApi;
8
+ #listeners = new Map();
9
+ #subscriberManager = new SubscriberManager();
10
+ constructor(restApi) {
11
+ process.on('SIGTERM', () => {
12
+ log.debug(`SIGTERM received, close all SSE subscribers (nb: ${this.#subscriberManager.subscribers.size})`);
13
+ this.#subscriberManager.clear();
14
+ this.#listeners.forEach(l => l.clear());
15
+ this.#listeners.clear();
16
+ });
17
+ this.#restApi = restApi;
18
+ this.#alarmService = restApi.ioc.get(AlarmService);
19
+ }
20
+ #getListener(type) {
21
+ if (!this.#listeners.has(type)) {
22
+ if (type === 'ping') {
23
+ this.#listeners.set(type, new PingListener());
24
+ }
25
+ else {
26
+ const isAlarm = type === 'alarm';
27
+ // alarm is purely XO-related; it doesn't exist at the XAPI level.
28
+ // alarm is a message with parsed values. So, in the case of an alarm listener, it listens for message collection.
29
+ const ee = this.#restApi.xoApp.objects.allIndexes.type.getEventEmitterByType(isAlarm ? 'message' : type);
30
+ this.#listeners.set(type, new XapiXoListener(type, ee, isAlarm ? this.#alarmService : undefined));
31
+ }
32
+ }
33
+ return this.#listeners.get(type);
34
+ }
35
+ createSseSubscriber(res) {
36
+ const subscriber = new Subscriber(res, this.#subscriberManager);
37
+ subscriber.broadcast('init', { id: subscriber.id });
38
+ this.addListenerFor(subscriber.id, { type: 'ping' });
39
+ log.debug(`new SSE subscriber added: ${subscriber.id}`);
40
+ log.debug(`nb subscriber: ${this.#subscriberManager.subscribers.size}`);
41
+ return subscriber.id;
42
+ }
43
+ addListenerFor(id, { fields, type }) {
44
+ const subscriber = this.#subscriberManager.getSubscriber(id);
45
+ const listener = this.#getListener(type);
46
+ listener.addSubscriber(subscriber, fields);
47
+ }
48
+ removeListenerFor(id, type) {
49
+ const subscriber = this.#subscriberManager.getSubscriber(id);
50
+ const listener = this.#getListener(type);
51
+ listener.removeSubscriber(subscriber.id);
52
+ }
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,10 +1,15 @@
1
1
  export class ApiError extends Error {
2
2
  #status;
3
- constructor(message, status) {
3
+ #data;
4
+ constructor(message, status, opts = {}) {
4
5
  super(message);
5
6
  this.#status = status;
7
+ this.#data = opts.data;
6
8
  }
7
9
  get status() {
8
10
  return this.#status;
9
11
  }
12
+ get data() {
13
+ return this.#data;
14
+ }
10
15
  }
@@ -1,6 +1,7 @@
1
1
  import * as CM from 'complex-matcher';
2
2
  import { createLogger } from '@xen-orchestra/log';
3
3
  import { isPromise } from 'node:util/types';
4
+ import { ApiError } from './error.helper.mjs';
4
5
  export const NDJSON_CONTENT_TYPE = 'application/x-ndjson';
5
6
  const log = createLogger('xo:rest-api:utils-helper');
6
7
  export const isSrWritable = (sr) => isSrWritableOrIso(sr) && sr.content_type !== 'iso';
@@ -76,9 +77,22 @@ export function escapeUnsafeComplexMatcher(maybeString) {
76
77
  }
77
78
  return `(${maybeString})`;
78
79
  }
80
+ export function safeParseComplexMatcher(string) {
81
+ try {
82
+ return CM.parse(string);
83
+ }
84
+ catch (_error) {
85
+ // CM.parse only throw errors that are instances of Error
86
+ const error = _error;
87
+ const apiError = new ApiError(error.message, 400, { data: { stringToParse: string } });
88
+ apiError.cause = error;
89
+ apiError.stack = error.stack;
90
+ throw apiError;
91
+ }
92
+ }
79
93
  export function limitAndFilterArray(array, { filter, limit = Infinity } = {}) {
80
94
  if (filter !== undefined) {
81
- const predicate = typeof filter === 'string' ? CM.parse(filter).createPredicate() : filter;
95
+ const predicate = typeof filter === 'string' ? safeParseComplexMatcher(filter).createPredicate() : filter;
82
96
  array = array.filter(predicate);
83
97
  }
84
98
  if (limit < array.length) {
package/dist/ioc/ioc.mjs CHANGED
@@ -11,6 +11,7 @@ import { VdiService } from '../vdis/vdi.service.mjs';
11
11
  import { UserService } from '../users/user.service.mjs';
12
12
  import { BackupJobService } from '../backup-jobs/backup-job.service.mjs';
13
13
  import { BackupLogService } from '../backup-logs/backup-log.service.mjs';
14
+ import { EventService } from '../events/event.service.mjs';
14
15
  const iocContainer = new Container();
15
16
  decorate(injectable(), Controller);
16
17
  iocContainer.load(buildProviderModule());
@@ -84,5 +85,12 @@ export function setupContainer(xoApp) {
84
85
  return new BackupLogService();
85
86
  })
86
87
  .inSingletonScope();
88
+ iocContainer
89
+ .bind(EventService)
90
+ .toDynamicValue(ctx => {
91
+ const restApi = ctx.container.get(RestApi);
92
+ return new EventService(restApi);
93
+ })
94
+ .inSingletonScope();
87
95
  }
88
96
  export { iocContainer };
@@ -7,7 +7,6 @@ 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 * as CM from 'complex-matcher';
11
10
  import { Example, Get, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa';
12
11
  import { inject } from 'inversify';
13
12
  import { noSuchObject } from 'xo-common/api-errors.js';
@@ -17,6 +16,7 @@ import { message, messageIds, partialMessages } from '../open-api/oa-examples/me
17
16
  import { RestApi } from '../rest-api/rest-api.mjs';
18
17
  import { badRequestResp, notFoundResp, unauthorizedResp } from '../open-api/common/response.common.mjs';
19
18
  import { XapiXoController } from '../abstract-classes/xapi-xo-controller.mjs';
19
+ import { safeParseComplexMatcher } from '../helpers/utils.helper.mjs';
20
20
  let MessageController = class MessageController extends XapiXoController {
21
21
  constructor(restapi) {
22
22
  super('message', restapi);
@@ -27,7 +27,7 @@ let MessageController = class MessageController extends XapiXoController {
27
27
  getObjects({ filter, limit = Infinity } = {}) {
28
28
  let userfilter = () => true;
29
29
  if (filter !== undefined) {
30
- userfilter = CM.parse(filter).createPredicate();
30
+ userfilter = safeParseComplexMatcher(filter).createPredicate();
31
31
  }
32
32
  const messagePredicate = (obj) => !alarmPredicate(obj) && userfilter(obj);
33
33
  return super.getObjects({ filter: messagePredicate, limit });
@@ -5,7 +5,6 @@ export const createdResp = {
5
5
  export const asynchronousActionResp = {
6
6
  status: 202,
7
7
  description: 'Action executed asynchronously',
8
- produce: 'text/plain',
9
8
  };
10
9
  export const unauthorizedResp = {
11
10
  status: 401,