mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
feat(rest-api): add endpoint to plugins (#10588)
* feat(rest-api): add endpoint to plugins * add changelog * review * mathieu's review * remove patch endpoint --------- Co-authored-by: MathieuRA <contact@mathieu-raisin.fr>
This commit is contained in:
@@ -4,5 +4,6 @@ export * from './xo.mjs'
|
||||
export * from './xo-app.mjs'
|
||||
export * from './lib/xen-orchestra-xapi.mjs'
|
||||
export * from './lib/xen-orchestra-acl.mjs'
|
||||
export * from './lib/xen-orchestra-plugins.mjs'
|
||||
export * from './lib/complex-matcher.mjs'
|
||||
export * from './lib/rest-api.mjs'
|
||||
|
||||
1261
@vates/types/src/lib/xen-orchestra-plugins.mts
Normal file
1261
@vates/types/src/lib/xen-orchestra-plugins.mts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import type {
|
||||
XoConfigBackupArchive,
|
||||
XoGroup,
|
||||
XoHost,
|
||||
XoPlugin,
|
||||
XoPool,
|
||||
XoPoolBackupArchive,
|
||||
XoProxy,
|
||||
@@ -347,8 +348,10 @@ export type XoApp = EventEmitter & {
|
||||
deleteUser(id: XoUser['id']): Promise<void>
|
||||
detachHostFromPool(hostId: XoHost['id']): Promise<void>
|
||||
createGroup(params: { name: string; provider?: string; providerGroup?: string }): Promise<XoGroup>
|
||||
disablePluginAutoload(id: XoPlugin['id']): Promise<void>
|
||||
/* disconnect a server (XCP-ng/XenServer) */
|
||||
disconnectXenServer(id: XoServer['id']): Promise<void>
|
||||
enablePluginAutoload(id: XoPlugin['id']): Promise<void>
|
||||
findEnabledScheduleSequenceFromSchedule(id: XoSchedule['id']): Promise<XoSchedule | undefined>
|
||||
getAclV2Privilege(id: XoAclBasePrivilege['id']): Promise<XoAclBasePrivilege>
|
||||
getAclV2Privileges(): Promise<XoAclBasePrivilege[]>
|
||||
@@ -374,6 +377,8 @@ export type XoApp = EventEmitter & {
|
||||
getAllProxies(): Promise<XoProxy[]>
|
||||
getAllJobs<T extends AnyXoJob['type']>(type: T): Promise<Extract<AnyXoJob, { type: T }>[]>
|
||||
getAllJobs(type?: string): Promise<AnyXoJob[]>
|
||||
getPlugin(id: XoPlugin['id']): Promise<XoPlugin>
|
||||
getPlugins(): Promise<XoPlugin[]>
|
||||
getProxy(id: XoProxy['id']): Promise<XoProxy>
|
||||
getRemote(id: XoBackupRepository['id']): Promise<XoBackupRepository>
|
||||
getAllRemotes(): Promise<XoBackupRepository[]>
|
||||
@@ -453,6 +458,7 @@ export type XoApp = EventEmitter & {
|
||||
getXoEventEmitterByType(type: string): EventEmitter
|
||||
hasFeatureAuthorization(featureCode: string): Promise<boolean>
|
||||
hasObject<T extends XapiXoRecord>(id: T['id'], type: T['type']): boolean
|
||||
loadPlugin(id: XoPlugin['id']): Promise<void>
|
||||
listMetadataBackups(backupRepositoryIds: XoBackupRepository['id'][]): Promise<{
|
||||
xo: Record<XoBackupRepository['id'], XoConfigBackupArchive[]>
|
||||
pool: Record<XoBackupRepository['id'], Record<XoPool['id'], XoPoolBackupArchive[]>>
|
||||
@@ -475,6 +481,7 @@ export type XoApp = EventEmitter & {
|
||||
diskId: string
|
||||
hostId: XoHost['id']
|
||||
}): Promise<BackupArchiveDiskMount>
|
||||
purgePluginConfiguration(id: XoPlugin['id']): Promise<void>
|
||||
pingRemote(id: XoBackupRepository['id']): Promise<{ success: true }>
|
||||
/**
|
||||
* Record the live mounts a proxy created by itself, while running a restore: they never went
|
||||
@@ -515,6 +522,7 @@ export type XoApp = EventEmitter & {
|
||||
removeUserFromGroup(userId: XoUser['id'], id: XoGroup['id']): Promise<void>
|
||||
runJob(job: AnyXoJob, schedule: XoSchedule): void
|
||||
runWithApiContext: (user: XoUser | undefined, fn: () => void) => Promise<unknown>
|
||||
testPlugin(id: XoPlugin['id'], data?: unknown): Promise<void>
|
||||
testRemote(
|
||||
id: XoBackupRepository['id']
|
||||
): Promise<
|
||||
@@ -524,6 +532,7 @@ export type XoApp = EventEmitter & {
|
||||
touchXoObject(type: string, id: NonXapiXoRecord['id']): Promise<void>
|
||||
/** Detach a disk mounted by `mountBackupArchiveDisk` and stop serving it */
|
||||
unmountBackupArchiveDisk(id: BackupArchiveDiskMount['id']): Promise<void>
|
||||
unloadPlugin(id: XoPlugin['id']): Promise<void>
|
||||
reclaimSpace(
|
||||
remoteId: XoBackupRepository['id'],
|
||||
opts?: { vmUuid?: XoVm['id']; merge?: boolean; remove?: boolean }
|
||||
|
||||
@@ -21,6 +21,29 @@ import type {
|
||||
import type * as CMType from './lib/complex-matcher.mjs'
|
||||
import { XoAclBasePrivilege, XoAclRole } from './lib/xen-orchestra-acl.mjs'
|
||||
import type { XenApiHost, XenApiPool } from './xen-api.mjs'
|
||||
import type {
|
||||
XoAuditPlugin,
|
||||
XoAuthGithubPlugin,
|
||||
XoAuthGooglePlugin,
|
||||
XoAuthLdapPlugin,
|
||||
XoAuthOidcPlugin,
|
||||
XoAuthSamlPlugin,
|
||||
XoBackupReportsPlugin,
|
||||
XoIpmiSensorsPlugin,
|
||||
XoLoadBalancerPlugin,
|
||||
XoNetboxPlugin,
|
||||
XoOpenmetricsPlugin,
|
||||
XoPerfAlertPlugin,
|
||||
XoSdnControllerPlugin,
|
||||
XoTestPluginPlugin,
|
||||
XoTransportEmailPlugin,
|
||||
XoTransportIcinga2Plugin,
|
||||
XoTransportNagiosPlugin,
|
||||
XoTransportSlackPlugin,
|
||||
XoTransportXmppPlugin,
|
||||
XoUsageReportPlugin,
|
||||
XoWebHooksPlugin,
|
||||
} from './lib/xen-orchestra-plugins.mjs'
|
||||
|
||||
type BaseXapiXo = {
|
||||
$pool: XoPool['id']
|
||||
@@ -460,6 +483,29 @@ export type XoPool = BaseXapiXo & {
|
||||
zstdSupported: boolean
|
||||
}
|
||||
|
||||
export type XoPlugin =
|
||||
| XoAuditPlugin
|
||||
| XoAuthGithubPlugin
|
||||
| XoAuthGooglePlugin
|
||||
| XoAuthLdapPlugin
|
||||
| XoAuthOidcPlugin
|
||||
| XoAuthSamlPlugin
|
||||
| XoBackupReportsPlugin
|
||||
| XoIpmiSensorsPlugin
|
||||
| XoLoadBalancerPlugin
|
||||
| XoNetboxPlugin
|
||||
| XoOpenmetricsPlugin
|
||||
| XoPerfAlertPlugin
|
||||
| XoSdnControllerPlugin
|
||||
| XoTestPluginPlugin
|
||||
| XoTransportEmailPlugin
|
||||
| XoTransportIcinga2Plugin
|
||||
| XoTransportNagiosPlugin
|
||||
| XoTransportSlackPlugin
|
||||
| XoTransportXmppPlugin
|
||||
| XoUsageReportPlugin
|
||||
| XoWebHooksPlugin
|
||||
|
||||
export type XoProxy = {
|
||||
id: Branded<'proxy'>
|
||||
url: string
|
||||
@@ -701,6 +747,7 @@ export type XoTask = {
|
||||
| 'backup-log'
|
||||
| 'backup-repository'
|
||||
| 'group'
|
||||
| 'plugin'
|
||||
| 'proxy'
|
||||
| 'restore'
|
||||
| 'restore-log'
|
||||
@@ -916,6 +963,7 @@ export type NonXapiXoRecord =
|
||||
| AnyXoLog
|
||||
| XoAuthenticationToken
|
||||
| XoGroup
|
||||
| XoPlugin
|
||||
| XoProxy
|
||||
| XoJob
|
||||
| XoBackupRepository
|
||||
|
||||
@@ -14,6 +14,7 @@ import { default as pbd } from './pbd.mjs'
|
||||
import { default as pci } from './pci.mjs'
|
||||
import { default as pgpu } from './pgpu.mjs'
|
||||
import { default as pif } from './pif.mjs'
|
||||
import { default as plugin } from './plugin.mjs'
|
||||
import { default as pool } from './pool.mjs'
|
||||
import { default as proxy } from './proxy.mjs'
|
||||
import { default as restoreLog } from './restore-log.mjs'
|
||||
@@ -56,6 +57,7 @@ export const SUPPORTED_ACTIONS_BY_RESOURCE = {
|
||||
pci,
|
||||
pgpu,
|
||||
pif,
|
||||
plugin,
|
||||
pool,
|
||||
proxy,
|
||||
'restore-log': restoreLog,
|
||||
|
||||
10
@xen-orchestra/acl/src/actions/plugin.mts
Normal file
10
@xen-orchestra/acl/src/actions/plugin.mts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
load: true,
|
||||
read: true,
|
||||
test: true,
|
||||
unload: true,
|
||||
update: {
|
||||
autoload: true,
|
||||
configuration: true,
|
||||
},
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@vates/obfuscate": "^0.1.0",
|
||||
"@vates/task": "^0.7.0",
|
||||
"@vates/types": "^2.3.0",
|
||||
"@xen-orchestra/acl": "^1.7.0",
|
||||
|
||||
@@ -7,9 +7,10 @@ import type { PassThrough } from 'node:stream'
|
||||
|
||||
import { Listener } from '../abstract-classes/listener.mjs'
|
||||
import type { CollectionEventType, EventType, SubscriberId, XoListenerType } from './event.type.mjs'
|
||||
import type { AnyXoJob, XoAlarm, XoAuthenticationToken, XoMessage, XoRecord, XoUser } from '@vates/types'
|
||||
import type { AnyXoJob, XoAlarm, XoAuthenticationToken, XoMessage, XoPlugin, XoRecord, XoUser } from '@vates/types'
|
||||
import type { AlarmService } from '../alarms/alarm.service.mjs'
|
||||
import type { BackupJobService } from '../backup-jobs/backup-job.service.mjs'
|
||||
import { obfuscatePluginSecrets } from '../plugins/plugin.helper.mjs'
|
||||
|
||||
const log = createLogger('xo:rest-api:event-service')
|
||||
|
||||
@@ -148,6 +149,10 @@ export class XoListener extends Listener<XoListenerType> {
|
||||
_object = (object as XoAuthenticationToken | undefined)?.user_id === subscriber.userId ? object : undefined
|
||||
_prevObject =
|
||||
(previousObj as XoAuthenticationToken | undefined)?.user_id === subscriber.userId ? previousObj : undefined
|
||||
} else if (this.type === 'plugin') {
|
||||
// `as` assertions safe because we are in the `plugin` listener
|
||||
_object = object === undefined ? undefined : obfuscatePluginSecrets(object as XoPlugin)
|
||||
_prevObject = previousObj === undefined ? undefined : obfuscatePluginSecrets(previousObj as XoPlugin)
|
||||
}
|
||||
|
||||
if (_object === undefined && _prevObject === undefined) {
|
||||
|
||||
@@ -9,7 +9,8 @@ import { PassThrough } from 'node:stream'
|
||||
import '../open-api/routes/routes.js'
|
||||
|
||||
import type { AnyPrivilege } from '@xen-orchestra/acl'
|
||||
import type { XoUser, XoVmBackupArchive } from '@vates/types'
|
||||
import type { XoPlugin, XoUser, XoVmBackupArchive } from '@vates/types'
|
||||
import { OBFUSCATED_VALUE } from '@vates/obfuscate'
|
||||
|
||||
import { Subscriber, SubscriberManager, XoListener } from './event.class.mjs'
|
||||
import { iocContainer } from '../ioc/ioc.mjs'
|
||||
@@ -169,3 +170,30 @@ describe('XoListener on the backup-archive collection', () => {
|
||||
assert.equal(frames[0].data.backupRepository, REPOSITORY_ID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('XoListener on the plugin collection', () => {
|
||||
it('obfuscates the secrets of the plugins', async () => {
|
||||
const user = bindRestApi({ permission: 'admin' })
|
||||
const { frames, subscriber } = createSubscriber(user.id)
|
||||
|
||||
const emitter = new EventEmitter()
|
||||
const listener = new XoListener('plugin', emitter)
|
||||
listener.addSubscriber(subscriber)
|
||||
|
||||
const plugin = (clientSecret: string) =>
|
||||
({ id: 'auth-oidc', name: 'auth-oidc', configuration: { clientID: 'xo', clientSecret } }) as unknown as XoPlugin
|
||||
emitter.emit('add', plugin('secret'), undefined)
|
||||
emitter.emit('update', plugin('new secret'), plugin('secret'))
|
||||
emitter.emit('remove', undefined, plugin('new secret'))
|
||||
|
||||
await flush()
|
||||
|
||||
assert.deepEqual(
|
||||
frames.map(({ event }) => event),
|
||||
['add', 'update', 'remove']
|
||||
)
|
||||
frames.forEach(({ data }) => {
|
||||
assert.deepEqual(data.configuration, { clientID: 'xo', clientSecret: OBFUSCATED_VALUE })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ export type NonXapiListenerType =
|
||||
| 'group'
|
||||
| 'acl-privilege'
|
||||
| 'acl-role'
|
||||
| 'plugin'
|
||||
| 'proxy'
|
||||
| 'server'
|
||||
| 'backup-repository'
|
||||
|
||||
3
@xen-orchestra/rest-api/src/lib-types/vates-obfuscate.d.ts
vendored
Normal file
3
@xen-orchestra/rest-api/src/lib-types/vates-obfuscate.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module '@vates/obfuscate' {
|
||||
export const OBFUSCATED_VALUE: string
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export const pluginIds = ['/rest/v0/plugins/auth-ldap', '/rest/v0/plugins/transport-email']
|
||||
|
||||
export const partialPlugins = [
|
||||
{
|
||||
id: 'auth-ldap',
|
||||
loaded: true,
|
||||
href: '/rest/v0/plugins/auth-ldap',
|
||||
},
|
||||
{
|
||||
id: 'transport-email',
|
||||
loaded: false,
|
||||
href: '/rest/v0/plugins/transport-email',
|
||||
},
|
||||
]
|
||||
|
||||
export const plugin = {
|
||||
id: 'transport-email',
|
||||
name: 'transport-email',
|
||||
autoload: true,
|
||||
description: 'xo-server plugin to send emails',
|
||||
loaded: true,
|
||||
keywords: ['email', 'orchestra', 'plugin', 'xen', 'xen-orchestra', 'xo-server'],
|
||||
unloadable: true,
|
||||
version: '1.2.0',
|
||||
configuration: {
|
||||
from: { address: 'xo@example.org' },
|
||||
transport: { host: 'smtp.example.org', port: 587, user: 'xo', password: 'obfuscated-q3oi6d9X8uenGvdLnHk2' },
|
||||
},
|
||||
configurationSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: {
|
||||
type: 'object',
|
||||
properties: { address: { type: 'string', format: 'email' } },
|
||||
required: ['address'],
|
||||
},
|
||||
transport: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
host: { type: 'string' },
|
||||
port: { type: 'integer' },
|
||||
user: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
},
|
||||
required: ['host'],
|
||||
},
|
||||
},
|
||||
required: ['from', 'transport'],
|
||||
},
|
||||
configured: true,
|
||||
testable: true,
|
||||
testSchema: {
|
||||
type: 'object',
|
||||
properties: { to: { type: 'string', title: 'Recipient' } },
|
||||
required: ['to'],
|
||||
},
|
||||
}
|
||||
359
@xen-orchestra/rest-api/src/plugins/plugin.controller.mts
Normal file
359
@xen-orchestra/rest-api/src/plugins/plugin.controller.mts
Normal file
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
Body,
|
||||
Delete,
|
||||
Example,
|
||||
Extension,
|
||||
Get,
|
||||
Middlewares,
|
||||
Patch,
|
||||
Path,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
Route,
|
||||
Security,
|
||||
SuccessResponse,
|
||||
Tags,
|
||||
} from 'tsoa'
|
||||
import { inject } from 'inversify'
|
||||
import { forbiddenOperation, incorrectState } from 'xo-common/api-errors.js'
|
||||
import { json, type Request as ExRequest } from 'express'
|
||||
import { provide } from 'inversify-binding-decorators'
|
||||
import type { XoPlugin } from '@vates/types'
|
||||
|
||||
import {
|
||||
asynchronousActionResp,
|
||||
badRequestResp,
|
||||
forbiddenOperationResp,
|
||||
incorrectStateResp,
|
||||
invalidParameters,
|
||||
noContentResp,
|
||||
notFoundResp,
|
||||
unauthorizedResp,
|
||||
type Unbrand,
|
||||
} from '../open-api/common/response.common.mjs'
|
||||
import { partialPlugins, plugin, pluginIds } from '../open-api/oa-examples/plugin.oa-example.mjs'
|
||||
import { taskLocation } from '../open-api/oa-examples/task.oa-example.mjs'
|
||||
import type { SendObjects } from '../helpers/helper.type.mjs'
|
||||
import type { PluginConfigurationRequestBody, PluginTestRequestBody, UpdatePluginRequestBody } from './plugin.type.mjs'
|
||||
import { XoController } from '../abstract-classes/xo-controller.mjs'
|
||||
import type { CreateActionReturnType } from '../abstract-classes/base-controller.mjs'
|
||||
import { RestApi } from '../rest-api/rest-api.mjs'
|
||||
import { acl, actionsFromBody } from '../middlewares/acl.middleware.mjs'
|
||||
import { assertNoUnknownProperties, obfuscatePluginSecrets } from './plugin.helper.mjs'
|
||||
import { OBFUSCATED_VALUE } from '@vates/obfuscate'
|
||||
|
||||
@Route('plugins')
|
||||
@Security('*')
|
||||
@Response(badRequestResp.status, badRequestResp.description)
|
||||
@Response(unauthorizedResp.status, unauthorizedResp.description)
|
||||
@Tags('plugins')
|
||||
@provide(PluginController)
|
||||
export class PluginController extends XoController<XoPlugin> {
|
||||
constructor(@inject(RestApi) restApi: RestApi) {
|
||||
super('plugin', restApi)
|
||||
}
|
||||
|
||||
// --- abstract methods
|
||||
async getAllCollectionObjects(): Promise<XoPlugin[]> {
|
||||
return (await this.restApi.xoApp.getPlugins()).map(obfuscatePluginSecrets)
|
||||
}
|
||||
async getCollectionObject(id: XoPlugin['id']): Promise<XoPlugin> {
|
||||
return obfuscatePluginSecrets(await this.restApi.xoApp.getPlugin(id))
|
||||
}
|
||||
|
||||
#assertState(plugin: XoPlugin, property: 'configured' | 'loaded' | 'loading', expected: boolean) {
|
||||
const actual = plugin[property] === true
|
||||
if (actual !== expected) {
|
||||
throw incorrectState({ actual, expected, object: plugin.id, property })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all plugins that match the following privilege:
|
||||
* - resource: plugin, action: read
|
||||
*
|
||||
* @example fields "id,name,loaded,autoload"
|
||||
* @example filter "loaded?"
|
||||
* @example limit 42
|
||||
*/
|
||||
@Example(pluginIds)
|
||||
@Example(partialPlugins)
|
||||
@Extension('x-mcp-exposure', 'allow')
|
||||
@Get('')
|
||||
@Security('*', ['acl'])
|
||||
async getPlugins(
|
||||
@Request() req: ExRequest,
|
||||
@Query() fields?: string,
|
||||
@Query() ndjson?: boolean,
|
||||
@Query() markdown?: boolean,
|
||||
@Query() filter?: string,
|
||||
@Query() limit?: number
|
||||
): SendObjects<Partial<Unbrand<XoPlugin>>> {
|
||||
return this.sendObjects(Object.values(await this.getObjects({ filter })), req, {
|
||||
limit,
|
||||
privilege: { action: 'read', resource: 'plugin' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Required privilege:
|
||||
* - resource: plugin, action: read
|
||||
*
|
||||
* @example id "transport-email"
|
||||
*/
|
||||
@Example(plugin)
|
||||
@Extension('x-mcp-exposure', 'allow')
|
||||
@Get('{id}')
|
||||
@Middlewares(
|
||||
acl({
|
||||
resource: 'plugin',
|
||||
action: 'read',
|
||||
objectId: 'params.id',
|
||||
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
|
||||
})
|
||||
)
|
||||
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
|
||||
@Response(notFoundResp.status, notFoundResp.description)
|
||||
getPlugin(@Path() id: string): Promise<Unbrand<XoPlugin>> {
|
||||
return this.getObject(id as XoPlugin['id'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Required privilege per field provided in the body:
|
||||
* - resource: plugin, action: update:<field> (e.g. update:autoload)
|
||||
*
|
||||
* `autoload`: whether the plugin is loaded when XO starts
|
||||
*
|
||||
* @example id "transport-email"
|
||||
* @example body { "autoload": true }
|
||||
*/
|
||||
@Extension('x-mcp-exposure', 'confirm')
|
||||
@Patch('{id}')
|
||||
@Middlewares([
|
||||
json(),
|
||||
acl({
|
||||
resource: 'plugin',
|
||||
actions: actionsFromBody(['update:autoload']),
|
||||
objectId: 'params.id',
|
||||
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
|
||||
}),
|
||||
])
|
||||
@SuccessResponse(noContentResp.status, noContentResp.description)
|
||||
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
|
||||
@Response(notFoundResp.status, notFoundResp.description)
|
||||
async updatePlugin(@Path() id: string, @Body() body: UpdatePluginRequestBody): Promise<void> {
|
||||
const pluginId = id as XoPlugin['id']
|
||||
const { autoload } = body
|
||||
|
||||
if (autoload !== undefined) {
|
||||
await (autoload
|
||||
? this.restApi.xoApp.enablePluginAutoload(pluginId)
|
||||
: this.restApi.xoApp.disablePluginAutoload(pluginId))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Required privilege:
|
||||
* - resource: plugin, action: update:configuration
|
||||
*
|
||||
* Properties not declared in the `configurationSchema` are rejected.
|
||||
*
|
||||
* @example id "transport-email"
|
||||
* @example body {
|
||||
* "from": { "address": "xo@example.org" },
|
||||
* "transport": { "host": "smtp.example.org", "port": 587 }
|
||||
* }
|
||||
*/
|
||||
@Extension('x-mcp-exposure', 'confirm')
|
||||
@Put('{id}/configuration')
|
||||
@Middlewares([
|
||||
json(),
|
||||
acl({
|
||||
resource: 'plugin',
|
||||
action: 'update:configuration',
|
||||
objectId: 'params.id',
|
||||
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
|
||||
}),
|
||||
])
|
||||
@SuccessResponse(noContentResp.status, noContentResp.description)
|
||||
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
|
||||
@Response(notFoundResp.status, notFoundResp.description)
|
||||
@Response(invalidParameters.status, invalidParameters.description)
|
||||
async configurePlugin(@Path() id: string, @Body() body: PluginConfigurationRequestBody): Promise<void> {
|
||||
const plugin = await this.getObject(id as XoPlugin['id'])
|
||||
|
||||
assertNoUnknownProperties(body, plugin.configurationSchema)
|
||||
await this.restApi.xoApp.configurePlugin(plugin.id, body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Required privilege:
|
||||
* - resource: plugin, action: update:configuration
|
||||
*
|
||||
* Remove the plugin configuration. The plugin must be unloaded.
|
||||
*
|
||||
* @example id "transport-email"
|
||||
*/
|
||||
@Extension('x-mcp-exposure', 'confirm')
|
||||
@Delete('{id}/configuration')
|
||||
@Middlewares(
|
||||
acl({
|
||||
resource: 'plugin',
|
||||
action: 'update:configuration',
|
||||
objectId: 'params.id',
|
||||
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
|
||||
})
|
||||
)
|
||||
@SuccessResponse(noContentResp.status, noContentResp.description)
|
||||
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
|
||||
@Response(notFoundResp.status, notFoundResp.description)
|
||||
@Response(incorrectStateResp.status, 'The plugin is loaded')
|
||||
async deletePluginConfiguration(@Path() id: string): Promise<void> {
|
||||
const plugin = await this.getObject(id as XoPlugin['id'])
|
||||
|
||||
this.#assertState(plugin, 'loaded', false)
|
||||
|
||||
await this.restApi.xoApp.purgePluginConfiguration(plugin.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Required privilege:
|
||||
* - resource: plugin, action: load
|
||||
*
|
||||
* @example id "transport-email"
|
||||
*/
|
||||
@Example(taskLocation)
|
||||
@Extension('x-mcp-exposure', 'confirm')
|
||||
@Post('{id}/actions/load')
|
||||
@Middlewares(
|
||||
acl({
|
||||
resource: 'plugin',
|
||||
action: 'load',
|
||||
objectId: 'params.id',
|
||||
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
|
||||
})
|
||||
)
|
||||
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
|
||||
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
|
||||
@Response(noContentResp.status, noContentResp.description)
|
||||
@Response(notFoundResp.status, notFoundResp.description)
|
||||
@Response(incorrectStateResp.status, 'The plugin is already loaded, is loading or is not configured')
|
||||
async loadPlugin(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
|
||||
const plugin = await this.getObject(id as XoPlugin['id'])
|
||||
|
||||
return this.createAction<void>(
|
||||
async () => {
|
||||
this.#assertState(plugin, 'loaded', false)
|
||||
this.#assertState(plugin, 'loading', false)
|
||||
this.#assertState(plugin, 'configured', true)
|
||||
await this.restApi.xoApp.loadPlugin(plugin.id)
|
||||
},
|
||||
{
|
||||
statusCode: noContentResp.status,
|
||||
sync,
|
||||
taskProperties: { name: `load ${plugin.name} plugin`, objectId: plugin.id },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Required privilege:
|
||||
* - resource: plugin, action: unload
|
||||
*
|
||||
* @example id "transport-email"
|
||||
*/
|
||||
@Example(taskLocation)
|
||||
@Extension('x-mcp-exposure', 'confirm')
|
||||
@Post('{id}/actions/unload')
|
||||
@Middlewares(
|
||||
acl({
|
||||
resource: 'plugin',
|
||||
action: 'unload',
|
||||
objectId: 'params.id',
|
||||
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
|
||||
})
|
||||
)
|
||||
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
|
||||
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
|
||||
@Response(noContentResp.status, noContentResp.description)
|
||||
@Response(notFoundResp.status, notFoundResp.description)
|
||||
@Response(incorrectStateResp.status, 'The plugin is not loaded')
|
||||
async unloadPlugin(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
|
||||
const plugin = await this.getObject(id as XoPlugin['id'])
|
||||
|
||||
return this.createAction<void>(
|
||||
async () => {
|
||||
if (!plugin.unloadable) {
|
||||
throw forbiddenOperation('unload plugin', 'the plugin cannot be unloaded')
|
||||
}
|
||||
this.#assertState(plugin, 'loaded', true)
|
||||
await this.restApi.xoApp.unloadPlugin(plugin.id)
|
||||
},
|
||||
{
|
||||
statusCode: noContentResp.status,
|
||||
sync,
|
||||
taskProperties: { name: `unload ${plugin.name} plugin`, objectId: plugin.id },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Required privilege:
|
||||
* - resource: plugin, action: test
|
||||
*
|
||||
* Test the plugin with its current configuration. The plugin must be loaded.
|
||||
*
|
||||
* `data` must match the plugin `testSchema`, if any.
|
||||
*
|
||||
* @example id "transport-email"
|
||||
* @example body { "data": { "to": "admin@example.org" } }
|
||||
*/
|
||||
@Example(taskLocation)
|
||||
@Extension('x-mcp-exposure', 'confirm')
|
||||
@Post('{id}/actions/test')
|
||||
@Middlewares([
|
||||
json(),
|
||||
acl({
|
||||
resource: 'plugin',
|
||||
action: 'test',
|
||||
objectId: 'params.id',
|
||||
getObject: ({ restApi }) => restApi.xoApp.getPlugin,
|
||||
}),
|
||||
])
|
||||
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
|
||||
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
|
||||
@Response(noContentResp.status, noContentResp.description)
|
||||
@Response(notFoundResp.status, notFoundResp.description)
|
||||
@Response(incorrectStateResp.status, 'The plugin is not loaded')
|
||||
@Response(invalidParameters.status, 'The data are invalid')
|
||||
async testPlugin(
|
||||
@Path() id: string,
|
||||
@Body() body: PluginTestRequestBody,
|
||||
@Query() sync?: boolean
|
||||
): CreateActionReturnType<void> {
|
||||
const plugin = await this.getObject(id as XoPlugin['id'])
|
||||
|
||||
assertNoUnknownProperties(body.data, 'testSchema' in plugin ? plugin.testSchema : undefined)
|
||||
|
||||
const params =
|
||||
body.data !== undefined && 'password' in body.data ? { ...body.data, password: OBFUSCATED_VALUE } : body.data
|
||||
|
||||
return this.createAction<void>(
|
||||
async () => {
|
||||
if (!plugin.testable) {
|
||||
throw forbiddenOperation('test plugin', 'the plugin is not testable')
|
||||
}
|
||||
this.#assertState(plugin, 'loaded', true)
|
||||
await this.restApi.xoApp.testPlugin(plugin.id, body.data)
|
||||
},
|
||||
{
|
||||
statusCode: noContentResp.status,
|
||||
sync,
|
||||
taskProperties: { name: `test ${plugin.name} plugin`, objectId: plugin.id, params },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
94
@xen-orchestra/rest-api/src/plugins/plugin.helper.mts
Normal file
94
@xen-orchestra/rest-api/src/plugins/plugin.helper.mts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { invalidParameters } from 'xo-common/api-errors.js'
|
||||
import { OBFUSCATED_VALUE } from '@vates/obfuscate'
|
||||
import type { XoPlugin } from '@vates/types'
|
||||
|
||||
// xo-server only obfuscates the properties named like a password or a token
|
||||
const SECRETS_BY_PLUGIN: { [P in XoPlugin as P['name']]?: (keyof NonNullable<P['configuration']>)[] } = {
|
||||
'auth-github': ['clientSecret'],
|
||||
'auth-google': ['clientSecret'],
|
||||
'auth-oidc': ['clientSecret'],
|
||||
openmetrics: ['secret'],
|
||||
'transport-nagios': ['key'],
|
||||
'transport-slack': ['webhookUri'],
|
||||
}
|
||||
|
||||
type JsonSchema = {
|
||||
additionalProperties?: unknown
|
||||
anyOf?: JsonSchema[]
|
||||
items?: JsonSchema
|
||||
oneOf?: JsonSchema[]
|
||||
properties?: Record<string, JsonSchema>
|
||||
[keyword: string]: unknown
|
||||
}
|
||||
|
||||
type UnknownPropertyError = {
|
||||
instancePath: string
|
||||
keyword: 'additionalProperties'
|
||||
message: string
|
||||
params: { additionalProperty: string }
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
const escapePointer = (key: string) => key.replace(/~/g, '~0').replace(/\//g, '~1')
|
||||
|
||||
function collectUnknownProperties(value: unknown, schema: JsonSchema, path: string, errors: UnknownPropertyError[]) {
|
||||
if (Array.isArray(value)) {
|
||||
const { items } = schema
|
||||
if (items !== undefined) {
|
||||
value.forEach((item, i) => collectUnknownProperties(item, items, `${path}/${i}`, errors))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const { properties } = schema
|
||||
if (!isObject(value) || properties === undefined || schema.additionalProperties === true) {
|
||||
return
|
||||
}
|
||||
|
||||
const declared = new Set(Object.keys(properties))
|
||||
for (const subSchema of [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])]) {
|
||||
Object.keys(subSchema.properties ?? {}).forEach(key => declared.add(key))
|
||||
}
|
||||
|
||||
for (const [key, propertyValue] of Object.entries(value)) {
|
||||
if (!declared.has(key)) {
|
||||
errors.push({
|
||||
instancePath: `${path}/${escapePointer(key)}`,
|
||||
keyword: 'additionalProperties',
|
||||
message: 'must NOT have additional properties',
|
||||
params: { additionalProperty: key },
|
||||
})
|
||||
} else if (properties[key] !== undefined) {
|
||||
collectUnknownProperties(propertyValue, properties[key], `${path}/${escapePointer(key)}`, errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNoUnknownProperties(value: unknown, schema: JsonSchema | undefined): void {
|
||||
if (schema === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const errors: UnknownPropertyError[] = []
|
||||
collectUnknownProperties(value, schema, '', errors)
|
||||
if (errors.length !== 0) {
|
||||
throw invalidParameters(errors)
|
||||
}
|
||||
}
|
||||
|
||||
export function obfuscatePluginSecrets<T extends XoPlugin>(plugin: T): T {
|
||||
const secrets: string[] | undefined = SECRETS_BY_PLUGIN[plugin.name]
|
||||
if (secrets === undefined || plugin.configuration === undefined) {
|
||||
return plugin
|
||||
}
|
||||
|
||||
const configuration: Record<string, unknown> = { ...plugin.configuration }
|
||||
for (const key of secrets) {
|
||||
if (typeof configuration[key] === 'string') {
|
||||
configuration[key] = OBFUSCATED_VALUE
|
||||
}
|
||||
}
|
||||
return { ...plugin, configuration }
|
||||
}
|
||||
114
@xen-orchestra/rest-api/src/plugins/plugin.helper.test.mts
Normal file
114
@xen-orchestra/rest-api/src/plugins/plugin.helper.test.mts
Normal file
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import type { XoPlugin } from '@vates/types'
|
||||
import { OBFUSCATED_VALUE } from '@vates/obfuscate'
|
||||
|
||||
import { assertNoUnknownProperties, obfuscatePluginSecrets } from './plugin.helper.mjs'
|
||||
|
||||
const sdnControllerSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
'cert-dir': { type: 'string' },
|
||||
'override-certs': { type: 'boolean', default: false },
|
||||
},
|
||||
}
|
||||
|
||||
const unknownPaths = (fn: () => void) => {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
return (error as { data: { errors: { instancePath: string }[] } }).data.errors.map(error => error.instancePath)
|
||||
}
|
||||
assert.fail('should have thrown')
|
||||
}
|
||||
|
||||
describe('assertNoUnknownProperties', () => {
|
||||
it('accepts the declared properties', () => {
|
||||
assertNoUnknownProperties({ 'cert-dir': '/etc/xo-server/sdn', 'override-certs': true }, sdnControllerSchema)
|
||||
})
|
||||
|
||||
it('rejects the properties of another plugin', () => {
|
||||
const auditSchema = { type: 'object', properties: { active: { type: 'boolean' } } }
|
||||
|
||||
assert.deepEqual(
|
||||
unknownPaths(() => assertNoUnknownProperties({ 'cert-dir': '/etc/xo-server/sdn' }, auditSchema)),
|
||||
['/cert-dir']
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unknown nested properties', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: { transport: { type: 'object', properties: { host: { type: 'string' } } } },
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
unknownPaths(() => assertNoUnknownProperties({ transport: { host: 'smtp.example.org', foo: 'bar' } }, schema)),
|
||||
['/transport/foo']
|
||||
)
|
||||
})
|
||||
|
||||
it('checks the items of arrays', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hooks: { type: 'array', items: { type: 'object', properties: { url: { type: 'string' } } } },
|
||||
},
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
unknownPaths(() =>
|
||||
assertNoUnknownProperties({ hooks: [{ url: 'https://example.org' }, { foo: 'bar' }] }, schema)
|
||||
),
|
||||
['/hooks/1/foo']
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts the properties declared in anyOf and oneOf', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: { clientID: { type: 'string' } },
|
||||
anyOf: [{ properties: { discoveryURL: { type: 'string' } } }],
|
||||
oneOf: [{ properties: { issuer: { type: 'string' } } }],
|
||||
}
|
||||
|
||||
assertNoUnknownProperties({ clientID: 'xo', discoveryURL: 'https://example.org', issuer: 'xo' }, schema)
|
||||
})
|
||||
|
||||
it('reports every unknown property', () => {
|
||||
assert.deepEqual(
|
||||
unknownPaths(() =>
|
||||
assertNoUnknownProperties({ foo: 'bar', cert_dir: '/etc/xo-server/sdn' }, sdnControllerSchema)
|
||||
),
|
||||
['/foo', '/cert_dir']
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('obfuscatePluginSecrets', () => {
|
||||
const plugin = (name: string, configuration?: object) => ({ id: name, name, configuration }) as unknown as XoPlugin
|
||||
|
||||
it('obfuscates the secrets that are not named like a password or a token', () => {
|
||||
assert.deepEqual(
|
||||
obfuscatePluginSecrets(plugin('auth-oidc', { clientID: 'xo', clientSecret: 'secret' })).configuration,
|
||||
{
|
||||
clientID: 'xo',
|
||||
clientSecret: OBFUSCATED_VALUE,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('does not modify the plugin', () => {
|
||||
const github = plugin('auth-github', { clientID: 'xo', clientSecret: 'secret' })
|
||||
obfuscatePluginSecrets(github)
|
||||
assert.deepEqual(github.configuration, { clientID: 'xo', clientSecret: 'secret' })
|
||||
})
|
||||
|
||||
it('ignores the plugins without such secrets or without configuration', () => {
|
||||
const sdnController = plugin('sdn-controller', { 'cert-dir': '/etc/xo-server/sdn' })
|
||||
assert.equal(obfuscatePluginSecrets(sdnController), sdnController)
|
||||
|
||||
const openmetrics = plugin('openmetrics')
|
||||
assert.equal(obfuscatePluginSecrets(openmetrics), openmetrics)
|
||||
})
|
||||
})
|
||||
12
@xen-orchestra/rest-api/src/plugins/plugin.type.mts
Normal file
12
@xen-orchestra/rest-api/src/plugins/plugin.type.mts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { XoPlugin, XoPluginConfiguration, XoPluginTestData } from '@vates/types'
|
||||
|
||||
export type UpdatePluginRequestBody = {
|
||||
autoload?: XoPlugin['autoload']
|
||||
}
|
||||
|
||||
export type PluginConfigurationRequestBody = XoPluginConfiguration
|
||||
|
||||
export type PluginTestRequestBody = {
|
||||
/** data matching the plugin `testSchema` */
|
||||
data?: XoPluginTestData
|
||||
}
|
||||
@@ -63,7 +63,7 @@
|
||||
- [Backup] New `zstd` compression for the remotes in block mode (needs Node 22.15 or later), faster to back up and about 1.5 times faster to restore than `brotli` (PR [#10545](https://github.com/vatesfr/xen-orchestra/pull/10545))
|
||||
- [Backup] Remove the unused `vhdDirectoryCompression` setting of the configuration: the compression is the one of the backup repository, brotli by default (PR [#10545](https://github.com/vatesfr/xen-orchestra/pull/10545))
|
||||
- [Backup] New `zeros` compression for the remotes in block mode: only the pages full of zeroes are removed, much cheaper than a real compression (PR [#10545](https://github.com/vatesfr/xen-orchestra/pull/10545))
|
||||
|
||||
- [REST API] Add `/rest/v0/plugins` endpoints to list, configure, load, unload and test the plugins (PR [#10588](https://github.com/vatesfr/xen-orchestra/pull/10588))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const log = createLogger('xo:xo-mixins:plugins')
|
||||
|
||||
export default class {
|
||||
constructor(app) {
|
||||
this._app = app
|
||||
this._ajv = new Ajv({
|
||||
strict: 'log',
|
||||
// `strictRequired` option seems to require that the object's properties are defined in the same sub-schema (even
|
||||
@@ -33,6 +34,13 @@ export default class {
|
||||
crypto: app.cryptoCredentials,
|
||||
})
|
||||
|
||||
app.hooks.emit('registerCollection', {
|
||||
collection: this._pluginsMetadata,
|
||||
type: 'plugin',
|
||||
// metadata of plugins that are not registered (yet) are ignored
|
||||
decorate: ({ id }) => this.getOptionalPlugin(id),
|
||||
})
|
||||
|
||||
app.addConfigManager(
|
||||
'plugins',
|
||||
() => this._pluginsMetadata.get(),
|
||||
@@ -87,44 +95,54 @@ export default class {
|
||||
version,
|
||||
})
|
||||
|
||||
const metadata = await this._getPluginMetadata(id)
|
||||
let autoload = true
|
||||
let configuration
|
||||
if (metadata !== undefined) {
|
||||
;({ autoload, configuration } = metadata)
|
||||
} else {
|
||||
log.info(`[NOTICE] register plugin ${name} for the first time`)
|
||||
await this._pluginsMetadata.update({
|
||||
id,
|
||||
autoload,
|
||||
})
|
||||
}
|
||||
|
||||
if (!plugin.configured) {
|
||||
const tryEmptyConfig = configuration === undefined
|
||||
try {
|
||||
await this._configurePlugin(plugin, tryEmptyConfig ? {} : configuration)
|
||||
} catch (error) {
|
||||
// dont throw any error in case the empty config did not work
|
||||
if (tryEmptyConfig) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
try {
|
||||
const metadata = await this._getPluginMetadata(id)
|
||||
let autoload = true
|
||||
let configuration
|
||||
if (metadata !== undefined) {
|
||||
;({ autoload, configuration } = metadata)
|
||||
} else {
|
||||
log.info(`[NOTICE] register plugin ${name} for the first time`)
|
||||
await this._pluginsMetadata.update({
|
||||
id,
|
||||
autoload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (autoload) {
|
||||
await this.loadPlugin(id)
|
||||
if (!plugin.configured) {
|
||||
const tryEmptyConfig = configuration === undefined
|
||||
try {
|
||||
await this._configurePlugin(plugin, tryEmptyConfig ? {} : configuration)
|
||||
} catch (error) {
|
||||
// dont throw any error in case the empty config did not work
|
||||
if (tryEmptyConfig) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (autoload) {
|
||||
await this.loadPlugin(id)
|
||||
}
|
||||
} finally {
|
||||
await this._touchPlugin(id)
|
||||
}
|
||||
}
|
||||
|
||||
_touchPlugin(id) {
|
||||
return this._app.touchXoObject('plugin', id)
|
||||
}
|
||||
|
||||
async getPlugin(id) {
|
||||
const {
|
||||
configurationPresets,
|
||||
configurationSchema,
|
||||
configured,
|
||||
description,
|
||||
keywords,
|
||||
loaded,
|
||||
loading,
|
||||
name,
|
||||
testable,
|
||||
testSchema,
|
||||
@@ -139,12 +157,14 @@ export default class {
|
||||
autoload,
|
||||
description,
|
||||
loaded,
|
||||
loading,
|
||||
keywords,
|
||||
unloadable,
|
||||
version,
|
||||
configuration: Obfuscate.obfuscate(configuration),
|
||||
configurationPresets,
|
||||
configurationSchema,
|
||||
configured,
|
||||
testable,
|
||||
testSchema,
|
||||
}
|
||||
@@ -235,11 +255,13 @@ export default class {
|
||||
}
|
||||
|
||||
plugin.loading = true
|
||||
await this._touchPlugin(id)
|
||||
try {
|
||||
await plugin.instance.load()
|
||||
plugin.loaded = true
|
||||
} finally {
|
||||
plugin.loading = false
|
||||
await this._touchPlugin(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,11 +277,25 @@ export default class {
|
||||
|
||||
await plugin.instance.unload()
|
||||
plugin.loaded = false
|
||||
await this._touchPlugin(id)
|
||||
}
|
||||
|
||||
async purgePluginConfiguration(id) {
|
||||
const plugin = this._getRawPlugin(id)
|
||||
|
||||
await this.unloadPlugin(id).catch(noop)
|
||||
await this._pluginsMetadata.merge(id, { configuration: undefined })
|
||||
|
||||
if (plugin.configurationSchema !== undefined) {
|
||||
// like at registration, the plugin stays configured only if an empty configuration is valid
|
||||
plugin.configured = false
|
||||
try {
|
||||
await this._configurePlugin(plugin, {})
|
||||
} catch (error) {
|
||||
log.info('plugin not configured after its configuration was deleted', { error, plugin: id })
|
||||
}
|
||||
}
|
||||
await this._touchPlugin(id)
|
||||
}
|
||||
|
||||
async testPlugin(id, data) {
|
||||
|
||||
@@ -76,6 +76,7 @@ export default class Xo extends EventEmitter {
|
||||
}
|
||||
|
||||
this.hooks.on('start', () => this._watchObjects())
|
||||
// `decorate` can return `undefined` to ignore an object
|
||||
this.hooks.on('registerCollection', async ({ collection, type, decorate = obj => obj }) => {
|
||||
const cache = new Map()
|
||||
const emitter = new EventEmitter()
|
||||
@@ -86,7 +87,10 @@ export default class Xo extends EventEmitter {
|
||||
await Promise.all(
|
||||
objects.map(async object => {
|
||||
// pass a copy of the object to avoid any mutation on the source object
|
||||
cache.set(object.id, await decorate({ ...object }))
|
||||
const obj = await decorate({ ...object })
|
||||
if (obj !== undefined) {
|
||||
cache.set(obj.id, obj)
|
||||
}
|
||||
})
|
||||
)
|
||||
})()
|
||||
@@ -109,6 +113,13 @@ export default class Xo extends EventEmitter {
|
||||
const previous = cache.get(object.id)
|
||||
const newEntry = previous === undefined
|
||||
const obj = await decorate({ ...object })
|
||||
if (obj === undefined) {
|
||||
if (!newEntry) {
|
||||
cache.delete(object.id)
|
||||
emitter.emit('remove', undefined, previous)
|
||||
}
|
||||
return
|
||||
}
|
||||
cache.set(obj.id, obj)
|
||||
emitter.emit(newEntry ? 'add' : 'update', obj, previous)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user