@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.
- package/dist/abstract-classes/base-controller.mjs +4 -5
- package/dist/abstract-classes/listener.mjs +59 -0
- package/dist/alarms/alarm.service.mjs +3 -3
- package/dist/backup-archives/backup-archive.controller.mjs +1 -0
- package/dist/backup-jobs/backup-job.controller.mjs +20 -11
- package/dist/backup-jobs/backup-job.service.mjs +28 -2
- package/dist/backup-logs/backup-log.service.mjs +8 -1
- package/dist/events/event.class.mjs +127 -0
- package/dist/events/event.controller.mjs +101 -0
- package/dist/events/event.service.mjs +53 -0
- package/dist/events/event.type.mjs +1 -0
- package/dist/helpers/error.helper.mjs +6 -1
- package/dist/helpers/utils.helper.mjs +15 -1
- package/dist/ioc/ioc.mjs +8 -0
- package/dist/messages/message.controller.mjs +2 -2
- package/dist/open-api/common/response.common.mjs +0 -1
- package/dist/open-api/oa-examples/backup-archive.oa-example.mjs +2 -1
- package/dist/open-api/oa-examples/event.oa-example.mjs +3 -0
- package/dist/open-api/oa-examples/task.oa-example.mjs +1 -1
- package/dist/open-api/oa-examples/vm.oa-example.mjs +74 -0
- package/dist/open-api/routes/routes.js +195 -21
- package/dist/pools/pool.controller.mjs +5 -5
- package/dist/rest-api/rest-api.mjs +2 -2
- package/dist/schedules/schedule.controller.mjs +1 -1
- package/dist/servers/server.controller.mjs +2 -2
- package/dist/tasks/task.controller.mjs +3 -3
- package/dist/vms/vm.controller.mjs +44 -23
- package/dist/vms/vm.service.mjs +181 -0
- package/dist/vms/vm.type.mjs +1 -0
- package/dist/xoa/xoa.service.mjs +4 -1
- package/open-api/spec/swagger.json +939 -197
- 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' ?
|
|
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', '
|
|
68
|
-
return
|
|
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 =
|
|
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' ?
|
|
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 =>
|
|
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 (!(
|
|
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
|
-
|
|
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 =>
|
|
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 (!(
|
|
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 :
|
|
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
|
-
|
|
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
|
|
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'
|
|
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
|
-
|
|
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' ?
|
|
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 =
|
|
30
|
+
userfilter = safeParseComplexMatcher(filter).createPredicate();
|
|
31
31
|
}
|
|
32
32
|
const messagePredicate = (obj) => !alarmPredicate(obj) && userfilter(obj);
|
|
33
33
|
return super.getObjects({ filter: messagePredicate, limit });
|