mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
17 Commits
8025af3cc8
...
deep_resol
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7477db7f3b | ||
|
|
5ae2ad9ec7 | ||
|
|
e871c5e032 | ||
|
|
8e1fc0c9dd | ||
|
|
3a39e1f919 | ||
|
|
258f365b56 | ||
|
|
d8dd0554a8 | ||
|
|
fc37ec557c | ||
|
|
3257d9d9a9 | ||
|
|
bc8d7d4cdb | ||
|
|
a5c7df43b7 | ||
|
|
d49d74abc4 | ||
|
|
d5a3b6e16a | ||
|
|
9ee440bb7c | ||
|
|
5c7c9d698f | ||
|
|
1be405308a | ||
|
|
a3fda70595 |
@@ -34,7 +34,8 @@
|
||||
"typescript": "~5.6.3",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@xen-orchestra/disk-transform": "^1.3.3"
|
||||
"@xen-orchestra/disk-transform": "^1.3.3",
|
||||
"complex-matcher": "^1.1.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
XoPool,
|
||||
XoPoolBackupArchive,
|
||||
XoProxy,
|
||||
XoRecord,
|
||||
XoSchedule,
|
||||
XoServer,
|
||||
XoTask,
|
||||
@@ -23,6 +24,7 @@ import type {
|
||||
import { VatesTask } from './lib/vates-task.mjs'
|
||||
import type { PluginRestRouteDefinition } from './lib/rest-api.mjs'
|
||||
import type { RPU_RECOVERY_STEP_NAME } from './common.mjs'
|
||||
import type { Node as CmNode, Resolver } from 'complex-matcher'
|
||||
import {
|
||||
Xapi,
|
||||
XapiHostStats,
|
||||
@@ -475,6 +477,7 @@ export type XoApp = EventEmitter & {
|
||||
diskId: string
|
||||
hostId: XoHost['id']
|
||||
}): Promise<BackupArchiveDiskMount>
|
||||
getVmBackupArchive(id: XoVmBackupArchive['id']): Promise<XoVmBackupArchive>
|
||||
pingRemote(id: XoBackupRepository['id']): Promise<{ success: true }>
|
||||
/**
|
||||
* Record the live mounts a proxy created by itself, while running a restore: they never went
|
||||
@@ -579,6 +582,10 @@ export type XoApp = EventEmitter & {
|
||||
): Promise<XoBackupRepository>
|
||||
getAllXapis(): Record<string, Xapi>
|
||||
getObjects(opts?: { filter?: Record<string, unknown>; limit?: number }): Record<string, XapiXoRecord>
|
||||
getAnyObject<T extends XoRecord>(id: T['id']): Promise<T>
|
||||
/** Can't be narrower as buildResolver will be asked to walk objects */
|
||||
buildResolver(objects: object | object[], node: CmNode): Promise<Resolver>
|
||||
xapiObjectResolver: Resolver
|
||||
getLicenses(params?: { productType?: LicenseProductType }): Promise<License[]>
|
||||
bindLicense(params: { licenseId: string; boundObjectId: string }): Promise<License>
|
||||
unbindLicense(params: {
|
||||
|
||||
@@ -14,21 +14,24 @@ export class Privilege<T extends SupportedResource> {
|
||||
#resource: TPrivilege<T>['resource']
|
||||
#effect: TPrivilege<T>['effect']
|
||||
|
||||
constructor({
|
||||
action,
|
||||
selector,
|
||||
resource,
|
||||
effect,
|
||||
}: {
|
||||
action: TPrivilege<T>['action']
|
||||
selector?: TPrivilege<T>['selector']
|
||||
resource: TPrivilege<T>['resource']
|
||||
effect: TPrivilege<T>['effect']
|
||||
}) {
|
||||
constructor(
|
||||
{
|
||||
action,
|
||||
selector,
|
||||
resource,
|
||||
effect,
|
||||
}: {
|
||||
action: TPrivilege<T>['action']
|
||||
selector?: TPrivilege<T>['selector']
|
||||
resource: TPrivilege<T>['resource']
|
||||
effect: TPrivilege<T>['effect']
|
||||
},
|
||||
resolver?: CM.Resolver
|
||||
) {
|
||||
Privilege.checkActionIsValid(resource, action)
|
||||
|
||||
this.#action = action
|
||||
this.#selector = selector !== undefined ? CM.parse(selector).createPredicate() : undefined
|
||||
this.#selector = selector !== undefined ? CM.parse(selector).createPredicate(resolver) : undefined
|
||||
this.#resource = resource
|
||||
this.#effect = effect
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export { SUPPORTED_ACTIONS_BY_RESOURCE }
|
||||
export type * from './generated/privilege-types.mjs'
|
||||
|
||||
import { AnyPrivilege } from './generated/privilege-types.mjs'
|
||||
import type { Resolver } from 'complex-matcher'
|
||||
|
||||
export type AnyPrivilegeOnParam = {
|
||||
[Resource in SupportedResource]: {
|
||||
@@ -26,19 +27,22 @@ export type AnyPrivilegeOnParam = {
|
||||
}
|
||||
}[SupportedResource]
|
||||
|
||||
export function hasPrivilegeOn<T extends SupportedResource>({
|
||||
user,
|
||||
action,
|
||||
resource,
|
||||
objects,
|
||||
userPrivileges,
|
||||
}: {
|
||||
user: XoUser
|
||||
resource: T
|
||||
action: SupportedActions<T>
|
||||
objects: object | object[]
|
||||
userPrivileges: AnyPrivilege[]
|
||||
}) {
|
||||
export function hasPrivilegeOn<T extends SupportedResource>(
|
||||
{
|
||||
user,
|
||||
action,
|
||||
resource,
|
||||
objects,
|
||||
userPrivileges,
|
||||
}: {
|
||||
user: XoUser
|
||||
resource: T
|
||||
action: SupportedActions<T>
|
||||
objects: object | object[]
|
||||
userPrivileges: AnyPrivilege[]
|
||||
},
|
||||
resolver?: Resolver
|
||||
) {
|
||||
// Function that will be called outside of the module
|
||||
// We cannot be sure types are respected
|
||||
if (typeof user?.permission !== 'string') {
|
||||
@@ -71,7 +75,7 @@ export function hasPrivilegeOn<T extends SupportedResource>({
|
||||
}
|
||||
|
||||
for (const userPrivilege of userPrivileges) {
|
||||
const privilege = new CPrivilege(userPrivilege as Privilege<typeof userPrivilege.resource>)
|
||||
const privilege = new CPrivilege(userPrivilege as Privilege<typeof userPrivilege.resource>, resolver)
|
||||
|
||||
for (const [object, effects] of effectsByObject.entries()) {
|
||||
if (!privilege.match({ action, resource, object })) {
|
||||
@@ -91,17 +95,24 @@ export function hasPrivilegeOn<T extends SupportedResource>({
|
||||
return true
|
||||
}
|
||||
|
||||
export function getMissingPrivileges(params: AnyPrivilegeOnParam[], userPrivileges: AnyPrivilege[]) {
|
||||
export function getMissingPrivileges(
|
||||
params: AnyPrivilegeOnParam[],
|
||||
userPrivileges: AnyPrivilege[],
|
||||
resolver?: Resolver
|
||||
) {
|
||||
return params
|
||||
.filter(
|
||||
param =>
|
||||
!hasPrivilegeOn({
|
||||
user: param.user,
|
||||
resource: param.resource,
|
||||
action: param.action as SupportedActions<typeof param.resource>,
|
||||
objects: param.objects,
|
||||
userPrivileges,
|
||||
})
|
||||
!hasPrivilegeOn(
|
||||
{
|
||||
user: param.user,
|
||||
resource: param.resource,
|
||||
action: param.action as SupportedActions<typeof param.resource>,
|
||||
objects: param.objects,
|
||||
userPrivileges,
|
||||
},
|
||||
resolver
|
||||
)
|
||||
)
|
||||
.map(({ action, objects, resource }) => {
|
||||
let objectIds: unknown[] | undefined
|
||||
@@ -132,18 +143,21 @@ export function getMissingPrivileges(params: AnyPrivilegeOnParam[], userPrivileg
|
||||
})
|
||||
}
|
||||
|
||||
export function hasPrivileges(params: AnyPrivilegeOnParam[], userPrivileges: AnyPrivilege[]) {
|
||||
return getMissingPrivileges(params, userPrivileges).length === 0
|
||||
export function hasPrivileges(params: AnyPrivilegeOnParam[], userPrivileges: AnyPrivilege[], resolver?: Resolver) {
|
||||
return getMissingPrivileges(params, userPrivileges, resolver).length === 0
|
||||
}
|
||||
|
||||
export function filterObjectsWithPrivilege<Resource extends SupportedResource, Object extends object>(param: {
|
||||
user: XoUser
|
||||
resource: Resource
|
||||
action: SupportedActions<Resource>
|
||||
objects: Object[]
|
||||
userPrivileges: AnyPrivilege[]
|
||||
}) {
|
||||
return param.objects.filter(obj => hasPrivilegeOn({ ...param, objects: obj }))
|
||||
export function filterObjectsWithPrivilege<Resource extends SupportedResource, Object extends object>(
|
||||
param: {
|
||||
user: XoUser
|
||||
resource: Resource
|
||||
action: SupportedActions<Resource>
|
||||
objects: Object[]
|
||||
userPrivileges: AnyPrivilege[]
|
||||
},
|
||||
resolver?: Resolver
|
||||
) {
|
||||
return param.objects.filter(obj => hasPrivilegeOn({ ...param, objects: obj }, resolver))
|
||||
}
|
||||
|
||||
// Utils
|
||||
|
||||
@@ -91,6 +91,9 @@ suite('ACL V2 behavior', async () => {
|
||||
const templateDeny = { id: 'template-deny-id', $pool: '2' }
|
||||
const vdis = [{ $SR: '2' }, { $SR: '2' }]
|
||||
const vdisMultipleSrs = [{ $SR: '1' }, { $SR: '2' }]
|
||||
const vdisOnTaggedSr = [{ $SR: '4' }]
|
||||
const vdisOnUntaggedSr = [{ $SR: '5' }]
|
||||
const srs = { '4': { tags: ['tag'] } }
|
||||
const vifs = [{ $network: '1' }]
|
||||
const iso = { id: 'iso-vdi-id', $SR: '3' }
|
||||
const hostAffinity = { id: 'affinity-host-id', $pool: '1' }
|
||||
@@ -121,6 +124,14 @@ suite('ACL V2 behavior', async () => {
|
||||
effect: 'allow',
|
||||
roleId,
|
||||
} satisfies TPrivilege<'vdi'>
|
||||
const vdiCreateResolve = {
|
||||
id: privilegeId,
|
||||
action: 'create',
|
||||
resource: 'vdi',
|
||||
selector: '$SR:[resolve]:tags:tag',
|
||||
effect: 'allow',
|
||||
roleId,
|
||||
} satisfies TPrivilege<'vdi'>
|
||||
const vifCreate = {
|
||||
id: privilegeId,
|
||||
action: 'create',
|
||||
@@ -171,6 +182,11 @@ suite('ACL V2 behavior', async () => {
|
||||
hostVm,
|
||||
]
|
||||
|
||||
const resolvePrivileges = [vdiCreateResolve]
|
||||
|
||||
// === Resolvers
|
||||
const srTagResolver = (id: string) => srs[id]
|
||||
|
||||
// === Test ===
|
||||
suite('hasPrivilegeOn behavior', () => {
|
||||
test('Should always return true for admin', () => {
|
||||
@@ -258,6 +274,35 @@ suite('ACL V2 behavior', async () => {
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('should allow create on vdi whose SR has tag', () => {
|
||||
assert.strictEqual(
|
||||
hasPrivilegeOn(
|
||||
{
|
||||
user,
|
||||
action: 'create',
|
||||
resource: 'vdi',
|
||||
objects: vdisOnTaggedSr,
|
||||
userPrivileges: resolvePrivileges,
|
||||
},
|
||||
srTagResolver
|
||||
),
|
||||
true
|
||||
)
|
||||
assert.strictEqual(
|
||||
hasPrivilegeOn(
|
||||
{
|
||||
user,
|
||||
action: 'create',
|
||||
resource: 'vdi',
|
||||
objects: vdisOnUntaggedSr,
|
||||
userPrivileges: resolvePrivileges,
|
||||
},
|
||||
srTagResolver
|
||||
),
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
suite('getMissingPrivileges behavior', () => {
|
||||
|
||||
@@ -57,6 +57,12 @@ export abstract class BaseController<T extends XoRecord, IsSync extends boolean>
|
||||
: []
|
||||
) as AnyPrivilege[]
|
||||
|
||||
const privilegeResolver = await this.restApi.buildPrivilegeResolver(
|
||||
objects,
|
||||
userPrivileges,
|
||||
opts?.privilege?.resource
|
||||
)
|
||||
|
||||
let limit = opts?.limit ?? Infinity
|
||||
for (const object of objects) {
|
||||
if (limit === 0) {
|
||||
@@ -65,7 +71,7 @@ export abstract class BaseController<T extends XoRecord, IsSync extends boolean>
|
||||
|
||||
if (
|
||||
opts?.privilege !== undefined &&
|
||||
!hasPrivilegeOn({ user, userPrivileges, objects: object, ...opts.privilege })
|
||||
!hasPrivilegeOn({ user, userPrivileges, objects: object, ...opts.privilege }, privilegeResolver)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -105,15 +111,20 @@ export abstract class BaseController<T extends XoRecord, IsSync extends boolean>
|
||||
const objectFilter = (task: XoTask) =>
|
||||
task.properties.objectId === object.id || task.properties.params?.id === object.id
|
||||
|
||||
let userFilter: (task: XoTask) => boolean = () => true
|
||||
if (filter !== undefined) {
|
||||
userFilter = typeof filter === 'string' ? safeParseComplexMatcher(filter).createPredicate() : filter
|
||||
const objectTasks: XoTask[] = []
|
||||
for await (const task of this.restApi.tasks.list({ filter: objectFilter })) {
|
||||
objectTasks.push(task)
|
||||
}
|
||||
|
||||
for await (const task of this.restApi.tasks.list({ filter: objectFilter })) {
|
||||
if (limit === 0) {
|
||||
break
|
||||
}
|
||||
let userFilter: (task: XoTask) => boolean = () => true
|
||||
if (filter !== undefined) {
|
||||
const parsedFilter = safeParseComplexMatcher(filter)
|
||||
userFilter = parsedFilter.createPredicate(await this.restApi.buildResolver(objectTasks, parsedFilter))
|
||||
}
|
||||
|
||||
for (const task of objectTasks) {
|
||||
if (limit === 0) break
|
||||
|
||||
if (userFilter(task)) {
|
||||
tasks[task.id] = task
|
||||
limit--
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import type { EventEmitter } from 'node:events'
|
||||
import { AnyPrivilege, hasPrivilegeOn, SupportedResource } from '@xen-orchestra/acl'
|
||||
import { XAPI_TYPES, type XapiXoRecord, type XoUser } from '@vates/types'
|
||||
import { XAPI_TYPES, XoRecord, type XapiXoRecord, type XoUser } from '@vates/types'
|
||||
import type { CollectionEventType, EventType, NonXapiListenerType, XoListenerType } from '../events/event.type.mjs'
|
||||
import { iocContainer } from '../ioc/ioc.mjs'
|
||||
import { RestApi } from '../rest-api/rest-api.mjs'
|
||||
@@ -124,8 +124,8 @@ export abstract class Listener<Type extends XoListenerType | undefined = undefin
|
||||
userId,
|
||||
}: {
|
||||
event: CollectionEventType
|
||||
object: object | undefined
|
||||
previousObject: object | undefined
|
||||
object: Partial<XoRecord> | undefined
|
||||
previousObject: Partial<XoRecord> | undefined
|
||||
userId: XoUser['id']
|
||||
}): Promise<CollectionEventType | undefined> {
|
||||
if (this.type === undefined || (object === undefined && previousObject === undefined)) {
|
||||
@@ -142,7 +142,6 @@ export abstract class Listener<Type extends XoListenerType | undefined = undefin
|
||||
return event
|
||||
}
|
||||
|
||||
const userPrivileges = (await restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[]
|
||||
let resource: SupportedResource | undefined
|
||||
|
||||
if (!XAPI_TYPES.includes(this.type)) {
|
||||
@@ -156,36 +155,60 @@ export abstract class Listener<Type extends XoListenerType | undefined = undefin
|
||||
;[resource] = resourceXapiType as [keyof typeof XAPI_TYPE_BY_ACL_RESOURCE, XapiXoRecord['type']]
|
||||
}
|
||||
|
||||
const userPrivileges = (await restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[]
|
||||
const privilegeResolver = await restApi.buildPrivilegeResolver(
|
||||
[object, previousObject].filter(o => o !== undefined),
|
||||
userPrivileges,
|
||||
resource
|
||||
)
|
||||
|
||||
switch (event) {
|
||||
case 'add':
|
||||
if (object === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasPrivilegeOn({ user, userPrivileges, action: 'read', objects: object, resource })) {
|
||||
if (
|
||||
!hasPrivilegeOn(
|
||||
{
|
||||
user,
|
||||
userPrivileges,
|
||||
action: 'read',
|
||||
objects: object,
|
||||
resource,
|
||||
},
|
||||
privilegeResolver
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
return 'add'
|
||||
case 'update': {
|
||||
const canSeeObject =
|
||||
object !== undefined &&
|
||||
hasPrivilegeOn({
|
||||
user,
|
||||
userPrivileges,
|
||||
action: 'read',
|
||||
objects: object,
|
||||
resource,
|
||||
})
|
||||
hasPrivilegeOn(
|
||||
{
|
||||
user,
|
||||
userPrivileges,
|
||||
action: 'read',
|
||||
objects: object,
|
||||
resource,
|
||||
},
|
||||
privilegeResolver
|
||||
)
|
||||
|
||||
const canSeePreviousObject =
|
||||
previousObject !== undefined &&
|
||||
hasPrivilegeOn({
|
||||
user,
|
||||
userPrivileges,
|
||||
action: 'read',
|
||||
objects: previousObject,
|
||||
resource,
|
||||
})
|
||||
hasPrivilegeOn(
|
||||
{
|
||||
user,
|
||||
userPrivileges,
|
||||
action: 'read',
|
||||
objects: previousObject,
|
||||
resource,
|
||||
},
|
||||
privilegeResolver
|
||||
)
|
||||
|
||||
if (canSeeObject && canSeePreviousObject) {
|
||||
return 'update'
|
||||
@@ -203,13 +226,16 @@ export abstract class Listener<Type extends XoListenerType | undefined = undefin
|
||||
}
|
||||
case 'remove':
|
||||
if (
|
||||
!hasPrivilegeOn({
|
||||
user,
|
||||
userPrivileges,
|
||||
action: 'read',
|
||||
objects: object ?? previousObject!,
|
||||
resource,
|
||||
})
|
||||
!hasPrivilegeOn(
|
||||
{
|
||||
user,
|
||||
userPrivileges,
|
||||
action: 'read',
|
||||
objects: object ?? previousObject!,
|
||||
resource,
|
||||
},
|
||||
privilegeResolver
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export abstract class XoController<T extends NonXapiXoRecord> extends BaseContro
|
||||
): Promise<Record<T['id'], T>> {
|
||||
let objects = await this.getAllCollectionObjects(opts)
|
||||
|
||||
objects = limitAndFilterArray(objects, opts)
|
||||
objects = await limitAndFilterArray(objects, { filter: opts.filter, limit: opts.limit }, this.restApi)
|
||||
|
||||
const objectById = {} as Record<T['id'], T>
|
||||
|
||||
|
||||
@@ -316,7 +316,8 @@ export class AclRoleController extends XoController<XoAclRole> {
|
||||
@Query() limit?: number
|
||||
): SendObjects<Partial<Unbrand<AnyPrivilege>>> {
|
||||
const privileges = (await this.restApi.xoApp.getAclV2RolePrivileges(id as XoAclRole['id'])) as AnyPrivilege[]
|
||||
return this.sendObjects(limitAndFilterArray(privileges, { filter }), req, {
|
||||
|
||||
return this.sendObjects(await limitAndFilterArray(privileges, { filter }, this.restApi), req, {
|
||||
path: 'acl-privileges',
|
||||
limit,
|
||||
privilege: { resource: 'acl-privilege', action: 'read' },
|
||||
@@ -488,7 +489,7 @@ export class AclRoleController extends XoController<XoAclRole> {
|
||||
)
|
||||
)
|
||||
|
||||
return this.sendObjects(limitAndFilterArray(users, { filter }), req, {
|
||||
return this.sendObjects(await limitAndFilterArray(users, { filter }, this.restApi), req, {
|
||||
path: 'users',
|
||||
limit,
|
||||
privilege: { resource: 'user', action: 'read' },
|
||||
@@ -534,7 +535,7 @@ export class AclRoleController extends XoController<XoAclRole> {
|
||||
)
|
||||
)
|
||||
|
||||
return this.sendObjects(limitAndFilterArray(groups, { filter }), req, {
|
||||
return this.sendObjects(await limitAndFilterArray(groups, { filter }, this.restApi), req, {
|
||||
path: 'groups',
|
||||
limit,
|
||||
privilege: { resource: 'group', action: 'read' },
|
||||
|
||||
@@ -60,7 +60,8 @@ export class AlarmService {
|
||||
|
||||
let userFilter: (obj: XoAlarm) => boolean = () => true
|
||||
if (filter !== undefined) {
|
||||
userFilter = typeof filter === 'string' ? safeParseComplexMatcher(filter).createPredicate() : filter
|
||||
userFilter =
|
||||
typeof filter === 'string' ? safeParseComplexMatcher(filter).createPredicate(this.#restApi.resolver) : filter
|
||||
}
|
||||
const alarms: Record<XoAlarm['id'], XoAlarm> = {}
|
||||
for (const id in rawAlarms) {
|
||||
|
||||
@@ -42,9 +42,8 @@ import {
|
||||
} from '../open-api/oa-examples/backup-archive.oa-example.mjs'
|
||||
import { taskLocation } from '../open-api/oa-examples/task.oa-example.mjs'
|
||||
import { SendObjects } from '../helpers/helper.type.mjs'
|
||||
import { BackupArchiveService } from './backup-archive.service.mjs'
|
||||
import type { MountLiveDiskBody } from './backup-archive.type.mjs'
|
||||
import { acl, autoBindService } from '../middlewares/acl.middleware.mjs'
|
||||
import { acl } from '../middlewares/acl.middleware.mjs'
|
||||
|
||||
@Route('backup-archives')
|
||||
@Security('*')
|
||||
@@ -53,14 +52,8 @@ import { acl, autoBindService } from '../middlewares/acl.middleware.mjs'
|
||||
@Tags('backup-archives')
|
||||
@provide(BackupArchiveController)
|
||||
export class BackupArchiveController extends XoController<XoVmBackupArchive> {
|
||||
#backupArchiveService: BackupArchiveService
|
||||
|
||||
constructor(
|
||||
@inject(RestApi) restApi: RestApi,
|
||||
@inject(BackupArchiveService) backupArchiveService: BackupArchiveService
|
||||
) {
|
||||
constructor(@inject(RestApi) restApi: RestApi) {
|
||||
super('backup-archive', restApi)
|
||||
this.#backupArchiveService = backupArchiveService
|
||||
}
|
||||
|
||||
async getAllCollectionObjects({
|
||||
@@ -88,7 +81,7 @@ export class BackupArchiveController extends XoController<XoVmBackupArchive> {
|
||||
}
|
||||
|
||||
getCollectionObject(id: XoVmBackupArchive['id']): Promise<XoVmBackupArchive> {
|
||||
return this.#backupArchiveService.getBackupArchive(id)
|
||||
return this.restApi.xoApp.getVmBackupArchive(id)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +131,7 @@ export class BackupArchiveController extends XoController<XoVmBackupArchive> {
|
||||
resource: 'backup-archive',
|
||||
action: 'read',
|
||||
objectId: 'params.id',
|
||||
getObject: autoBindService(BackupArchiveService, 'getBackupArchive'),
|
||||
getObject: ({ restApi }) => restApi.xoApp.getVmBackupArchive,
|
||||
})
|
||||
)
|
||||
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { noSuchObject } from 'xo-common/api-errors.js'
|
||||
import type { XoBackupRepository, XoVm, XoVmBackupArchive } from '@vates/types'
|
||||
|
||||
import type { RestApi } from '../rest-api/rest-api.mjs'
|
||||
|
||||
// BR uuid/xo-vm-backups/VM uuid/(ISO 8601 compact).json
|
||||
const BACKUP_ARCHIVE_ID_REGEX = /^([0-9a-fA-F-]{36})\/+xo-vm-backups\/+([0-9a-fA-F-]{36})\/+(\d{8}T\d{6}Z)\.json$/
|
||||
|
||||
export class BackupArchiveService {
|
||||
#restApi: RestApi
|
||||
|
||||
constructor(restApi: RestApi) {
|
||||
this.#restApi = restApi
|
||||
}
|
||||
|
||||
async getBackupArchive(id: XoVmBackupArchive['id']): Promise<XoVmBackupArchive> {
|
||||
const match = id.match(BACKUP_ARCHIVE_ID_REGEX)
|
||||
if (match === null) {
|
||||
throw noSuchObject(id, 'backup-archive')
|
||||
}
|
||||
|
||||
const [, brId, vmId] = match as [XoVmBackupArchive['id'], XoBackupRepository['id'], XoVm['id'], string]
|
||||
|
||||
const backupArchive = (await this.#restApi.xoApp.listVmBackupsNg([brId]))[brId]?.[vmId]?.find(ba => ba.id === id)
|
||||
if (backupArchive === undefined) {
|
||||
throw noSuchObject(id, 'backup-archive')
|
||||
}
|
||||
|
||||
return backupArchive
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
Unbrand,
|
||||
} from '../open-api/common/response.common.mjs'
|
||||
import { RestApi } from '../rest-api/rest-api.mjs'
|
||||
import { BASE_URL, limitAndFilterArray, safeParseComplexMatcher } from '../helpers/utils.helper.mjs'
|
||||
import { BASE_URL, limitAndFilterArray } from '../helpers/utils.helper.mjs'
|
||||
import type {
|
||||
UnbrandAnyXoBackupJob,
|
||||
UnbrandXoMetadataBackupJob,
|
||||
@@ -218,7 +218,8 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
|
||||
@Query() limit?: number
|
||||
): SendObjects<Partial<UnbrandXoVmBackupJob>> {
|
||||
const vmBackupJobs = await this.restApi.xoApp.getAllJobs('backup')
|
||||
return this.sendObjects(limitAndFilterArray(vmBackupJobs, { filter }), req, {
|
||||
|
||||
return this.sendObjects(await limitAndFilterArray(vmBackupJobs, { filter }, this.restApi), req, {
|
||||
path: 'backup-jobs',
|
||||
limit,
|
||||
privilege: { action: 'read', resource: 'backup-job' },
|
||||
@@ -272,7 +273,8 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
|
||||
@Query() limit?: number
|
||||
): SendObjects<Partial<UnbrandXoMetadataBackupJob>> {
|
||||
const metadataBackupJobs = await this.restApi.xoApp.getAllJobs('metadataBackup')
|
||||
return this.sendObjects(limitAndFilterArray(metadataBackupJobs, { filter }), req, {
|
||||
|
||||
return this.sendObjects(await limitAndFilterArray(metadataBackupJobs, { filter }, this.restApi), req, {
|
||||
path: 'backup-jobs',
|
||||
limit,
|
||||
privilege: { action: 'read', resource: 'backup-job' },
|
||||
@@ -316,7 +318,8 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
|
||||
@Query() limit?: number
|
||||
): SendObjects<Partial<UnbrandXoMirrorBackupJob>> {
|
||||
const mirrorBackupJobs = await this.restApi.xoApp.getAllJobs('mirrorBackup')
|
||||
return this.sendObjects(limitAndFilterArray(mirrorBackupJobs, { filter }), req, {
|
||||
|
||||
return this.sendObjects(await limitAndFilterArray(mirrorBackupJobs, { filter }, this.restApi), req, {
|
||||
path: 'backup-jobs',
|
||||
limit,
|
||||
privilege: { action: 'read', resource: 'backup-job' },
|
||||
@@ -359,17 +362,11 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
|
||||
@Query() filter?: string,
|
||||
@Query() limit?: number
|
||||
): SendObjects<Partial<Unbrand<XoBackupLog>>> {
|
||||
const userFilter = filter === undefined ? () => true : safeParseComplexMatcher(filter).createPredicate()
|
||||
const logs = (await this.restApi.xoApp.getBackupNgLogsSorted({
|
||||
filter: (log: AnyXoLog) => this.#backupLogService.isBackupLog(log),
|
||||
})) as XoBackupLog[]
|
||||
|
||||
const predicate = (log: AnyXoLog) => {
|
||||
if (!this.#backupLogService.isBackupLog(log)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return userFilter(log)
|
||||
}
|
||||
const logs = (await this.restApi.xoApp.getBackupNgLogsSorted({ filter: predicate })) as XoBackupLog[]
|
||||
return this.sendObjects(logs, req, {
|
||||
return this.sendObjects(await limitAndFilterArray(logs, { filter }, this.restApi), req, {
|
||||
path: 'backup-logs',
|
||||
limit,
|
||||
privilege: { action: 'read', resource: 'backup-log' },
|
||||
|
||||
@@ -50,6 +50,8 @@ function bindRestApi(user: Partial<XoUser>, userPrivileges: AnyPrivilege[] = [])
|
||||
getUser: async () => _user,
|
||||
getAclV2UserPrivileges: async () => userPrivileges,
|
||||
},
|
||||
// none of the selectors uses [resolve], so the resolver is never called
|
||||
buildPrivilegeResolver: async () => undefined,
|
||||
} as unknown as RestApi)
|
||||
|
||||
return _user
|
||||
|
||||
@@ -285,7 +285,8 @@ export class GroupController extends XoController<XoGroup> {
|
||||
): SendObjects<Partial<Unbrand<XoUser>>> {
|
||||
const group = await this.getObject(id as XoGroup['id'])
|
||||
const users = await Promise.all(group.users.map(id => this.restApi.xoApp.getUser(id, { obfuscatePassword: true })))
|
||||
return this.sendObjects(limitAndFilterArray(users, { filter }), req, {
|
||||
|
||||
return this.sendObjects(await limitAndFilterArray(users, { filter }, this.restApi), req, {
|
||||
path: 'users',
|
||||
limit,
|
||||
privilege: { action: 'read', resource: 'user' },
|
||||
@@ -354,7 +355,7 @@ export class GroupController extends XoController<XoGroup> {
|
||||
const group = await this.getObject(id as XoGroup['id'])
|
||||
const roles = await Promise.all(group.aclRoleIds.map(roleId => this.restApi.xoApp.getAclV2Role(roleId)))
|
||||
|
||||
return this.sendObjects(limitAndFilterArray(roles, { filter }), req, {
|
||||
return this.sendObjects(await limitAndFilterArray(roles, { filter }, this.restApi), req, {
|
||||
path: 'acl-roles',
|
||||
limit,
|
||||
privilege: { action: 'read', resource: 'acl-role' },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Writable } from 'node:stream'
|
||||
import { ApiError } from './error.helper.mjs'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
|
||||
import type { RestApi } from '../rest-api/rest-api.mjs'
|
||||
export const BASE_URL = '/rest/v0'
|
||||
export { default as forEach } from 'lodash/forEach.js'
|
||||
export const NDJSON_CONTENT_TYPE = 'application/x-ndjson'
|
||||
@@ -142,13 +143,18 @@ export function safeParseComplexMatcher(string: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function limitAndFilterArray<T>(
|
||||
export async function limitAndFilterArray<T>(
|
||||
array: T[],
|
||||
{ filter, limit = Infinity }: { filter?: string | ((obj: T) => boolean); limit?: number } = {}
|
||||
): T[] {
|
||||
if (filter !== undefined) {
|
||||
const predicate = typeof filter === 'string' ? safeParseComplexMatcher(filter).createPredicate() : filter
|
||||
array = array.filter(predicate)
|
||||
{ filter, limit = Infinity }: { filter?: string | ((obj: T) => boolean); limit?: number },
|
||||
restApi: RestApi
|
||||
): Promise<T[]> {
|
||||
if (typeof filter === 'string') {
|
||||
const parsedFilter = safeParseComplexMatcher(filter)
|
||||
const userFilter = parsedFilter.createPredicate(await restApi.buildResolver(array, parsedFilter))
|
||||
|
||||
array = array.filter(userFilter)
|
||||
} else if (filter !== undefined) {
|
||||
array = array.filter(filter)
|
||||
}
|
||||
|
||||
if (limit < array.length) {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { BackupJobService } from '../backup-jobs/backup-job.service.mjs'
|
||||
import { BackupLogService } from '../backup-logs/backup-log.service.mjs'
|
||||
import { EventService } from '../events/event.service.mjs'
|
||||
import { NetworkService } from '../networks/network.service.mjs'
|
||||
import { BackupArchiveService } from '../backup-archives/backup-archive.service.mjs'
|
||||
import { SrService } from '../srs/sr.service.mjs'
|
||||
import { LicenseService } from '../licenses/license.service.mjs'
|
||||
import { BackupRepositoryService } from '../backup-repositories/backup-repository.service.mjs'
|
||||
@@ -114,14 +113,6 @@ export function setupContainer(xoApp: XoApp) {
|
||||
})
|
||||
.inSingletonScope()
|
||||
|
||||
iocContainer
|
||||
.bind(BackupArchiveService)
|
||||
.toDynamicValue(ctx => {
|
||||
const restApi = ctx.container.get(RestApi)
|
||||
return new BackupArchiveService(restApi)
|
||||
})
|
||||
.inSingletonScope()
|
||||
|
||||
iocContainer
|
||||
.bind(SrService)
|
||||
.toDynamicValue(ctx => {
|
||||
|
||||
@@ -52,7 +52,7 @@ export class MessageController extends XapiXoController<XoMessage> {
|
||||
> {
|
||||
let userfilter: (obj: XoMessage) => boolean = () => true
|
||||
if (filter !== undefined) {
|
||||
userfilter = safeParseComplexMatcher(filter).createPredicate()
|
||||
userfilter = safeParseComplexMatcher(filter).createPredicate(this.restApi.resolver)
|
||||
}
|
||||
const messagePredicate = (obj: XoMessage) => !alarmPredicate(obj) && userfilter(obj)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { iocContainer } from '../ioc/ioc.mjs'
|
||||
import type { Branded, NonXapiXoRecord, XapiXoRecord, XoRecord } from '@vates/types'
|
||||
import { ServiceIdentifier, ValidateError } from 'tsoa'
|
||||
import { ApiError } from '../helpers/error.helper.mjs'
|
||||
import type { Resolver } from 'complex-matcher'
|
||||
|
||||
export const ACL_MIDDLEWARE_NAME = '_aclMiddleware'
|
||||
|
||||
@@ -206,6 +207,7 @@ export function acl(acls: AclEntry | AclEntry[]) {
|
||||
}
|
||||
const invalidFields: { [key: string]: { message: string; value: unknown } } = {}
|
||||
const missingPrivilegeParams: AnyPrivilegeOnParam[] = []
|
||||
const objectsToResolve: object[] = []
|
||||
|
||||
for (const acl of _acls) {
|
||||
let objects: object[] = []
|
||||
@@ -262,6 +264,8 @@ export function acl(acls: AclEntry | AclEntry[]) {
|
||||
}
|
||||
}
|
||||
|
||||
objectsToResolve.push(...objects)
|
||||
|
||||
// We cast here to restore the discriminated union correlation between `resource` and `action`.
|
||||
// When rebuilding an object from individual properties of a discriminated union, TypeScript transform it into an union type
|
||||
// { resource: SupportedResource, action: SupportedAction<SupportedResource> }
|
||||
@@ -293,7 +297,14 @@ export function acl(acls: AclEntry | AclEntry[]) {
|
||||
return next(error)
|
||||
}
|
||||
|
||||
const missingPrivileges = getMissingPrivileges(missingPrivilegeParams, userPrivileges)
|
||||
let privilegeResolver: Resolver | undefined
|
||||
try {
|
||||
privilegeResolver = await restApi.buildPrivilegeResolver(objectsToResolve, userPrivileges)
|
||||
} catch (error) {
|
||||
return next(error)
|
||||
}
|
||||
|
||||
const missingPrivileges = getMissingPrivileges(missingPrivilegeParams, userPrivileges, privilegeResolver)
|
||||
if (missingPrivileges.length > 0) {
|
||||
return next(
|
||||
new ApiError('not enough privileges', 403, {
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { XapiXoRecord, XoApp, XoUser } from '@vates/types'
|
||||
|
||||
import type { Container } from 'inversify'
|
||||
import { safeParseComplexMatcher } from '../helpers/utils.helper.mjs'
|
||||
import * as CM from 'complex-matcher'
|
||||
import type { AnyPrivilege, SupportedResource } from '@xen-orchestra/acl'
|
||||
|
||||
const log = createLogger('xo:rest-api:error-handler')
|
||||
|
||||
@@ -28,6 +30,10 @@ export class RestApi {
|
||||
return this.#ioc
|
||||
}
|
||||
|
||||
get resolver(): XoApp['xapiObjectResolver'] {
|
||||
return this.#xoApp.xapiObjectResolver
|
||||
}
|
||||
|
||||
authenticateUser(...args: Parameters<XoApp['authenticateUser']>) {
|
||||
return this.#xoApp.authenticateUser(...args)
|
||||
}
|
||||
@@ -50,12 +56,35 @@ export class RestApi {
|
||||
return this.#xoApp.getObject(id, type)
|
||||
}
|
||||
|
||||
buildResolver(...args: Parameters<XoApp['buildResolver']>) {
|
||||
return this.#xoApp.buildResolver(...args)
|
||||
}
|
||||
|
||||
async buildPrivilegeResolver(
|
||||
objects: object[],
|
||||
userPrivileges: AnyPrivilege[],
|
||||
resource?: SupportedResource
|
||||
): Promise<CM.Resolver | undefined> {
|
||||
const nodes: CM.Node[] = []
|
||||
userPrivileges.forEach(userPrivilege => {
|
||||
if (userPrivilege.selector && (resource === undefined || userPrivilege.resource === resource)) {
|
||||
nodes.push(CM.parse(userPrivilege.selector))
|
||||
}
|
||||
})
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return this.buildResolver(objects, new CM.And(nodes))
|
||||
}
|
||||
|
||||
getObjectsByType<T extends XapiXoRecord>(
|
||||
type: T['type'],
|
||||
{ filter, ...opts }: { filter?: string | ((obj: T) => boolean); limit?: number } = {}
|
||||
): Record<T['id'], T> {
|
||||
if (filter !== undefined && typeof filter === 'string') {
|
||||
filter = safeParseComplexMatcher(filter).createPredicate()
|
||||
filter = safeParseComplexMatcher(filter).createPredicate(this.resolver)
|
||||
}
|
||||
return this.#xoApp.getObjectsByType(type, { filter, ...opts }) ?? ({} as Record<T['id'], T>)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export class TaskController extends XoController<XoTask> {
|
||||
throw new ApiError('watch=true requires ndjson=true', 400)
|
||||
}
|
||||
|
||||
const userFilter = filter === undefined ? undefined : safeParseComplexMatcher(filter).createPredicate()
|
||||
const complexMatcher = filter === undefined ? undefined : safeParseComplexMatcher(filter)
|
||||
const mapper = makeObjectMapper(req)
|
||||
const stream = new Transform({
|
||||
objectMode: true,
|
||||
@@ -123,6 +123,14 @@ export class TaskController extends XoController<XoTask> {
|
||||
}
|
||||
}
|
||||
|
||||
// these handlers run on an event emitter which does not await them, so a
|
||||
// rejection would otherwise be unhandled and silently lose events for
|
||||
// this client: destroy it instead, it reconnects and refetches
|
||||
const onError = (error: unknown) => {
|
||||
warn('cannot decide whether to send this task, the connection is going to be destroyed', { error })
|
||||
req.destroy()
|
||||
}
|
||||
|
||||
const onSigTerm = () => {
|
||||
req.destroy()
|
||||
}
|
||||
@@ -139,7 +147,8 @@ export class TaskController extends XoController<XoTask> {
|
||||
process.on('SIGTERM', onSigTerm)
|
||||
|
||||
const userId = this.restApi.getCurrentUser().id
|
||||
const update = async (task: XoTask) => {
|
||||
|
||||
const shouldSend = async (task: XoTask): Promise<boolean> => {
|
||||
const user = await this.restApi.xoApp.getUser(userId)
|
||||
const userPrivileges =
|
||||
user.permission === 'admin'
|
||||
@@ -147,24 +156,40 @@ export class TaskController extends XoController<XoTask> {
|
||||
: ((await this.restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[])
|
||||
|
||||
if (
|
||||
hasPrivilegeOn({ user, userPrivileges, action: 'read', resource: 'task', objects: task }) &&
|
||||
(userFilter === undefined || userFilter(task))
|
||||
hasPrivilegeOn(
|
||||
{ user, userPrivileges, action: 'read', resource: 'task', objects: task },
|
||||
await this.restApi.buildPrivilegeResolver([task], userPrivileges, 'task')
|
||||
)
|
||||
) {
|
||||
safeWrite(['update', task])
|
||||
if (complexMatcher === undefined) {
|
||||
return true
|
||||
} else {
|
||||
const resolver = await this.restApi.buildResolver([task], complexMatcher)
|
||||
if (complexMatcher.createPredicate(resolver)(task)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const update = async (task: XoTask) => {
|
||||
try {
|
||||
if (await shouldSend(task)) {
|
||||
safeWrite(['update', task])
|
||||
}
|
||||
} catch (error) {
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
const remove = async (task: XoTask) => {
|
||||
const user = await this.restApi.xoApp.getUser(userId)
|
||||
const userPrivileges =
|
||||
user.permission === 'admin'
|
||||
? []
|
||||
: ((await this.restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[])
|
||||
|
||||
if (
|
||||
hasPrivilegeOn({ user, userPrivileges, action: 'read', resource: 'task', objects: task }) &&
|
||||
(userFilter === undefined || userFilter(task))
|
||||
) {
|
||||
safeWrite(['remove', { id: task.id }])
|
||||
try {
|
||||
if (await shouldSend(task)) {
|
||||
safeWrite(['remove', { id: task.id }])
|
||||
}
|
||||
} catch (error) {
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,9 +254,18 @@ export class TaskController extends XoController<XoTask> {
|
||||
const userPrivileges =
|
||||
user.permission === 'admin' ? [] : ((await this.restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[])
|
||||
|
||||
const deletePromises: Promise<void>[] = []
|
||||
const tasks: XoTask[] = []
|
||||
for await (const task of this.restApi.tasks.list()) {
|
||||
if (hasPrivilegeOn({ user, userPrivileges, resource: 'task', action: 'delete', objects: task })) {
|
||||
tasks.push(task)
|
||||
}
|
||||
|
||||
const privilegeResolver = await this.restApi.buildPrivilegeResolver(tasks, userPrivileges, 'task')
|
||||
|
||||
const deletePromises: Promise<void>[] = []
|
||||
for (const task of tasks) {
|
||||
if (
|
||||
hasPrivilegeOn({ user, userPrivileges, resource: 'task', action: 'delete', objects: task }, privilegeResolver)
|
||||
) {
|
||||
deletePromises.push(this.restApi.tasks.deleteLog(task.id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ export class UserController extends XoController<XoUser> {
|
||||
const user = await this.getObject(id as XoUser['id'])
|
||||
const groups = await Promise.all(user.groups.map(group => this.restApi.xoApp.getGroup(group)))
|
||||
|
||||
return this.sendObjects(limitAndFilterArray(groups, { filter }), req, {
|
||||
return this.sendObjects(await limitAndFilterArray(groups, { filter }, this.restApi), req, {
|
||||
path: 'groups',
|
||||
limit,
|
||||
privilege: { action: 'read', resource: 'group' },
|
||||
@@ -320,8 +320,7 @@ export class UserController extends XoController<XoUser> {
|
||||
}
|
||||
|
||||
const tokens = await this.restApi.xoApp.getAuthenticationTokensForUser(user.id)
|
||||
|
||||
return limitAndFilterArray(tokens, { filter, limit })
|
||||
return limitAndFilterArray(tokens, { filter, limit }, this.restApi)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -459,8 +458,7 @@ export class UserController extends XoController<XoUser> {
|
||||
const currentUser = this.restApi.getCurrentUser()
|
||||
|
||||
const userPrivileges = (await this.restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[]
|
||||
|
||||
return this.sendObjects(limitAndFilterArray(userPrivileges, { filter }), req, {
|
||||
return this.sendObjects(await limitAndFilterArray(userPrivileges, { filter }, this.restApi), req, {
|
||||
path: 'acl-privileges',
|
||||
limit,
|
||||
privilege: currentUser.id === user.id ? undefined : { action: 'read', resource: 'acl-privilege' },
|
||||
|
||||
@@ -159,7 +159,7 @@ export class VmControllerController extends XapiXoController<XoVmController> {
|
||||
@Security('*', ['acl'])
|
||||
@Tags('vdis')
|
||||
@Response(notFoundResp.status, notFoundResp.description)
|
||||
getVmControllerVdis(
|
||||
async getVmControllerVdis(
|
||||
@Request() req: ExRequest,
|
||||
@Path() id: string,
|
||||
@Query() fields?: string,
|
||||
@@ -167,9 +167,9 @@ export class VmControllerController extends XapiXoController<XoVmController> {
|
||||
@Query() markdown?: boolean,
|
||||
@Query() filter?: string,
|
||||
@Query() limit?: number
|
||||
): SendObjects<Partial<Unbrand<XoVdi>>> {
|
||||
): Promise<SendObjects<Partial<Unbrand<XoVdi>>>> {
|
||||
const vdis = this.#vmService.getVmVdis(id as XoVmController['id'], 'VM-controller')
|
||||
return this.sendObjects(limitAndFilterArray(vdis, { filter }), req, {
|
||||
return this.sendObjects(await limitAndFilterArray(vdis, { filter }, this.restApi), req, {
|
||||
path: obj => obj.type.toLowerCase() + 's',
|
||||
limit,
|
||||
privilege: { action: 'read', resource: 'vdi' },
|
||||
|
||||
@@ -212,7 +212,7 @@ export class VmSnapshotController extends XapiXoController<XoVmSnapshot> {
|
||||
@Security('*', ['acl'])
|
||||
@Tags('vdis')
|
||||
@Response(notFoundResp.status, notFoundResp.description)
|
||||
getVmSnapshotVdis(
|
||||
async getVmSnapshotVdis(
|
||||
@Request() req: ExRequest,
|
||||
@Path() id: string,
|
||||
@Query() fields?: string,
|
||||
@@ -220,9 +220,9 @@ export class VmSnapshotController extends XapiXoController<XoVmSnapshot> {
|
||||
@Query() markdown?: boolean,
|
||||
@Query() filter?: string,
|
||||
@Query() limit?: number
|
||||
): SendObjects<Partial<Unbrand<XoVdiSnapshot>>> {
|
||||
): Promise<SendObjects<Partial<Unbrand<XoVdiSnapshot>>>> {
|
||||
const vdis = this.#vmService.getVmVdis(id as XoVmSnapshot['id'], 'VM-snapshot')
|
||||
return this.sendObjects(limitAndFilterArray(vdis, { filter }), req, {
|
||||
return this.sendObjects(await limitAndFilterArray(vdis, { filter }, this.restApi), req, {
|
||||
path: obj => obj.type.toLowerCase() + 's',
|
||||
limit,
|
||||
privilege: { action: 'read', resource: 'vdi' },
|
||||
|
||||
@@ -213,7 +213,7 @@ export class VmTemplateController extends XapiXoController<XoVmTemplate> {
|
||||
@Security('*', ['acl'])
|
||||
@Tags('vdis')
|
||||
@Response(notFoundResp.status, notFoundResp.description)
|
||||
getVmTemplateVdis(
|
||||
async getVmTemplateVdis(
|
||||
@Request() req: ExRequest,
|
||||
@Path() id: string,
|
||||
@Query() fields?: string,
|
||||
@@ -221,9 +221,9 @@ export class VmTemplateController extends XapiXoController<XoVmTemplate> {
|
||||
@Query() markdown?: boolean,
|
||||
@Query() filter?: string,
|
||||
@Query() limit?: number
|
||||
): SendObjects<Partial<Unbrand<XoVdi>>> {
|
||||
): Promise<SendObjects<Partial<Unbrand<XoVdi>>>> {
|
||||
const vdis = this.#vmService.getVmVdis(id as XoVmTemplate['id'], 'VM-template')
|
||||
return this.sendObjects(limitAndFilterArray(vdis, { filter }), req, {
|
||||
return this.sendObjects(await limitAndFilterArray(vdis, { filter }, this.restApi), req, {
|
||||
path: obj => obj.type.toLowerCase() + 's',
|
||||
limit,
|
||||
privilege: { action: 'read', resource: 'vdi' },
|
||||
|
||||
@@ -864,7 +864,7 @@ export class VmController extends XapiXoController<XoVm> {
|
||||
@Security('*', ['acl'])
|
||||
@Tags('vdis')
|
||||
@Response(notFoundResp.status, notFoundResp.description)
|
||||
getVmVdis(
|
||||
async getVmVdis(
|
||||
@Request() req: ExRequest,
|
||||
@Path() id: string,
|
||||
@Query() fields?: string,
|
||||
@@ -872,9 +872,9 @@ export class VmController extends XapiXoController<XoVm> {
|
||||
@Query() markdown?: boolean,
|
||||
@Query() filter?: string,
|
||||
@Query() limit?: number
|
||||
): SendObjects<Partial<Unbrand<XoVdi>>> {
|
||||
): Promise<SendObjects<Partial<Unbrand<XoVdi>>>> {
|
||||
const vdis = this.#vmService.getVmVdis(id as XoVm['id'], 'VM')
|
||||
return this.sendObjects(limitAndFilterArray(vdis, { filter }), req, {
|
||||
return this.sendObjects(await limitAndFilterArray(vdis, { filter }, this.restApi), req, {
|
||||
path: obj => obj.type.toLowerCase() + 's',
|
||||
limit,
|
||||
privilege: { action: 'read', resource: 'vdi' },
|
||||
@@ -915,7 +915,7 @@ export class VmController extends XapiXoController<XoVm> {
|
||||
}
|
||||
}
|
||||
|
||||
return this.sendObjects(limitAndFilterArray(vmBackupJobs, { filter }), req, {
|
||||
return this.sendObjects(await limitAndFilterArray(vmBackupJobs, { filter }, this.restApi), req, {
|
||||
path: '/backup-jobs',
|
||||
limit,
|
||||
privilege: { action: 'read', resource: 'backup-job' },
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
- [XO5] Improve event handling (PR [#10568](https://github.com/vatesfr/xen-orchestra/pull/10568))
|
||||
- [Netbox] Support Netbox v4.7.x [#10565](https://github.com/vatesfr/xen-orchestra/issues/10565) (PR [#10574](https://github.com/vatesfr/xen-orchestra/pull/10574))
|
||||
- [V2V] Import VMs from ESXi 6.x hosts: their disks are read one request at a time, so more slowly than from ESXi 7 and 8 (PR [#10590](https://github.com/vatesfr/xen-orchestra/pull/10590))
|
||||
- [Complex-matcher] Add `[resolve]` for deep resolution, with `[some]` and `[every]` quantifiers (PR [#9911](https://github.com/vatesfr/xen-orchestra/pull/9911))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
@@ -124,6 +125,7 @@
|
||||
- @xen-orchestra/web minor
|
||||
- @xen-orchestra/web-core minor
|
||||
- @xen-orchestra/xapi minor
|
||||
- complex-matcher minor
|
||||
- vhd-lib minor
|
||||
- xen-api minor
|
||||
- xo-acl-resolver minor
|
||||
|
||||
@@ -1,17 +1,57 @@
|
||||
```js
|
||||
import * as CM from 'complex-matcher'
|
||||
|
||||
const places = {
|
||||
gotham: { name: 'Gotham City', planet: 'Earth' },
|
||||
krypton: { name: 'Krypton', planet: 'Krypton', destroyed: true },
|
||||
themyscira: { name: 'Themyscira', planet: 'Earth' },
|
||||
}
|
||||
|
||||
const characters = [
|
||||
{ name: 'Catwoman', costumeColor: 'black' },
|
||||
{ name: 'Superman', costumeColor: 'blue', hasCape: true },
|
||||
{ name: 'Wonder Woman', costumeColor: 'blue' },
|
||||
{ name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
|
||||
{ name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
|
||||
{ name: 'Wonder Woman', costumeColor: 'blue', originId: 'themyscira', visitedIds: [] },
|
||||
]
|
||||
|
||||
const predicate = CM.parse('costumeColor:blue hasCape?').createPredicate()
|
||||
// [resolve] requires a resolver, calling createPredicate() without one throws.
|
||||
// [resolve] can be nested up to a depth of 5, beyond it throws at parse time.
|
||||
const resolver = id => places[id]
|
||||
|
||||
characters.filter(predicate)
|
||||
// --------------------------------------------
|
||||
|
||||
const costumeColorPredicate = CM.parse('costumeColor:blue hasCape?').createPredicate()
|
||||
characters.filter(costumeColorPredicate)
|
||||
// [
|
||||
// { name: 'Superman', costumeColor: 'blue', hasCape: true },
|
||||
// { name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
|
||||
// ]
|
||||
|
||||
const originPlanetPredicate = CM.parse('originId:[resolve]:planet:Earth').createPredicate(resolver)
|
||||
characters.filter(originPlanetPredicate)
|
||||
// [
|
||||
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
|
||||
// { name: 'Wonder Woman', costumeColor: 'blue', originId: 'themyscira', visitedIds: [] },
|
||||
// ]
|
||||
|
||||
const earthPredicate = CM.parse('visitedIds:[resolve]:planet:Earth').createPredicate(resolver)
|
||||
characters.filter(earthPredicate)
|
||||
// No quantifier defaults to [some].
|
||||
// [
|
||||
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
|
||||
// { name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
|
||||
// ]
|
||||
|
||||
const someEarthPredicate = CM.parse('visitedIds:[resolve]:[some]:planet:Earth').createPredicate(resolver)
|
||||
characters.filter(someEarthPredicate)
|
||||
// [
|
||||
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
|
||||
// { name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
|
||||
// ]
|
||||
|
||||
const everyEarthPredicate = CM.parse('visitedIds:[resolve]:[every]:planet:Earth').createPredicate(resolver)
|
||||
characters.filter(everyEarthPredicate)
|
||||
// Wonder Woman is excluded because [every] requires at least one resolved object.
|
||||
// [
|
||||
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
|
||||
// ]
|
||||
|
||||
new CM.String('foo').createPredicate()
|
||||
|
||||
@@ -19,17 +19,57 @@ npm install --save complex-matcher
|
||||
```js
|
||||
import * as CM from 'complex-matcher'
|
||||
|
||||
const places = {
|
||||
gotham: { name: 'Gotham City', planet: 'Earth' },
|
||||
krypton: { name: 'Krypton', planet: 'Krypton', destroyed: true },
|
||||
themyscira: { name: 'Themyscira', planet: 'Earth' },
|
||||
}
|
||||
|
||||
const characters = [
|
||||
{ name: 'Catwoman', costumeColor: 'black' },
|
||||
{ name: 'Superman', costumeColor: 'blue', hasCape: true },
|
||||
{ name: 'Wonder Woman', costumeColor: 'blue' },
|
||||
{ name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
|
||||
{ name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
|
||||
{ name: 'Wonder Woman', costumeColor: 'blue', originId: 'themyscira', visitedIds: [] },
|
||||
]
|
||||
|
||||
const predicate = CM.parse('costumeColor:blue hasCape?').createPredicate()
|
||||
// [resolve] requires a resolver, calling createPredicate() without one throws.
|
||||
// [resolve] can be nested up to a depth of 5, beyond it throws at parse time.
|
||||
const resolver = id => places[id]
|
||||
|
||||
characters.filter(predicate)
|
||||
// --------------------------------------------
|
||||
|
||||
const costumeColorPredicate = CM.parse('costumeColor:blue hasCape?').createPredicate()
|
||||
characters.filter(costumeColorPredicate)
|
||||
// [
|
||||
// { name: 'Superman', costumeColor: 'blue', hasCape: true },
|
||||
// { name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
|
||||
// ]
|
||||
|
||||
const originPlanetPredicate = CM.parse('originId:[resolve]:planet:Earth').createPredicate(resolver)
|
||||
characters.filter(originPlanetPredicate)
|
||||
// [
|
||||
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
|
||||
// { name: 'Wonder Woman', costumeColor: 'blue', originId: 'themyscira', visitedIds: [] },
|
||||
// ]
|
||||
|
||||
const earthPredicate = CM.parse('visitedIds:[resolve]:planet:Earth').createPredicate(resolver)
|
||||
characters.filter(earthPredicate)
|
||||
// No quantifier defaults to [some].
|
||||
// [
|
||||
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
|
||||
// { name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
|
||||
// ]
|
||||
|
||||
const someEarthPredicate = CM.parse('visitedIds:[resolve]:[some]:planet:Earth').createPredicate(resolver)
|
||||
characters.filter(someEarthPredicate)
|
||||
// [
|
||||
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
|
||||
// { name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
|
||||
// ]
|
||||
|
||||
const everyEarthPredicate = CM.parse('visitedIds:[resolve]:[every]:planet:Earth').createPredicate(resolver)
|
||||
characters.filter(everyEarthPredicate)
|
||||
// Wonder Woman is excluded because [every] requires at least one resolved object.
|
||||
// [
|
||||
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
|
||||
// ]
|
||||
|
||||
new CM.String('foo').createPredicate()
|
||||
|
||||
20
packages/complex-matcher/index.d.ts
vendored
20
packages/complex-matcher/index.d.ts
vendored
@@ -1,8 +1,10 @@
|
||||
export type ComparisonOperator = '>' | '>=' | '<' | '<='
|
||||
|
||||
export type Resolver = (id: string) => object | undefined
|
||||
|
||||
declare class BaseNode {
|
||||
createPredicate(): (value: any) => boolean
|
||||
match(value: any): boolean
|
||||
createPredicate(resolver?: Resolver): (value: any) => boolean
|
||||
match(value: any, resolver?: Resolver): boolean
|
||||
toString(isNested?: boolean): string
|
||||
}
|
||||
|
||||
@@ -74,10 +76,24 @@ export class TruthyProperty extends BaseNode {
|
||||
name: string
|
||||
}
|
||||
|
||||
export class Quantifier extends BaseNode {
|
||||
constructor(child: BaseNode, mode?: 'some' | 'every', implicit?: boolean)
|
||||
child: BaseNode
|
||||
mode: 'some' | 'every'
|
||||
implicit: boolean
|
||||
}
|
||||
|
||||
export class Resolve extends BaseNode {
|
||||
constructor(child: Quantifier)
|
||||
child: Quantifier
|
||||
}
|
||||
|
||||
export function parse(input: string): Node
|
||||
|
||||
export function getPropertyClausesStrings(node: Node): {
|
||||
[key: string]: string[]
|
||||
}
|
||||
|
||||
export function getResolveFields(node: Node): { path: string[]; resolveNode: Resolve }[]
|
||||
|
||||
export function setPropertyClause(node: Node | undefined, name: string, child: Node | string): Node
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
const CM = require('./')
|
||||
|
||||
exports.pattern = 'foo !"\\\\ \\"" name:|(wonderwoman batman) hasCape? age:32 chi*go /^foo\\/bar\\./i'
|
||||
exports.pattern =
|
||||
'foo !"\\\\ \\"" name:|(wonderwoman batman) hasCape? age:32 chi*go /^foo\\/bar\\./i object:[resolve]:tags:tag'
|
||||
|
||||
exports.ast = new CM.And([
|
||||
new CM.String('foo'),
|
||||
@@ -12,4 +13,8 @@ exports.ast = new CM.And([
|
||||
new CM.Property('age', new CM.NumberOrStringNode('32')),
|
||||
new CM.GlobPattern('chi*go'),
|
||||
new CM.RegExp('^foo/bar\\.', 'i'),
|
||||
new CM.Property(
|
||||
'object',
|
||||
new CM.Resolve(new CM.Quantifier(new CM.Property('tags', new CM.String('tag')), 'some', true))
|
||||
),
|
||||
])
|
||||
|
||||
@@ -14,6 +14,8 @@ const RAW_STRING_SYMBOLS = {
|
||||
$: true,
|
||||
}
|
||||
|
||||
const MAX_RESOLVE_DEPTH = 5
|
||||
|
||||
const isRawStringChar = c =>
|
||||
(c >= '0' && c <= '9') || c in RAW_STRING_SYMBOLS || !(c === c.toUpperCase() && c === c.toLowerCase())
|
||||
|
||||
@@ -27,8 +29,8 @@ const isRawString = string => {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
class Node {
|
||||
createPredicate() {
|
||||
return value => this.match(value)
|
||||
createPredicate(resolver) {
|
||||
return value => this.match(value, resolver)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +57,8 @@ class And extends Node {
|
||||
this.children = children
|
||||
}
|
||||
|
||||
match(value) {
|
||||
return this.children.every(child => child.match(value))
|
||||
match(value, resolver) {
|
||||
return this.children.every(child => child.match(value, resolver))
|
||||
}
|
||||
|
||||
toString(isNested) {
|
||||
@@ -105,8 +107,8 @@ class Or extends Node {
|
||||
this.children = children
|
||||
}
|
||||
|
||||
match(value) {
|
||||
return this.children.some(child => child.match(value))
|
||||
match(value, resolver) {
|
||||
return this.children.some(child => child.match(value, resolver))
|
||||
}
|
||||
|
||||
toString() {
|
||||
@@ -122,8 +124,8 @@ class Not extends Node {
|
||||
this.child = child
|
||||
}
|
||||
|
||||
match(value) {
|
||||
return !this.child.match(value)
|
||||
match(value, resolver) {
|
||||
return !this.child.match(value, resolver)
|
||||
}
|
||||
|
||||
toString() {
|
||||
@@ -188,8 +190,8 @@ class Property extends Node {
|
||||
this.child = child
|
||||
}
|
||||
|
||||
match(value) {
|
||||
return value != null && this.child.match(value[this.name])
|
||||
match(value, resolver) {
|
||||
return value != null && this.child.match(value[this.name], resolver)
|
||||
}
|
||||
|
||||
toString() {
|
||||
@@ -314,6 +316,66 @@ class TruthyProperty extends Node {
|
||||
}
|
||||
exports.TruthyProperty = TruthyProperty
|
||||
|
||||
class Resolve extends Node {
|
||||
constructor(child) {
|
||||
super()
|
||||
|
||||
this.child = child
|
||||
}
|
||||
|
||||
match(value, resolver) {
|
||||
if (resolver === undefined) {
|
||||
throw new Error('[resolve] requires a resolver')
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ids = Array.isArray(value) ? value : [value]
|
||||
const objects = []
|
||||
for (const id of ids) {
|
||||
objects.push(resolver(id))
|
||||
}
|
||||
|
||||
return this.child.match(objects, resolver)
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `[resolve]:${this.child.toString(true)}`
|
||||
}
|
||||
}
|
||||
exports.Resolve = Resolve
|
||||
|
||||
class Quantifier extends Node {
|
||||
constructor(child, mode = 'some', implicit = false) {
|
||||
super()
|
||||
|
||||
this.child = child
|
||||
this.mode = mode
|
||||
this.implicit = implicit
|
||||
}
|
||||
|
||||
match(values, resolver) {
|
||||
const array = Array.isArray(values) ? values : [values]
|
||||
|
||||
if (this.mode === 'some') {
|
||||
return array.some(value => value != null && this.child.match(value, resolver))
|
||||
} else if (this.mode === 'every') {
|
||||
// nothing resolved means nothing matches
|
||||
return array.length > 0 && array.every(value => value != null && this.child.match(value, resolver))
|
||||
}
|
||||
|
||||
throw new Error(`${this.mode} quantifier not supported`)
|
||||
}
|
||||
|
||||
toString(isNested) {
|
||||
// an implicit 'some' is not re-emitted so that toString still round-trips
|
||||
return this.implicit ? this.child.toString(isNested) : `[${this.mode}]:${this.child.toString(true)}`
|
||||
}
|
||||
}
|
||||
exports.Quantifier = Quantifier
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// https://gist.github.com/yelouafi/556e5159e869952335e01f6b473c4ec1
|
||||
@@ -533,6 +595,11 @@ const parser = P.grammar({
|
||||
P.seq(P.regex(/[<>]=?/), r.rawString).map(([op, val]) => {
|
||||
return new Comparison(op, +val)
|
||||
}),
|
||||
P.seq(P.text('[resolve]'), P.text(':'), r.ws, r.term).map(
|
||||
_ => new Resolve(_[3] instanceof Quantifier ? _[3] : new Quantifier(_[3], 'some', true))
|
||||
),
|
||||
P.seq(P.text('[some]'), P.text(':'), r.ws, r.term).map(_ => new Quantifier(_[3], 'some')),
|
||||
P.seq(P.text('[every]'), P.text(':'), r.ws, r.term).map(_ => new Quantifier(_[3], 'every')),
|
||||
P.seq(r.property, r.ws, P.text(':'), r.ws, r.term).map(_ => new Property(_[0], _[4])),
|
||||
P.seq(r.property, P.text('?')).map(_ => new TruthyProperty(_[0])),
|
||||
r.value
|
||||
@@ -548,7 +615,48 @@ const parser = P.grammar({
|
||||
),
|
||||
ws: P.regex(/\s*/),
|
||||
}).default
|
||||
exports.parse = parser.parse.bind(parser)
|
||||
|
||||
const _checkResolveConstraints = (node, parent) => {
|
||||
let depth = 0
|
||||
|
||||
if (node instanceof Resolve && !(parent instanceof Property)) {
|
||||
throw new Error(`[resolve] must follow a property`)
|
||||
}
|
||||
|
||||
if (node instanceof Quantifier && !(parent instanceof Resolve)) {
|
||||
throw new Error(`[some]/[every] must follow a [resolve]`)
|
||||
}
|
||||
|
||||
if (node.children !== undefined) {
|
||||
for (const child of node.children) {
|
||||
const childDepth = _checkResolveConstraints(child, node)
|
||||
if (childDepth > depth) {
|
||||
depth = childDepth
|
||||
}
|
||||
}
|
||||
} else if (node.child !== undefined) {
|
||||
depth = _checkResolveConstraints(node.child, node)
|
||||
}
|
||||
|
||||
if (node instanceof Resolve) {
|
||||
depth += 1
|
||||
if (depth > MAX_RESOLVE_DEPTH) {
|
||||
throw new Error(`[resolve] cannot be nested more than ${MAX_RESOLVE_DEPTH} levels`)
|
||||
}
|
||||
}
|
||||
|
||||
return depth
|
||||
}
|
||||
|
||||
const _parse = parser.parse.bind(parser)
|
||||
|
||||
exports.parse = function parse(input) {
|
||||
const node = _parse(input)
|
||||
|
||||
_checkResolveConstraints(node)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -622,6 +730,26 @@ exports.getPropertyClausesStrings = function getPropertyClausesStrings(node) {
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
exports.getResolveFields = function getResolveFields(node, prefix = []) {
|
||||
const result = []
|
||||
|
||||
if (node instanceof Property) {
|
||||
if (node.child instanceof Resolve) {
|
||||
result.push({ path: [...prefix, node.name], resolveNode: node.child })
|
||||
} else {
|
||||
result.push(...getResolveFields(node.child, [...prefix, node.name]))
|
||||
}
|
||||
} else if (node instanceof And || node instanceof Or) {
|
||||
node.children.forEach(childNode => {
|
||||
result.push(...getResolveFields(childNode, prefix))
|
||||
})
|
||||
} else if (node instanceof Not || node instanceof Quantifier) {
|
||||
result.push(...getResolveFields(node.child, prefix))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
exports.setPropertyClause = function setPropertyClause(node, name, child) {
|
||||
const property = child && new Property(name, typeof child === 'string' ? new StringNode(child) : child)
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ const { ast, pattern } = require('./index.fixtures')
|
||||
const {
|
||||
getPropertyClausesStrings,
|
||||
Comparison,
|
||||
getResolveFields,
|
||||
GlobPattern,
|
||||
Null,
|
||||
NumberNode,
|
||||
NumberOrStringNode,
|
||||
parse,
|
||||
Property,
|
||||
Resolve,
|
||||
setPropertyClause,
|
||||
StringNode,
|
||||
} = require('./')
|
||||
@@ -103,6 +105,70 @@ describe('NumberOrStringNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getResolveFields', () => {
|
||||
it('returns field name and resolve node for a single property', () => {
|
||||
const resolveFields = getResolveFields(parse('object:[resolve]:tags:tag'))
|
||||
assert.deepEqual(resolveFields[0].path, ['object'])
|
||||
assert(resolveFields[0].resolveNode instanceof Resolve)
|
||||
})
|
||||
|
||||
it('returns empty array when no [resolve] is present', () => {
|
||||
const resolveFields = getResolveFields(parse('tags:tag'))
|
||||
assert.equal(resolveFields.length, 0)
|
||||
})
|
||||
|
||||
it('returns all field names and resolve nodes for an And node', () => {
|
||||
const resolveFields = getResolveFields(parse('object1:[resolve]:tags:tag object2:[resolve]:tags:tag'))
|
||||
assert.deepEqual(resolveFields[0].path, ['object1'])
|
||||
assert(resolveFields[0].resolveNode instanceof Resolve)
|
||||
assert.deepEqual(resolveFields[1].path, ['object2'])
|
||||
assert(resolveFields[1].resolveNode instanceof Resolve)
|
||||
})
|
||||
|
||||
it('only returns properties with [resolve] in a mixed And node', () => {
|
||||
const resolveFields2 = getResolveFields(parse('object3:[resolve]:tags:tag tags:tag'))
|
||||
assert.equal(resolveFields2.length, 1)
|
||||
assert.deepEqual(resolveFields2[0].path, ['object3'])
|
||||
assert(resolveFields2[0].resolveNode instanceof Resolve)
|
||||
})
|
||||
|
||||
it('returns all field names and resolve nodes for an Or node', () => {
|
||||
const resolveFields = getResolveFields(parse('|(object1:[resolve]:tags:tag object2:[resolve]:tags:tag)'))
|
||||
assert.equal(resolveFields.length, 2)
|
||||
assert.deepEqual(resolveFields[0].path, ['object1'])
|
||||
assert(resolveFields[0].resolveNode instanceof Resolve)
|
||||
assert.deepEqual(resolveFields[1].path, ['object2'])
|
||||
assert(resolveFields[1].resolveNode instanceof Resolve)
|
||||
})
|
||||
|
||||
it('returns field names and resolve nodes inside a Not node', () => {
|
||||
const resolveFields = getResolveFields(parse('!(object:[resolve]:tags:tag)'))
|
||||
assert.equal(resolveFields.length, 1)
|
||||
assert.deepEqual(resolveFields[0].path, ['object'])
|
||||
assert(resolveFields[0].resolveNode instanceof Resolve)
|
||||
})
|
||||
|
||||
it('returns the full path and resolve node for a nested property', () => {
|
||||
const resolveFields = getResolveFields(parse('properties:userId:[resolve]:tags:tag'))
|
||||
assert.equal(resolveFields.length, 1)
|
||||
assert.deepEqual(resolveFields[0].path, ['properties', 'userId'])
|
||||
assert(resolveFields[0].resolveNode instanceof Resolve)
|
||||
})
|
||||
|
||||
it('prefixes paths for resolves inside a grouped property', () => {
|
||||
const resolveFields = getResolveFields(parse('object1:(object2:[resolve]:tags:tag object3:[resolve]:tags:tag)'))
|
||||
assert.equal(resolveFields.length, 2)
|
||||
assert.deepEqual(resolveFields[0].path, ['object1', 'object2'])
|
||||
assert.deepEqual(resolveFields[1].path, ['object1', 'object3'])
|
||||
})
|
||||
|
||||
it('ignores non-resolve terms in a group', () => {
|
||||
const resolveFields = getResolveFields(parse('object1:(object2:[resolve]:tags:tag property)'))
|
||||
assert.equal(resolveFields.length, 1)
|
||||
assert.deepEqual(resolveFields[0].path, ['object1', 'object2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('setPropertyClause', () => {
|
||||
it('creates a node if none passed', () => {
|
||||
assert.equal(setPropertyClause(undefined, 'foo', 'bar').toString(), 'foo:bar')
|
||||
@@ -128,3 +194,219 @@ describe('setPropertyClause', () => {
|
||||
it('toString', () => {
|
||||
assert.equal(ast.toString(), pattern)
|
||||
})
|
||||
|
||||
describe('resolve', () => {
|
||||
const target = { objects: 'object' }
|
||||
const targetArray = { objects: ['object'] }
|
||||
const store = { object: { tags: ['tag'], nested: 'nested-object' }, 'nested-object': { tags: ['nested-tag'] } }
|
||||
const notFoundStore = { 'not-object': { tags: ['tag'] } }
|
||||
|
||||
it('match a single ID', () => {
|
||||
assert(parse('objects:[resolve]:tags:tag').createPredicate(id => store[id])(target))
|
||||
})
|
||||
|
||||
it('match an array of IDs', () => {
|
||||
assert(parse('objects:[resolve]:tags:tag').createPredicate(id => store[id])(targetArray))
|
||||
})
|
||||
|
||||
it('match with recursive resolution', () => {
|
||||
assert(parse('objects:[resolve]:nested:[resolve]:tags:nested-tag').createPredicate(id => store[id])(target))
|
||||
})
|
||||
|
||||
it("doesn't match when resolved object does not satisfy the predicate", () => {
|
||||
assert(!parse('objects:[resolve]:tags:not-found-tag').createPredicate(id => store[id])(target))
|
||||
})
|
||||
|
||||
it("doesn't match when resolved object not found", () => {
|
||||
assert(!parse('objects:[resolve]:tags:tag').createPredicate(id => notFoundStore[id])(target))
|
||||
})
|
||||
|
||||
it("doesn't match recursively when resolved object does not satisfy the predicate", () => {
|
||||
assert(!parse('objects:[resolve]:nested:[resolve]:tags:not-found-tag').createPredicate(id => store[id])(target))
|
||||
})
|
||||
|
||||
it('matches through a grouped property', () => {
|
||||
const filter = 'outer:(objects:[resolve]:tags:tag other:x)'
|
||||
const predicate = parse(filter).createPredicate(id => store[id])
|
||||
|
||||
assert(predicate({ outer: { objects: 'object', other: 'x' } }))
|
||||
// the resolved object matches but the sibling term of the group does not
|
||||
assert(!predicate({ outer: { objects: 'object', other: 'nope' } }))
|
||||
// the sibling matches but the id resolves to nothing
|
||||
assert(!predicate({ outer: { objects: 'ghost', other: 'x' } }))
|
||||
})
|
||||
|
||||
it('throws when no resolver is provided', () => {
|
||||
assert.throws(() => parse('objects:[resolve]:tags:tag').createPredicate()(target), {
|
||||
message: '[resolve] requires a resolver',
|
||||
})
|
||||
})
|
||||
|
||||
it('toString round-trips correctly', () => {
|
||||
assert.equal(parse('objects:[resolve]:tags:tag').toString(), 'objects:[resolve]:tags:tag')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve quantifiers', () => {
|
||||
const store = {
|
||||
'object-1': { tags: ['tag'] },
|
||||
'object-2': { tags: ['tag'] },
|
||||
'object-3': { tags: ['other'] },
|
||||
}
|
||||
const resolver = id => store[id]
|
||||
|
||||
const allResolve = { objects: ['object-1', 'object-2'] }
|
||||
const someFail = { objects: ['object-1', 'object-3'] }
|
||||
const oneUnresolvable = { objects: ['object-1', 'object-2', 'ghost'] }
|
||||
const allUnresolvable = { objects: ['ghost-1', 'ghost-2'] }
|
||||
const emptyArray = { objects: [] }
|
||||
const scalar = { objects: 'object-1' }
|
||||
|
||||
const every = 'objects:[resolve]:[every]:tags:tag'
|
||||
const some = 'objects:[resolve]:[some]:tags:tag'
|
||||
|
||||
it('[every] matches when all resolved objects satisfy the predicate', () => {
|
||||
assert(parse(every).createPredicate(resolver)(allResolve))
|
||||
})
|
||||
|
||||
it("[every] doesn't match when only some resolved objects satisfy the predicate", () => {
|
||||
assert(!parse(every).createPredicate(resolver)(someFail))
|
||||
})
|
||||
|
||||
it("[every] doesn't match when an id cannot be resolved", () => {
|
||||
assert(!parse(every).createPredicate(resolver)(oneUnresolvable))
|
||||
})
|
||||
|
||||
it("[every] doesn't match when nothing could be resolved", () => {
|
||||
assert(!parse(every).createPredicate(resolver)(allUnresolvable))
|
||||
})
|
||||
|
||||
it("[every] doesn't match an empty collection", () => {
|
||||
assert(!parse(every).createPredicate(resolver)(emptyArray))
|
||||
})
|
||||
|
||||
it('[some] matches when at least one resolved object satisfies the predicate', () => {
|
||||
assert(parse(some).createPredicate(resolver)(someFail))
|
||||
})
|
||||
|
||||
it("[some] doesn't match when nothing could be resolved", () => {
|
||||
assert(!parse(some).createPredicate(resolver)(allUnresolvable))
|
||||
})
|
||||
|
||||
it("[some] doesn't match an empty collection", () => {
|
||||
assert(!parse(some).createPredicate(resolver)(emptyArray))
|
||||
})
|
||||
|
||||
it('both quantifiers agree on a single id', () => {
|
||||
assert(parse(every).createPredicate(resolver)(scalar))
|
||||
assert(parse(some).createPredicate(resolver)(scalar))
|
||||
})
|
||||
|
||||
it('[resolve] without a quantifier behaves as [some]', () => {
|
||||
assert(parse('objects:[resolve]:tags:tag').createPredicate(resolver)(someFail))
|
||||
})
|
||||
|
||||
it('toString round-trips an explicit quantifier', () => {
|
||||
assert.equal(parse(every).toString(), every)
|
||||
})
|
||||
|
||||
it('toString does not emit the implicit [some]', () => {
|
||||
assert.equal(parse('objects:[resolve]:tags:tag').toString(), 'objects:[resolve]:tags:tag')
|
||||
})
|
||||
|
||||
it('getResolveFields walks through a quantifier', () => {
|
||||
const [field] = getResolveFields(parse('a:[resolve]:[every]:b:[resolve]:c:d'))
|
||||
assert.deepEqual(field.path, ['a'])
|
||||
assert.deepEqual(
|
||||
getResolveFields(field.resolveNode.child).map(_ => _.path),
|
||||
[['b']]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve quantifiers with a negated condition', () => {
|
||||
const store = {
|
||||
'object-1': { tags: ['tag'] },
|
||||
'object-3': { tags: ['other'] },
|
||||
}
|
||||
const resolver = id => store[id]
|
||||
|
||||
// an unresolvable id must never match, whatever the condition: without a guard
|
||||
// in the quantifier, `!tags:tag` would be *satisfied* by an unresolved object
|
||||
const notEvery = 'objects:[resolve]:[every]:!tags:tag'
|
||||
const notSome = 'objects:[resolve]:[some]:!tags:tag'
|
||||
|
||||
it('[some] matches a resolved object which satisfies the negation', () => {
|
||||
assert(parse(notSome).createPredicate(resolver)({ objects: ['object-3'] }))
|
||||
})
|
||||
|
||||
it("[some] doesn't match when only an unresolvable id would satisfy the negation", () => {
|
||||
assert(!parse(notSome).createPredicate(resolver)({ objects: ['object-1', 'ghost'] }))
|
||||
})
|
||||
|
||||
it('[every] matches when every resolved object satisfies the negation', () => {
|
||||
assert(parse(notEvery).createPredicate(resolver)({ objects: ['object-3'] }))
|
||||
})
|
||||
|
||||
it("[every] doesn't match when an id cannot be resolved", () => {
|
||||
assert(!parse(notEvery).createPredicate(resolver)({ objects: ['object-3', 'ghost'] }))
|
||||
})
|
||||
|
||||
it("[every] doesn't match when nothing could be resolved", () => {
|
||||
assert(!parse(notEvery).createPredicate(resolver)({ objects: ['ghost-1', 'ghost-2'] }))
|
||||
})
|
||||
|
||||
it('a negation outside the resolve is unaffected', () => {
|
||||
const filter = '!(objects:[resolve]:[some]:tags:tag)'
|
||||
|
||||
assert(!parse(filter).createPredicate(resolver)({ objects: ['object-1', 'ghost'] }))
|
||||
assert(parse(filter).createPredicate(resolver)({ objects: ['ghost'] }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve depth limit', () => {
|
||||
// mirrors MAX_RESOLVE_DEPTH in index.js: update both together
|
||||
const maxDepth = 5
|
||||
|
||||
// `a:[resolve]:b:[resolve]:…:tags:tag`
|
||||
const nested = depth =>
|
||||
Array.from({ length: depth }, (_, i) => `${String.fromCharCode(97 + i)}:[resolve]`).join(':') + ':tags:tag'
|
||||
|
||||
it('parses a filter nested up to the maximum depth', () => {
|
||||
assert(parse(nested(maxDepth)) instanceof Property)
|
||||
})
|
||||
|
||||
it('throws beyond the maximum depth', () => {
|
||||
assert.throws(() => parse(nested(maxDepth + 1)), {
|
||||
message: '[resolve] cannot be nested more than 5 levels',
|
||||
})
|
||||
})
|
||||
|
||||
it('counts nesting, not the number of [resolve]', () => {
|
||||
const siblings = Array.from({ length: maxDepth + 1 }, (_, i) => `${String.fromCharCode(97 + i)}:[resolve]:tags:tag`)
|
||||
assert.doesNotThrow(() => parse(siblings.join(' ')))
|
||||
})
|
||||
|
||||
it('counts nesting through a quantifier', () => {
|
||||
assert.throws(
|
||||
() => parse('a:[resolve]:[every]:b:[resolve]:[some]:c:[resolve]:d:[resolve]:e:[resolve]:f:[resolve]:tags:tag'),
|
||||
{ message: '[resolve] cannot be nested more than 5 levels' }
|
||||
)
|
||||
})
|
||||
|
||||
it('throws if resolve does not follow a property', () => {
|
||||
assert.throws(() => parse('[resolve]:tags:tag'), {
|
||||
message: '[resolve] must follow a property',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws if some/every does not follow a resolve', () => {
|
||||
assert.throws(() => parse('a:[some]:tags:tag'), {
|
||||
message: '[some]/[every] must follow a [resolve]',
|
||||
})
|
||||
|
||||
assert.throws(() => parse('a:[every]:tags:tag'), {
|
||||
message: '[some]/[every] must follow a [resolve]',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
330
packages/xo-server/src/xo-mixins/_object-resolver.test.mjs
Normal file
330
packages/xo-server/src/xo-mixins/_object-resolver.test.mjs
Normal file
@@ -0,0 +1,330 @@
|
||||
import assert from 'assert/strict'
|
||||
import test from 'node:test'
|
||||
import * as CM from 'complex-matcher'
|
||||
import { featureUnauthorized, noSuchObject } from 'xo-common/api-errors.js'
|
||||
|
||||
import ObjectResolver from './object-resolver.mjs'
|
||||
|
||||
const { describe, it } = test
|
||||
|
||||
describe('ObjectResolver', function () {
|
||||
describe('buildResolver', function () {
|
||||
// only XAPI objects are reachable through `getObject`, the others are only
|
||||
// reachable through the asynchronous `getAnyObject`
|
||||
const xapiObjects = {
|
||||
// `creation` is written by xo-server on the XAPI object itself, so a XAPI
|
||||
// object can reference a non-XAPI one
|
||||
vm1: { $SR: 'sr1', creation: { user: 'user1' } },
|
||||
sr1: { tags: ['prod'] },
|
||||
}
|
||||
const anyObjects = { ...xapiObjects, user1: { email: 'test@test.com' } }
|
||||
|
||||
const makeApp = (over = {}) => ({
|
||||
// `getObject` is synchronous and throws when the id is unknown
|
||||
getObject: id => {
|
||||
const object = xapiObjects[id]
|
||||
if (object === undefined) {
|
||||
throw noSuchObject(id)
|
||||
}
|
||||
return object
|
||||
},
|
||||
getAnyObject: async id => {
|
||||
const object = anyObjects[id]
|
||||
if (object === undefined) {
|
||||
throw noSuchObject(id)
|
||||
}
|
||||
return object
|
||||
},
|
||||
...over,
|
||||
})
|
||||
|
||||
it('resolves a single referenced id', async function () {
|
||||
const objectResolver = new ObjectResolver(makeApp())
|
||||
const node = CM.parse('$SR:[resolve]:tags:prod')
|
||||
|
||||
const resolver = await objectResolver.buildResolver({ $SR: 'sr1' }, node)
|
||||
|
||||
assert.deepEqual(resolver('sr1'), { tags: ['prod'] })
|
||||
})
|
||||
|
||||
it('skips an unresolvable id (noSuchObject) instead of throwing', async function () {
|
||||
const objectResolver = new ObjectResolver(makeApp())
|
||||
const node = CM.parse('$SR:[resolve]:tags:prod')
|
||||
|
||||
await assert.doesNotReject(() => objectResolver.buildResolver({ $SR: 'missing' }, node))
|
||||
})
|
||||
|
||||
it('propagates a genuine (non-noSuchObject) error', async function () {
|
||||
const objectResolver = new ObjectResolver(
|
||||
makeApp({
|
||||
getAnyObject: async () => {
|
||||
throw new Error('db down')
|
||||
},
|
||||
})
|
||||
)
|
||||
const node = CM.parse('$SR:[resolve]:tags:prod')
|
||||
|
||||
await assert.rejects(() => objectResolver.buildResolver({ $SR: 'sr1' }, node), /db down/)
|
||||
})
|
||||
|
||||
it('resolves a nested referenced id via getAnyObject prefetch', async function () {
|
||||
const objectResolver = new ObjectResolver(makeApp())
|
||||
const node = CM.parse('properties:userId:[resolve]:email:"test@test.com"')
|
||||
|
||||
const resolver = await objectResolver.buildResolver({ properties: { userId: 'user1' } }, node)
|
||||
|
||||
assert.deepEqual(resolver('user1'), { email: 'test@test.com' })
|
||||
})
|
||||
|
||||
it('prefetches and matches through a grouped property', async function () {
|
||||
const objectResolver = new ObjectResolver(makeApp())
|
||||
// the resolve sits inside a group, so its path has two segments
|
||||
const node = CM.parse('properties:(userId:[resolve]:email:"test@test.com" method:vm.start)')
|
||||
const object = { properties: { userId: 'user1', method: 'vm.start' } }
|
||||
|
||||
const resolver = await objectResolver.buildResolver([object], node)
|
||||
|
||||
assert.deepEqual(resolver('user1'), { email: 'test@test.com' })
|
||||
assert(node.createPredicate(resolver)(object))
|
||||
})
|
||||
|
||||
it('resolves a non-XAPI object referenced by a XAPI object', async function () {
|
||||
const objectResolver = new ObjectResolver(makeApp())
|
||||
const node = CM.parse('$VM:[resolve]:creation:user:[resolve]:email:"test@test.com"')
|
||||
|
||||
const resolver = await objectResolver.buildResolver({ $VM: 'vm1' }, node)
|
||||
|
||||
// first hop is a XAPI object, second one is only reachable through `getAnyObject`
|
||||
assert.deepEqual(resolver('vm1'), xapiObjects.vm1)
|
||||
assert.deepEqual(resolver('user1'), { email: 'test@test.com' })
|
||||
})
|
||||
|
||||
describe('when the same id is reached from two branches', function () {
|
||||
// both branches dereference `shared`, but each one continues through a
|
||||
// different field of it
|
||||
const store = {
|
||||
shared: { x: 'xobj', y: 'yobj' },
|
||||
xobj: { tags: ['tx'] },
|
||||
yobj: { tags: ['ty'] },
|
||||
}
|
||||
const sharedApp = {
|
||||
getObject: id => {
|
||||
throw noSuchObject(id)
|
||||
},
|
||||
getAnyObject: async id => {
|
||||
const object = store[id]
|
||||
if (object === undefined) {
|
||||
throw noSuchObject(id)
|
||||
}
|
||||
return object
|
||||
},
|
||||
}
|
||||
const object = { a: 'shared', b: 'shared' }
|
||||
const filter = 'a:[resolve]:x:[resolve]:tags:tx b:[resolve]:y:[resolve]:tags:ty'
|
||||
|
||||
it('prefetches both branches', async function () {
|
||||
const node = CM.parse(filter)
|
||||
|
||||
const resolver = await new ObjectResolver(sharedApp).buildResolver([object], node)
|
||||
|
||||
assert.deepEqual(resolver('shared'), store.shared)
|
||||
assert.deepEqual(resolver('xobj'), store.xobj)
|
||||
// only reachable by walking `shared` a second time, for the other branch
|
||||
assert.deepEqual(resolver('yobj'), store.yobj)
|
||||
})
|
||||
|
||||
it('matches on both branches', async function () {
|
||||
const node = CM.parse(filter)
|
||||
|
||||
const resolver = await new ObjectResolver(sharedApp).buildResolver([object], node)
|
||||
|
||||
assert(node.createPredicate(resolver)(object))
|
||||
})
|
||||
|
||||
it('looks a shared dangling id up only once', async function () {
|
||||
// an id which resolves to nothing must be remembered too, otherwise the
|
||||
// second branch walks the whole getter chain again
|
||||
let lookups = 0
|
||||
const objectResolver = new ObjectResolver({
|
||||
...sharedApp,
|
||||
getAnyObject: async id => {
|
||||
lookups++
|
||||
return sharedApp.getAnyObject(id)
|
||||
},
|
||||
})
|
||||
const node = CM.parse('a:[resolve]:x:y b:[resolve]:x:z')
|
||||
|
||||
await objectResolver.buildResolver([{ a: 'ghost', b: 'ghost' }], node)
|
||||
|
||||
assert.equal(lookups, 1)
|
||||
})
|
||||
|
||||
it('fetches the shared object only once', async function () {
|
||||
let fetches = 0
|
||||
const node = CM.parse(filter)
|
||||
const objectResolver = new ObjectResolver({
|
||||
...sharedApp,
|
||||
getAnyObject: async id => {
|
||||
if (id === 'shared') {
|
||||
fetches++
|
||||
}
|
||||
return sharedApp.getAnyObject(id)
|
||||
},
|
||||
})
|
||||
|
||||
await objectResolver.buildResolver([object], node)
|
||||
|
||||
assert.equal(fetches, 1)
|
||||
})
|
||||
})
|
||||
|
||||
it('matches through a XAPI object onto a non-XAPI one', async function () {
|
||||
const objectResolver = new ObjectResolver(makeApp())
|
||||
const node = CM.parse('$VM:[resolve]:creation:user:[resolve]:email:"test@test.com"')
|
||||
const object = { $VM: 'vm1' }
|
||||
|
||||
const resolver = await objectResolver.buildResolver(object, node)
|
||||
|
||||
assert(node.createPredicate(resolver)(object))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAnyObject', function () {
|
||||
const notFound = async () => {
|
||||
throw noSuchObject()
|
||||
}
|
||||
|
||||
// `getAnyObject` looks an id up in three tiers, ordered by cost:
|
||||
// 1. `getObject`, synchronous, every XAPI object
|
||||
// 2. the cheap store reads, raced against each other
|
||||
// 3. the costly backup archive and log lookups
|
||||
const makeApp = (over = {}) => ({
|
||||
getObject: id => {
|
||||
if (id !== 'vm') {
|
||||
throw noSuchObject(id)
|
||||
}
|
||||
return { id, type: 'VM' }
|
||||
},
|
||||
// an installation without an RBAC licence rejects these
|
||||
getAclV2Privilege: async () => {
|
||||
throw featureUnauthorized({ featureCode: 'RBAC' })
|
||||
},
|
||||
getAclV2Role: async () => {
|
||||
throw featureUnauthorized({ featureCode: 'RBAC' })
|
||||
},
|
||||
getGroup: notFound,
|
||||
getJob: notFound,
|
||||
getProxy: notFound,
|
||||
getRemote: notFound,
|
||||
getSchedule: notFound,
|
||||
tasks: { get: notFound },
|
||||
getUser: async id => {
|
||||
if (id !== 'user') {
|
||||
throw noSuchObject(id)
|
||||
}
|
||||
return { id, email: 'test@test.com' }
|
||||
},
|
||||
getXenServer: notFound,
|
||||
getVmBackupArchive: async id => {
|
||||
if (id !== 'archive') {
|
||||
throw noSuchObject(id)
|
||||
}
|
||||
return { id, kind: 'archive' }
|
||||
},
|
||||
getBackupNgLogs: async id => {
|
||||
if (id !== 'log') {
|
||||
throw noSuchObject(id)
|
||||
}
|
||||
return { id, message: 'backup' }
|
||||
},
|
||||
...over,
|
||||
})
|
||||
|
||||
it('returns an XAPI object', async function () {
|
||||
assert.deepEqual(await new ObjectResolver(makeApp()).getAnyObject('vm'), { id: 'vm', type: 'VM' })
|
||||
})
|
||||
|
||||
it('returns an object found by one of the cheap getters', async function () {
|
||||
assert.deepEqual(await new ObjectResolver(makeApp()).getAnyObject('user'), {
|
||||
id: 'user',
|
||||
email: 'test@test.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('obfuscates the password of a resolved user', async function () {
|
||||
let opts
|
||||
const objectResolver = new ObjectResolver(
|
||||
makeApp({
|
||||
getUser: async (id, _opts) => {
|
||||
opts = _opts
|
||||
return { id, pw_hash: 'secret' }
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
await objectResolver.getAnyObject('user')
|
||||
|
||||
assert.deepEqual(opts, { obfuscatePassword: true })
|
||||
})
|
||||
|
||||
it('ignores featureUnauthorized raised by the RBAC getters', async function () {
|
||||
// `user` is only found once the RBAC rejections have been ignored
|
||||
assert.deepEqual(await new ObjectResolver(makeApp()).getAnyObject('user'), {
|
||||
id: 'user',
|
||||
email: 'test@test.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls through to the costly getters', async function () {
|
||||
const objectResolver = new ObjectResolver(makeApp())
|
||||
|
||||
assert.deepEqual(await objectResolver.getAnyObject('archive'), { id: 'archive', kind: 'archive' })
|
||||
assert.deepEqual(await objectResolver.getAnyObject('log'), { id: 'log', message: 'backup' })
|
||||
})
|
||||
|
||||
it('does not reach the costly getters when a cheaper one matches', async function () {
|
||||
let costlyCalls = 0
|
||||
const costly = async () => {
|
||||
costlyCalls++
|
||||
throw noSuchObject()
|
||||
}
|
||||
const objectResolver = new ObjectResolver(makeApp({ getVmBackupArchive: costly, getBackupNgLogs: costly }))
|
||||
|
||||
await objectResolver.getAnyObject('vm')
|
||||
await objectResolver.getAnyObject('user')
|
||||
|
||||
assert.equal(costlyCalls, 0)
|
||||
})
|
||||
|
||||
it('throws noSuchObject when no collection holds the id', async function () {
|
||||
await assert.rejects(
|
||||
() => new ObjectResolver(makeApp()).getAnyObject('ghost'),
|
||||
error => noSuchObject.is(error)
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates a genuine error raised by a cheap getter', async function () {
|
||||
const objectResolver = new ObjectResolver(
|
||||
makeApp({
|
||||
getGroup: async () => {
|
||||
throw new Error('db down')
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
await assert.rejects(() => objectResolver.getAnyObject('ghost'), /db down/)
|
||||
})
|
||||
|
||||
it('propagates a genuine error raised by a costly getter', async function () {
|
||||
const objectResolver = new ObjectResolver(
|
||||
makeApp({
|
||||
getBackupNgLogs: async () => {
|
||||
throw new Error('remote unreachable')
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
await assert.rejects(() => objectResolver.getAnyObject('ghost'), /remote unreachable/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import { hasLiveMountTarget } from '@xen-orchestra/backups/_vdiRestoreTargets.mj
|
||||
import { HealthCheckVmBackup } from '@xen-orchestra/backups/HealthCheckVmBackup.mjs'
|
||||
import { ImportVmBackup } from '@xen-orchestra/backups/ImportVmBackup.mjs'
|
||||
import { createRunner } from '@xen-orchestra/backups/Backup.mjs'
|
||||
import { invalidParameters, noMatchingVm } from 'xo-common/api-errors.js'
|
||||
import { invalidParameters, noMatchingVm, noSuchObject } from 'xo-common/api-errors.js'
|
||||
import { timeout } from 'promise-toolbox'
|
||||
import { runBackupWorker } from '@xen-orchestra/backups/runBackupWorker.mjs'
|
||||
import { Task } from '@vates/task'
|
||||
@@ -22,6 +22,9 @@ import { serveVmBackups, VmBackupsCache } from './_vmBackupsCache.mjs'
|
||||
import { VmBackupsSource } from './_vmBackupsSource.mjs'
|
||||
import { waitAll } from '../../_waitAll.mjs'
|
||||
|
||||
// BR uuid/xo-vm-backups/VM uuid/(ISO 8601 compact).json
|
||||
const BACKUP_ARCHIVE_ID_REGEX = /^([0-9a-fA-F-]{36})\/+xo-vm-backups\/+([0-9a-fA-F-]{36})\/+(\d{8}T\d{6}Z)\.json$/
|
||||
|
||||
const logger = createLogger('xo:xo-mixins:backups-ng')
|
||||
|
||||
/**
|
||||
@@ -904,4 +907,24 @@ export default class BackupNg {
|
||||
this.#vmBackupsCache.remove(remoteId)
|
||||
this.#resetVmBackupsListingState(remoteId)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {XoVmBackupArchive['id']} id
|
||||
* @returns {Promise<XoVmBackupArchive>}
|
||||
*/
|
||||
async getVmBackupArchive(id) {
|
||||
const match = id.match(BACKUP_ARCHIVE_ID_REGEX)
|
||||
if (match === null) {
|
||||
throw noSuchObject(id, 'backup-archive')
|
||||
}
|
||||
|
||||
const [, brId, vmId] = match
|
||||
|
||||
const backupArchive = (await this.listVmBackupsNg([brId]))[brId]?.[vmId]?.find(backup => backup.id === id)
|
||||
if (backupArchive === undefined) {
|
||||
throw noSuchObject(id, 'backup-archive')
|
||||
}
|
||||
|
||||
return backupArchive
|
||||
}
|
||||
}
|
||||
|
||||
206
packages/xo-server/src/xo-mixins/object-resolver.mjs
Normal file
206
packages/xo-server/src/xo-mixins/object-resolver.mjs
Normal file
@@ -0,0 +1,206 @@
|
||||
import * as CM from 'complex-matcher'
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { featureUnauthorized, noSuchObject } from 'xo-common/api-errors.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('@vates/types').XoApp} XoApp
|
||||
* @typedef {import('@vates/types').XoRecord} XoRecord
|
||||
* @typedef {import('complex-matcher').Node} CmNode
|
||||
* @typedef {import('complex-matcher').Resolver} CmResolver
|
||||
*/
|
||||
|
||||
const getAnyObjectsConcurrency = 10
|
||||
|
||||
export default class ObjectResolver {
|
||||
/**
|
||||
* @param {XoApp} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this._app = app
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous resolver, limited to XAPI objects: they are the only ones held
|
||||
* in memory and therefore the only ones reachable without awaiting.
|
||||
*
|
||||
* @template {import('@vates/types').XapiXoRecord} Record
|
||||
* @param {Record['id']} id
|
||||
* @returns {Record | undefined} `undefined` when the id is unknown
|
||||
*/
|
||||
xapiObjectResolver(id) {
|
||||
try {
|
||||
return this._app.getObject(id)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a resolver for a specific filter, by prefetching every object that
|
||||
* filter will dereference.
|
||||
*
|
||||
* complex-matcher cannot do this itself: `match()` is synchronous, because the
|
||||
* predicate it produces is handed to `Array#filter`. Yet only XAPI objects can
|
||||
* be read synchronously, every other record is fetched from a database by the
|
||||
* asynchronous `getAnyObject()`. So the awaiting is lifted out of matching and
|
||||
* done up front and what matching gets is a plain synchronous lookup.
|
||||
*
|
||||
* Resolution is walked level by level because a level's ids are only known
|
||||
* once the previous one has been fetched, and each id is fetched at most once
|
||||
* for the whole filter run.
|
||||
*
|
||||
* @param {XoRecord | XoRecord[]} objects the objects the filter will be matched against
|
||||
* @param {CmNode} filterNode
|
||||
* @returns {Promise<CmResolver>}
|
||||
*/
|
||||
async buildResolver(objects, filterNode) {
|
||||
/** @type {XoRecord[]} */
|
||||
let objectArray
|
||||
if (!Array.isArray(objects)) {
|
||||
objectArray = [objects]
|
||||
} else {
|
||||
objectArray = objects
|
||||
}
|
||||
|
||||
/** @type {Map<XoRecord['id'], XoRecord>} */
|
||||
const anyObjects = new Map()
|
||||
|
||||
// ids which `getAnyObject()` could not resolve, remembered so that a filter
|
||||
// reaching the same dangling id from another branch does not walk the whole
|
||||
// getter chain again
|
||||
/** @type {Set<XoRecord['id']>} */
|
||||
const unresolvableIds = new Set()
|
||||
|
||||
/**
|
||||
* @param {XoRecord[]} objectsToProcess
|
||||
* @param {CmNode} node
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const process = async (objectsToProcess, node) => {
|
||||
const fields = CM.getResolveFields(node)
|
||||
|
||||
for (const field of fields) {
|
||||
/** @type {Set<XoRecord['id']>} */
|
||||
const objectIds = new Set()
|
||||
for (const objectToProcess of objectsToProcess) {
|
||||
const ids = field.path.reduce((object, path) => object?.[path], objectToProcess)
|
||||
if (ids !== undefined) {
|
||||
for (const id of Array.isArray(ids) ? ids : [ids]) {
|
||||
objectIds.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await asyncEach(
|
||||
objectIds,
|
||||
async objectId => {
|
||||
if (!anyObjects.has(objectId) && !unresolvableIds.has(objectId)) {
|
||||
let fetchedObject
|
||||
try {
|
||||
fetchedObject = await this._app.getAnyObject(objectId)
|
||||
} catch (error) {
|
||||
if (!noSuchObject.is(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchedObject !== undefined) {
|
||||
anyObjects.set(objectId, fetchedObject)
|
||||
} else {
|
||||
unresolvableIds.add(objectId)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ concurrency: getAnyObjectsConcurrency }
|
||||
)
|
||||
|
||||
/** @type {XoRecord[]} */
|
||||
const resolvedObjects = []
|
||||
for (const objectId of objectIds) {
|
||||
const resolvedObject = anyObjects.get(objectId)
|
||||
if (resolvedObject !== undefined) {
|
||||
resolvedObjects.push(resolvedObject)
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedObjects.length > 0) {
|
||||
await process(resolvedObjects, field.resolveNode.child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await process(objectArray, filterNode)
|
||||
|
||||
return id => anyObjects.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look an id up in every collection which can hold an `XoRecord`.
|
||||
*
|
||||
* Ordered by cost and parallelized where pertinent.
|
||||
*
|
||||
* @template {XoRecord} Record
|
||||
* @param {Record['id']} id
|
||||
* @returns {Promise<Record>}
|
||||
* @throws `noSuchObject` when no collection holds this id
|
||||
*/
|
||||
async getAnyObject(id) {
|
||||
// We do getObject first because it covers all XAPI object
|
||||
try {
|
||||
return await this._app.getObject(id)
|
||||
} catch (error) {
|
||||
if (!noSuchObject.is(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Then the cheap getters in parallel
|
||||
const parallelGetters = [
|
||||
() => this._app.getAclV2Privilege(id),
|
||||
() => this._app.getAclV2Role(id),
|
||||
() => this._app.getGroup(id),
|
||||
() => this._app.getJob(id),
|
||||
() => this._app.getProxy(id),
|
||||
() => this._app.getRemote(id),
|
||||
() => this._app.getSchedule(id),
|
||||
() => this._app.tasks.get(id),
|
||||
() => this._app.getUser(id, { obfuscatePassword: true }),
|
||||
() => this._app.getXenServer(id),
|
||||
]
|
||||
|
||||
const parallelPromises = parallelGetters.map(async getter => getter())
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
let pending = parallelPromises.length
|
||||
const errors = []
|
||||
for (const promise of parallelPromises) {
|
||||
promise.then(resolve, error => {
|
||||
errors.push(error)
|
||||
if (--pending === 0) {
|
||||
reject(errors)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (errors) {
|
||||
for (const error of errors) {
|
||||
if (!noSuchObject.is(error) && !featureUnauthorized.is(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then the more costly logs and archives
|
||||
for (const getter of [() => this._app.getVmBackupArchive(id), () => this._app.getBackupNgLogs(id)]) {
|
||||
try {
|
||||
return await getter()
|
||||
} catch (error) {
|
||||
if (!noSuchObject.is(error) && !featureUnauthorized.is(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw noSuchObject(id)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user