mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
23 Commits
c145a7a579
...
open-api-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2102bec236 | ||
|
|
f3ff78f790 | ||
|
|
d97873242a | ||
|
|
ce651df896 | ||
|
|
bdcd8925c7 | ||
|
|
235d0ae103 | ||
|
|
5ad7caeb1c | ||
|
|
34da6fd857 | ||
|
|
a62aec0e0b | ||
|
|
4bf887076d | ||
|
|
06dd432803 | ||
|
|
344906e5df | ||
|
|
6df0ebc135 | ||
|
|
22bc90847a | ||
|
|
300d16ae1c | ||
|
|
d11526b277 | ||
|
|
8c7a427225 | ||
|
|
d0252cb13a | ||
|
|
906f35cf7b | ||
|
|
58c2235737 | ||
|
|
10026de7a4 | ||
|
|
1e84568dc6 | ||
|
|
86effd8117 |
19
@xen-orchestra/rest-api/.eslintrc.json
Normal file
19
@xen-orchestra/rest-api/.eslintrc.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts"],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-extra-semi": "off",
|
||||
"no-use-before-define": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["src/open-api/routes/routes.ts"]
|
||||
}
|
||||
1
@xen-orchestra/rest-api/.gitignore
vendored
Normal file
1
@xen-orchestra/rest-api/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
src/open-api/*
|
||||
34
@xen-orchestra/rest-api/package.json
Normal file
34
@xen-orchestra/rest-api/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"author": "Vates",
|
||||
"name": "@xen-orchestra/rest-api",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"main": "./dist/index.js",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsoa spec-and-routes && tsc",
|
||||
"dev": "tsoa spec-and-routes && tsc --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.6",
|
||||
"@types/swagger-ui-express": "^4.1.7",
|
||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
||||
"@typescript-eslint/parser": "^8.20.0",
|
||||
"eslint": "^9.18.0",
|
||||
"typescript": "~5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"inversify": "^6.2.1",
|
||||
"inversify-binding-decorators": "^4.0.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tsoa": "^6.6.0",
|
||||
"xo-common": "^0.8.0"
|
||||
}
|
||||
}
|
||||
30
@xen-orchestra/rest-api/src/abstract/xapi-xo.controller.ts
Normal file
30
@xen-orchestra/rest-api/src/abstract/xapi-xo.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getRestApi } from '../index.js'
|
||||
import { XapiXoObject } from '../xoApp.type.js'
|
||||
import { Controller } from 'tsoa'
|
||||
|
||||
export abstract class XapiXoController<T extends XapiXoObject> extends Controller {
|
||||
protected restApi = getRestApi()
|
||||
protected type
|
||||
|
||||
constructor(type: T['type']) {
|
||||
super()
|
||||
this.type = type
|
||||
}
|
||||
|
||||
// wrap these methods with permission (filter only objects that the users can see)
|
||||
protected getObjects(): Record<T['id'], T> | undefined {
|
||||
return this.restApi.getObjectsByType(this.type) as Record<T['id'], T> | undefined
|
||||
}
|
||||
protected getById(id: T['id']): T {
|
||||
return this.restApi.getObject(id, this.type)
|
||||
}
|
||||
|
||||
protected getXapiObject(objOrId: T['id'] | T) {
|
||||
return this.restApi.getXapiObject(objOrId, this.type)
|
||||
}
|
||||
// --------------------------------------------------------------------------------
|
||||
|
||||
protected getObjectIds(): T['id'][] {
|
||||
return Object.keys(this.getObjects() ?? {})
|
||||
}
|
||||
}
|
||||
37
@xen-orchestra/rest-api/src/abstract/xo.controller.ts
Normal file
37
@xen-orchestra/rest-api/src/abstract/xo.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { getRestApi } from '../index.js'
|
||||
import { NonXapiObject } from '../xoApp.type.js'
|
||||
import { Controller } from 'tsoa'
|
||||
|
||||
type XoType = 'server'
|
||||
|
||||
export abstract class XoController<T extends NonXapiObject> extends Controller {
|
||||
#restApi = getRestApi()
|
||||
#type: XoType
|
||||
|
||||
#fnByType = {
|
||||
server: {
|
||||
getAll: () => this.#restApi.getServers(),
|
||||
getById: (id: NonXapiObject['id']) => this.#restApi.getServer(id),
|
||||
},
|
||||
}
|
||||
|
||||
constructor(type: XoType) {
|
||||
super()
|
||||
this.#type = type
|
||||
}
|
||||
|
||||
protected getObjects() {
|
||||
return this.#fnByType[this.#type].getAll()
|
||||
}
|
||||
|
||||
protected getObject(id: T['id']) {
|
||||
return this.#fnByType[this.#type].getById(id)
|
||||
}
|
||||
protected async getObjectIds(): Promise<NonXapiObject['id'][]> {
|
||||
const objects = await this.getObjects()
|
||||
if (Array.isArray(objects)) {
|
||||
return objects.map(obj => obj.id)
|
||||
}
|
||||
return Object.keys(objects)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Controller, Get, Request, Route } from 'tsoa'
|
||||
import DashboardService from './dashboard.service.js'
|
||||
import { inject } from 'inversify'
|
||||
import { provideSingleton } from '../ioc/helper.js'
|
||||
import { Request as ExReq } from 'express'
|
||||
|
||||
export type Dashboard = {
|
||||
vmsStatus: {
|
||||
running: number
|
||||
inactive: number
|
||||
unknown: number
|
||||
}
|
||||
poolsStatus: {
|
||||
connected: number
|
||||
unreachable: number
|
||||
unknown: number
|
||||
}
|
||||
}
|
||||
|
||||
@Route('dashboard')
|
||||
@provideSingleton(DashboardController)
|
||||
export class DashboardController extends Controller {
|
||||
#dashboardService
|
||||
constructor(@inject(DashboardService) dashboardService: DashboardService) {
|
||||
super()
|
||||
this.#dashboardService = dashboardService
|
||||
}
|
||||
|
||||
@Get()
|
||||
public async getDashboard(@Request() req: ExReq): Promise<Dashboard> {
|
||||
const resp = req.res!
|
||||
resp.setHeader('Access-Control-Allow-Origin', '*') // TODO: remove this. Only used for test
|
||||
|
||||
return this.#dashboardService.getDashboard()
|
||||
}
|
||||
}
|
||||
107
@xen-orchestra/rest-api/src/dashboard/dashboard.service.ts
Normal file
107
@xen-orchestra/rest-api/src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { getRestApi } from '../index.js'
|
||||
import { Dashboard } from './dashboard.controller.js'
|
||||
import { provideSingleton } from '../ioc/helper.js'
|
||||
|
||||
// cache used to compare changes and trigger dashboard events
|
||||
type CacheDashboard = {
|
||||
vmsStatus: Dashboard['vmsStatus']
|
||||
poolsStatus: Dashboard['poolsStatus']
|
||||
}
|
||||
const cache = new Map<keyof CacheDashboard, CacheDashboard[keyof CacheDashboard]>()
|
||||
|
||||
@provideSingleton(DashboardService)
|
||||
export default class DashboardService {
|
||||
#restApi
|
||||
#fnById = {
|
||||
// arrow fn to ensure to call the method in the good context
|
||||
vmsStatus: () => this.#getVmsStatus(),
|
||||
poolsStatus: () => this.#getPoolsStatus(),
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.#restApi = getRestApi()
|
||||
this.#registerListener()
|
||||
}
|
||||
|
||||
async getDashboard(): Promise<Dashboard> {
|
||||
const vmsStatus = this.#getVmsStatus()
|
||||
const poolsStatus = await this.#getPoolsStatus()
|
||||
|
||||
return { vmsStatus, poolsStatus }
|
||||
}
|
||||
|
||||
#getVmsStatus(): Dashboard['vmsStatus'] {
|
||||
const vms = Object.values(this.#restApi.getObjectsByType('VM') ?? {}) as any
|
||||
let running = 0
|
||||
let inactive = 0
|
||||
let unknown = 0
|
||||
|
||||
vms.forEach(vm => {
|
||||
if (vm.power_state === 'Running' || vm.power_state === 'Paused') {
|
||||
running++
|
||||
} else if (vm.power_state === 'Halted' || vm.power_state === 'Suspended') {
|
||||
inactive++
|
||||
} else {
|
||||
unknown++
|
||||
}
|
||||
})
|
||||
|
||||
const result = { running, inactive, unknown }
|
||||
cache.set('vmsStatus', result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async #getPoolsStatus(): Promise<Dashboard['poolsStatus']> {
|
||||
const servers = await this.#restApi.getServers()
|
||||
const poolIds = Object.keys(this.#restApi.getObjectsByType('pool') ?? {})
|
||||
|
||||
let nConnectedServers = 0
|
||||
let nUnreachableServers = 0
|
||||
let nUnknownServers = 0
|
||||
servers.forEach(server => {
|
||||
// it may happen that some servers are marked as "connected", but no pool matches "server.pool"
|
||||
// so they are counted as `nUnknownServers`
|
||||
if (server.status === 'connected' && poolIds.includes(server.poolId)) {
|
||||
nConnectedServers++
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
server.status === 'disconnected' &&
|
||||
server.error !== undefined &&
|
||||
server.error.connectedServerId === undefined
|
||||
) {
|
||||
nUnreachableServers++
|
||||
return
|
||||
}
|
||||
|
||||
if (server.status === 'disconnected') {
|
||||
return
|
||||
}
|
||||
|
||||
nUnknownServers++
|
||||
})
|
||||
|
||||
const result = { connected: nConnectedServers, unreachable: nUnreachableServers, unknown: nUnknownServers }
|
||||
cache.set('poolsStatus', result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async #maybeSendEvent(key: keyof Dashboard) {
|
||||
const stringifiedCacheValue = JSON.stringify(cache.get(key))
|
||||
const newValue = await this.#fnById[key]()
|
||||
if (JSON.stringify(newValue) === stringifiedCacheValue) {
|
||||
return
|
||||
}
|
||||
|
||||
this.#restApi.sendData(key, 'dashboard', newValue, 'update')
|
||||
}
|
||||
|
||||
#registerListener() {
|
||||
this.#restApi.ee.on('vm', () => this.#maybeSendEvent('vmsStatus'))
|
||||
this.#restApi.ee.on('pool', () => this.#maybeSendEvent('poolsStatus'))
|
||||
// this.#restApi.ee.on('host', () => this.#maybeSendEvent('hostsStatus'))
|
||||
}
|
||||
}
|
||||
34
@xen-orchestra/rest-api/src/events/event.controller.ts
Normal file
34
@xen-orchestra/rest-api/src/events/event.controller.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Request as ExRequest } from 'express'
|
||||
import { getRestApi } from '../index.js'
|
||||
import { Controller, Get, Request, Route } from 'tsoa'
|
||||
import { provideSingleton } from '../ioc/helper.js'
|
||||
|
||||
@Route('events')
|
||||
@provideSingleton(EventsController)
|
||||
export class EventsController extends Controller {
|
||||
/**
|
||||
* subscribe to all evenements
|
||||
*/
|
||||
@Get()
|
||||
public async getEvent(@Request() req: ExRequest): Promise<void> {
|
||||
const restApi = getRestApi()
|
||||
|
||||
const res = req.res!
|
||||
|
||||
res.setHeader('Access-Control-Allow-Origin', '*') // TODO: remove this. Only used for test
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
|
||||
res.write(`data:${JSON.stringify({ data: null, operation: 'initial' })}\n\n`)
|
||||
|
||||
const symbol = Symbol('client-id')
|
||||
restApi.addSseClient(symbol, res)
|
||||
|
||||
res.on('close', () => {
|
||||
restApi.removeSseClient(symbol)
|
||||
res.end()
|
||||
})
|
||||
}
|
||||
}
|
||||
134
@xen-orchestra/rest-api/src/index.ts
Normal file
134
@xen-orchestra/rest-api/src/index.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import swaggerUi from 'swagger-ui-express'
|
||||
import { Express, NextFunction, Request, Response } from 'express'
|
||||
|
||||
import swaggerOpenApiSpec from './open-api/spec/swagger.json' assert { type: 'json' }
|
||||
import { RegisterRoutes } from './open-api/routes/routes.js'
|
||||
import { XapiXoObject, XoApp } from './xoApp.type.js'
|
||||
import { EventEmitter } from 'events'
|
||||
import DashboardService from './dashboard/dashboard.service.js'
|
||||
import { iocContainer } from './ioc/ioc.js'
|
||||
import { errorHandler } from './middleware/error.middleware.js'
|
||||
import fs from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
class RestApi {
|
||||
#sseClients: Map<symbol, Response> = new Map()
|
||||
#express
|
||||
|
||||
ee = new EventEmitter()
|
||||
|
||||
authenticateUser
|
||||
getObject
|
||||
getObjects
|
||||
getServers: XoApp['getAllXenServers']
|
||||
getServer: XoApp['getXenServer']
|
||||
getXapiObject: XoApp['getXapiObject']
|
||||
getObjectsByType
|
||||
|
||||
constructor(xoApp: XoApp, express: Express) {
|
||||
console.log('INITIALIZE')
|
||||
if (restApi !== undefined) {
|
||||
throw new Error('RestApi is a singleton')
|
||||
}
|
||||
|
||||
this.#express = express
|
||||
|
||||
// existing xo methods
|
||||
this.authenticateUser = xoApp.authenticateUser.bind(xoApp)
|
||||
this.getObject = xoApp.getObject.bind(xoApp)
|
||||
this.getObjects = xoApp.getObjects.bind(xoApp)
|
||||
this.getServers = () => xoApp.getAllXenServers()
|
||||
this.getXapiObject = (idOrObj, type) => xoApp.getXapiObject(idOrObj, type)
|
||||
this.getServer = id => xoApp.getXenServer(id)
|
||||
|
||||
// helpers
|
||||
this.getObjectsByType = <T extends keyof typeof xoApp.objects.indexes.type>(type: T) =>
|
||||
xoApp.objects.indexes.type[type]
|
||||
|
||||
// @ts-ignore
|
||||
this.#registerListener(xoApp.objects.allIndexes.type)
|
||||
}
|
||||
|
||||
addSseClient(id: symbol, client: Response) {
|
||||
this.#sseClients.set(id, client)
|
||||
}
|
||||
|
||||
removeSseClient(id: symbol) {
|
||||
this.#sseClients.delete(id)
|
||||
}
|
||||
|
||||
sendData(objId: string, objType: string | undefined, obj: any | undefined, operation: 'update' | 'add' | 'remove') {
|
||||
this.#sseClients.forEach(client => {
|
||||
client.write(`data: ${JSON.stringify({ id: objId, type: objType, data: obj, operation })}\n\n`)
|
||||
})
|
||||
}
|
||||
|
||||
async registerRoute(
|
||||
endpoint: string,
|
||||
cb: (req: Request, res: Response, next: NextFunction) => any,
|
||||
openapiSpec: Object,
|
||||
httpMethod: 'get' | 'post' | 'put' | 'patch' | 'delete' = 'get'
|
||||
) {
|
||||
if (endpoint[0] !== '/') {
|
||||
endpoint = '/' + endpoint
|
||||
}
|
||||
this.#express[httpMethod](`/rest/v1${endpoint}`, cb)
|
||||
const currentConfig = JSON.parse(
|
||||
await fs.readFile(`${__dirname}/open-api/spec/swagger-tmp.json`, { encoding: 'utf-8' })
|
||||
)
|
||||
currentConfig.paths[endpoint] = openapiSpec
|
||||
await fs.writeFile(`${__dirname}/open-api/spec/swagger-tmp.json`, JSON.stringify(currentConfig), {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
}
|
||||
|
||||
#registerListener(obj: EventEmitter) {
|
||||
// XO events
|
||||
obj.on('remove', (type, data) => this.#handleXapiEvent(type, data, 'remove'))
|
||||
obj.on('add', (type, data) => this.#handleXapiEvent(type, data, 'add'))
|
||||
obj.on('update', (type, data) => this.#handleXapiEvent(type, data, 'update'))
|
||||
}
|
||||
|
||||
async #handleXapiEvent(type: string, data: XapiXoObject, operation: 'update' | 'add' | 'remove') {
|
||||
if ((data as any).type === 'message') {
|
||||
// just to avoid client to be spammed by message for the moment
|
||||
return
|
||||
}
|
||||
|
||||
this.ee.emit(type.toLowerCase(), data, operation)
|
||||
this.sendData(data.id, data.type, data, operation)
|
||||
}
|
||||
}
|
||||
|
||||
let restApi: RestApi
|
||||
export const getRestApi = () => {
|
||||
if (restApi === undefined) {
|
||||
throw new Error('The REST API is not instantiated')
|
||||
}
|
||||
return restApi
|
||||
}
|
||||
|
||||
export default async function setupRestApi(express: Express, xoApp: XoApp) {
|
||||
restApi = new RestApi(xoApp, express)
|
||||
|
||||
await fs.copyFile(`${__dirname}/open-api/spec/swagger.json`, `${__dirname}/open-api/spec/swagger-tmp.json`)
|
||||
|
||||
express.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*') // TODO: remove this. Only used for test
|
||||
next()
|
||||
})
|
||||
|
||||
express.use('/rest/v1/api-doc', swaggerUi.serve, async (req, res, next) => {
|
||||
const spec = JSON.parse(await fs.readFile(`${__dirname}/open-api/spec/swagger-tmp.json`, { encoding: 'utf-8' }))
|
||||
swaggerUi.setup(spec)(req, res, next)
|
||||
})
|
||||
RegisterRoutes(express)
|
||||
|
||||
express.use(errorHandler)
|
||||
|
||||
// in order to create the instance of the service (and start to listen for dashboard changes)
|
||||
iocContainer.get(DashboardService)
|
||||
}
|
||||
8
@xen-orchestra/rest-api/src/ioc/helper.ts
Normal file
8
@xen-orchestra/rest-api/src/ioc/helper.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { interfaces } from 'inversify'
|
||||
import { fluentProvide, provide } from 'inversify-binding-decorators'
|
||||
|
||||
const provideSingleton = function <T>(identifier: interfaces.ServiceIdentifier<T>) {
|
||||
return fluentProvide(identifier).inSingletonScope().done()
|
||||
}
|
||||
|
||||
export { provideSingleton, provide }
|
||||
11
@xen-orchestra/rest-api/src/ioc/ioc.ts
Normal file
11
@xen-orchestra/rest-api/src/ioc/ioc.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Container, decorate, injectable } from 'inversify'
|
||||
import { buildProviderModule } from 'inversify-binding-decorators'
|
||||
import { Controller } from 'tsoa'
|
||||
|
||||
const iocContainer = new Container()
|
||||
|
||||
decorate(injectable(), Controller)
|
||||
|
||||
iocContainer.load(buildProviderModule())
|
||||
|
||||
export { iocContainer }
|
||||
39
@xen-orchestra/rest-api/src/middleware/authentication.ts
Normal file
39
@xen-orchestra/rest-api/src/middleware/authentication.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as express from 'express'
|
||||
import { getRestApi } from '../index.js'
|
||||
|
||||
export async function expressAuthentication(
|
||||
req: express.Request,
|
||||
securityName: string,
|
||||
scopes?: string[]
|
||||
): Promise<any> {
|
||||
const restApi = getRestApi()
|
||||
const ip = req.ip
|
||||
const permission = scopes?.[0]
|
||||
|
||||
if (securityName === 'token') {
|
||||
const token = req.cookies.token ?? req.cookies.authenticationToken
|
||||
|
||||
const { user } = await restApi.authenticateUser({ token }, { ip })
|
||||
if (permission !== undefined && permission !== user.permission) {
|
||||
return Promise.reject(new Error('unauthorize'))
|
||||
}
|
||||
return Promise.resolve(user)
|
||||
}
|
||||
|
||||
if (securityName === 'basic_auth') {
|
||||
const authorization = req.headers.authorization ?? ''
|
||||
const [, encodedCredentials] = authorization.split(' ')
|
||||
if (encodedCredentials === undefined) {
|
||||
return Promise.reject(new Error('missing credentials'))
|
||||
}
|
||||
|
||||
const [username, password] = Buffer.from(encodedCredentials, 'base64').toString().split(':')
|
||||
const { user } = await restApi.authenticateUser({ username, password }, { ip })
|
||||
if (permission !== undefined && permission !== user.permission) {
|
||||
return Promise.reject(new Error('unauthorize'))
|
||||
}
|
||||
return Promise.resolve(user)
|
||||
}
|
||||
|
||||
throw new Error('unimplemented')
|
||||
}
|
||||
15
@xen-orchestra/rest-api/src/middleware/error.middleware.ts
Normal file
15
@xen-orchestra/rest-api/src/middleware/error.middleware.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { noSuchObject } from 'xo-common/api-errors.js'
|
||||
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
|
||||
export function errorHandler(err: Error, _req: Request, res: Response, next: NextFunction) {
|
||||
console.error(err) // use log.error instead
|
||||
if (noSuchObject.is(err)) {
|
||||
res.status(404).json({ error: err.message })
|
||||
return next()
|
||||
}
|
||||
|
||||
res.json({ error: err.message })
|
||||
|
||||
next()
|
||||
}
|
||||
24
@xen-orchestra/rest-api/src/servers/server.controller.ts
Normal file
24
@xen-orchestra/rest-api/src/servers/server.controller.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Get, Path, Route, Security } from 'tsoa'
|
||||
import { XoServer } from './server.type.js'
|
||||
import { provideSingleton } from '../ioc/helper.js'
|
||||
import { XoController } from '../abstract/xo.controller.js'
|
||||
|
||||
@Route('servers')
|
||||
@provideSingleton(ServersController)
|
||||
export class ServersController extends XoController<XoServer> {
|
||||
constructor() {
|
||||
super('server')
|
||||
}
|
||||
|
||||
@Security('token', ['admin'])
|
||||
@Get()
|
||||
public getServers(): Promise<string[]> {
|
||||
return this.getObjectIds()
|
||||
}
|
||||
|
||||
@Security('basic_auth', ['admin'])
|
||||
@Get('{id}')
|
||||
public getServer(@Path() id: XoServer['id']): Promise<XoServer> {
|
||||
return this.getObject(id)
|
||||
}
|
||||
}
|
||||
14
@xen-orchestra/rest-api/src/servers/server.type.ts
Normal file
14
@xen-orchestra/rest-api/src/servers/server.type.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NonXapiObject } from '../xoApp.type.js'
|
||||
|
||||
export interface XoServer extends NonXapiObject {
|
||||
allowUnauthorized: boolean
|
||||
host: string
|
||||
label: string
|
||||
password: string
|
||||
username: string
|
||||
enabled: boolean
|
||||
readOnly: boolean
|
||||
status: 'connected' | 'disconnected'
|
||||
error?: any
|
||||
poolId: string /* XoPool['id'] */
|
||||
}
|
||||
57
@xen-orchestra/rest-api/src/srs/sr.controller.ts
Normal file
57
@xen-orchestra/rest-api/src/srs/sr.controller.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { FormField, Get, Middlewares, Path, Post, Put, Query, Request, Response, Route, UploadedFile } from 'tsoa'
|
||||
import { Request as ExRequest } from 'express'
|
||||
|
||||
import { provideSingleton } from '../ioc/helper.js'
|
||||
import { XapiXoController } from '../abstract/xapi-xo.controller.js'
|
||||
|
||||
/**
|
||||
* Return a task ID or T if ?watch=true
|
||||
*/
|
||||
type WatchAction<T> = string | T
|
||||
|
||||
@Route('srs')
|
||||
@provideSingleton(SrsController)
|
||||
export class SrsController extends XapiXoController<any> {
|
||||
constructor() {
|
||||
super('SR')
|
||||
}
|
||||
/**
|
||||
* Some description
|
||||
*/
|
||||
@Get()
|
||||
@Response(401, 'unautorhized')
|
||||
public getSrs(): string[] {
|
||||
return this.getObjectIds()
|
||||
}
|
||||
|
||||
@Get('{id}')
|
||||
@Response(404, 'Not found')
|
||||
@Response(401, 'unautorhized')
|
||||
public getSr(@Path() id: string) {
|
||||
return this.getById(id)
|
||||
}
|
||||
|
||||
@Post('{id}/actions/import_vdi')
|
||||
public async importVdi(
|
||||
@Request() req: ExRequest & { length: number },
|
||||
@Path() id: string,
|
||||
@Query() name_label: string,
|
||||
@Query() name_description: string,
|
||||
@Query() format: 'raw' | 'vhd'
|
||||
): Promise<WatchAction<string>> {
|
||||
// create task and add the possibility to watch/await the task
|
||||
const sr = this.restApi.getXapiObject(id, this.type) as any
|
||||
const length = req.headers['content-length']
|
||||
if (length !== undefined) {
|
||||
req.length = Number(length)
|
||||
}
|
||||
|
||||
const vdiRef = await sr.$importVdi(req, {
|
||||
format,
|
||||
name_label,
|
||||
name_description,
|
||||
})
|
||||
const vdiUUid = await sr.$xapi.getField('VDI', vdiRef, 'uuid')
|
||||
return vdiUUid
|
||||
}
|
||||
}
|
||||
50
@xen-orchestra/rest-api/src/vdi/vdi.controller.ts
Normal file
50
@xen-orchestra/rest-api/src/vdi/vdi.controller.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { FormField, Get, Middlewares, Path, Post, Put, Query, Request, Res, Response, Route, UploadedFile } from 'tsoa'
|
||||
import { Request as ExRequest, Response as ExResp } from 'express'
|
||||
|
||||
import { provideSingleton } from '../ioc/helper.js'
|
||||
import { XapiXoController } from '../abstract/xapi-xo.controller.js'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
|
||||
@Route('vdis')
|
||||
@provideSingleton(VdiController)
|
||||
export class VdiController extends XapiXoController<any> {
|
||||
constructor() {
|
||||
super('VDI')
|
||||
}
|
||||
/**
|
||||
* Some description
|
||||
*/
|
||||
@Get()
|
||||
@Response(401, 'unautorhized')
|
||||
public getVdis(): string[] {
|
||||
return this.getObjectIds()
|
||||
}
|
||||
|
||||
@Get('{id}.{format}')
|
||||
public async exportVdi(
|
||||
@Request() req: ExRequest,
|
||||
@Path() id: string,
|
||||
@Path() format: 'vhd' | 'raw',
|
||||
@Query() nbdConcurrency: number,
|
||||
@Query() perferNbd = false
|
||||
) {
|
||||
const res = req.res!
|
||||
const vdi = this.getXapiObject(id) as any
|
||||
const stream = await vdi.$exportContent({ format, perferNbd, nbdConcurrency })
|
||||
const headers = { 'content-disposition': 'attachment', 'content-length': undefined }
|
||||
|
||||
const { length } = stream
|
||||
if (length !== undefined) {
|
||||
headers['content-length'] = length
|
||||
}
|
||||
res.writeHead(200, 'OK', headers)
|
||||
await pipeline(stream, res)
|
||||
}
|
||||
|
||||
@Get('{id}')
|
||||
@Response(404, 'Not found')
|
||||
@Response(401, 'unautorhized')
|
||||
public getVdi(@Path() id: string) {
|
||||
return this.getById(id)
|
||||
}
|
||||
}
|
||||
50
@xen-orchestra/rest-api/src/vms/vm.controller.ts
Normal file
50
@xen-orchestra/rest-api/src/vms/vm.controller.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Get, Path, Post, Query, Response, Route } from 'tsoa'
|
||||
|
||||
import { XapiVm, XoVm } from './vm.type.js'
|
||||
import { provideSingleton } from '../ioc/helper.js'
|
||||
import { XapiXoController } from '../abstract/xapi-xo.controller.js'
|
||||
|
||||
interface ApiError {
|
||||
error: string
|
||||
}
|
||||
|
||||
@Route('vms')
|
||||
@provideSingleton(VmsController)
|
||||
export class VmsController extends XapiXoController<XoVm> {
|
||||
constructor() {
|
||||
super('VM')
|
||||
}
|
||||
/**
|
||||
* Some description
|
||||
*/
|
||||
@Get()
|
||||
@Response<ApiError>(401, 'unautorhized')
|
||||
public getVms(): XoVm['id'][] {
|
||||
return this.getObjectIds()
|
||||
}
|
||||
|
||||
@Get('{id}')
|
||||
@Response<ApiError>(404, 'Not found')
|
||||
@Response<ApiError>(401, 'unautorhized')
|
||||
public getVm(@Path() id: XoVm['id']) {
|
||||
return this.getById(id)
|
||||
}
|
||||
|
||||
@Post('{id}/actions/start')
|
||||
@Response(404, 'Not found')
|
||||
@Response<ApiError>(401, 'unautorhized')
|
||||
public startVm(@Path() id: XoVm['id']): void {
|
||||
const vm = this.restApi.getXapiObject(id, this.type) as XapiVm
|
||||
// create task and add the possibility to watch/await the task
|
||||
vm.$callAsync<void>('start', false, false).catch(() => {})
|
||||
}
|
||||
|
||||
@Post('{id}/actions/shutdown')
|
||||
@Response(404, 'Not found')
|
||||
@Response<ApiError>(401, 'unautorhized')
|
||||
public stopVm(@Path() id: XoVm['id'], @Query() hard?: boolean): void {
|
||||
const vm = this.restApi.getXapiObject(id, this.type) as XapiVm
|
||||
// create task and add the possibility to watch/await the task
|
||||
vm.$callAsync<void>(`${hard ? 'hard' : 'clean'}_shutdown`).catch(() => {})
|
||||
}
|
||||
}
|
||||
15
@xen-orchestra/rest-api/src/vms/vm.type.ts
Normal file
15
@xen-orchestra/rest-api/src/vms/vm.type.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { XapiObject, XapiXoObject } from '../xoApp.type.js'
|
||||
|
||||
export interface XapiVm extends XapiObject {
|
||||
uuid: string
|
||||
name_label: string
|
||||
power_state: 'Running' | 'Paused' | 'Halted' | 'Suspended'
|
||||
}
|
||||
|
||||
export interface XoVm extends XapiXoObject {
|
||||
type: 'VM'
|
||||
|
||||
name_label: XapiVm['name_label']
|
||||
uuid: XapiVm['uuid']
|
||||
power_state: XapiVm['power_state']
|
||||
}
|
||||
71
@xen-orchestra/rest-api/src/xoApp.type.ts
Normal file
71
@xen-orchestra/rest-api/src/xoApp.type.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import EventEmitter from 'events'
|
||||
import { XoVm } from './vms/vm.type.js'
|
||||
import { Response } from 'express'
|
||||
import { XoServer } from './servers/server.type.js'
|
||||
|
||||
// -----
|
||||
interface XoPool extends XapiXoObject {
|
||||
id: string
|
||||
type: 'pool'
|
||||
}
|
||||
|
||||
interface XoUser extends NonXapiObject {
|
||||
id: string
|
||||
permission: string
|
||||
}
|
||||
// -----
|
||||
export type XapiObject = {
|
||||
$ref: string
|
||||
$id: string
|
||||
$type: 'VM' | 'pool'
|
||||
$xapi: any
|
||||
$call: <T>(method: string, ...args: any) => Promise<T>
|
||||
$callAsync: <T>(method: string, ...args: any) => Promise<T>
|
||||
}
|
||||
/**
|
||||
* XapiXoObject can be every "xapi-to-xo" object
|
||||
*/
|
||||
export type XapiXoObject = {
|
||||
id: string
|
||||
type: XapiObject['$type']
|
||||
}
|
||||
|
||||
/**
|
||||
* Every object that is not aa XapiXoObject
|
||||
*/
|
||||
export type NonXapiObject = {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be every type of object handled by XO
|
||||
*/
|
||||
export type XoObject = XapiXoObject | NonXapiObject
|
||||
|
||||
interface Objects<T extends XapiXoObject> extends EventEmitter {
|
||||
indexes: {
|
||||
type: {
|
||||
[K in T['type']]: Record<T['id'], Extract<T, { type: K }>> | undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface XoApp extends EventEmitter {
|
||||
authenticateUser: (
|
||||
params: { token?: string; username?: string; password?: string },
|
||||
optional?: { ip?: string }
|
||||
) => Promise<{ bypassOtp: boolean; user: XoUser }>
|
||||
objects: Objects<XoVm | XoPool> /** Nedd to add all XapiXoType here */
|
||||
|
||||
getObjects: (opts?: { filter?: (obj: XapiXoObject) => boolean }) => Record<XapiXoObject['id'], XapiXoObject>
|
||||
getObject: <T extends XapiXoObject>(id: T['id'], type: T['type']) => T
|
||||
|
||||
// how to return the right type for getXapiObject?
|
||||
getXapiObject: <T extends XapiObject>(objOrId: XapiXoObject['id'] | XapiXoObject, type: XapiXoObject['type']) => T
|
||||
|
||||
getAllXenServers: () => Promise<XoServer[]>
|
||||
getXenServer: (id: XoServer['id']) => Promise<XoServer>
|
||||
|
||||
addSseClient: (id: symbol, client: Response) => void
|
||||
removeSseClient: (id: symbol) => void
|
||||
}
|
||||
39
@xen-orchestra/rest-api/tsconfig.json
Normal file
39
@xen-orchestra/rest-api/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Basic Options */
|
||||
"incremental": true,
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"allowJs": true,
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitAny": false,
|
||||
"alwaysStrict": true,
|
||||
|
||||
/* Additional Checks */
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Module Resolution Options */
|
||||
"baseUrl": "./",
|
||||
"esModuleInterop": true,
|
||||
|
||||
/* Experimental Options */
|
||||
"experimentalDecorators": true,
|
||||
|
||||
/* Advanced Options */
|
||||
"moduleResolution": "nodenext",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
49
@xen-orchestra/rest-api/tsoa.json
Normal file
49
@xen-orchestra/rest-api/tsoa.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"entryFile": "./src/index.ts",
|
||||
"noImplicitAdditionalProperties": "throw-on-extras",
|
||||
"controllerPathGlobs": ["./src/**/*.controller.ts"],
|
||||
"spec": {
|
||||
"outputDirectory": "./src/open-api/spec",
|
||||
"specVersion": 3,
|
||||
"basePath": "/rest/v1",
|
||||
"securityDefinitions": {
|
||||
"basic_auth": {
|
||||
"type": "http",
|
||||
"scheme": "basic"
|
||||
},
|
||||
"token": {
|
||||
"type": "apiKey",
|
||||
"name": "authenticationToken",
|
||||
"in": "cookie"
|
||||
}
|
||||
},
|
||||
"specMerging": "recursive",
|
||||
"spec": {
|
||||
"paths": {
|
||||
"/srs/{id}/actions/import_vdi": {
|
||||
"post": {
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"routes": {
|
||||
"routesDir": "./src/open-api/routes",
|
||||
"esm": true,
|
||||
"middleware": "express",
|
||||
"basePath": "/rest/v1",
|
||||
"iocModule": "./src/ioc/ioc.ts",
|
||||
"authenticationModule": "./src/middleware/authentication.ts"
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,10 @@ export class Collection extends EventEmitter {
|
||||
return this._size
|
||||
}
|
||||
|
||||
get allIndexes() {
|
||||
return this._indexes
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Manipulation
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@@ -4,11 +4,12 @@ import clearObject from './clear-object'
|
||||
import isEmpty from './is-empty'
|
||||
import NotImplemented from './not-implemented'
|
||||
import { ACTION_ADD, ACTION_UPDATE, ACTION_REMOVE } from './collection'
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
// ===================================================================
|
||||
|
||||
export class Index {
|
||||
export class Index extends EventEmitter {
|
||||
constructor(computeHash) {
|
||||
super()
|
||||
if (computeHash) {
|
||||
this.computeHash = iteratee(computeHash)
|
||||
}
|
||||
@@ -85,6 +86,7 @@ export class Index {
|
||||
(itemsByHash[hash] = {}))[key] = value
|
||||
|
||||
keysToHash[key] = hash
|
||||
this.emit('add', hash, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +110,7 @@ export class Index {
|
||||
(itemsByHash[hash] = {}))[key] = value
|
||||
|
||||
keysToHash[key] = hash
|
||||
this.emit('update', hash, value)
|
||||
} else {
|
||||
delete keysToHash[key]
|
||||
}
|
||||
@@ -120,6 +123,7 @@ export class Index {
|
||||
for (const key in items) {
|
||||
const prev = keysToHash[key]
|
||||
if (prev != null) {
|
||||
this.emit('remove', prev, itemsByHash[prev][key])
|
||||
delete itemsByHash[prev][key]
|
||||
delete keysToHash[key]
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"@xen-orchestra/mixin": "^0.2.0",
|
||||
"@xen-orchestra/mixins": "^0.16.2",
|
||||
"@xen-orchestra/rest-api": "^0.1.0",
|
||||
"@xen-orchestra/self-signed": "^0.2.1",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"@xen-orchestra/vmware-explorer": "^0.8.5",
|
||||
|
||||
@@ -52,6 +52,7 @@ import transportConsole from '@xen-orchestra/log/transports/console'
|
||||
import { configure } from '@xen-orchestra/log/configure'
|
||||
import { generateToken } from './utils.mjs'
|
||||
import { ProxyAgent } from 'proxy-agent'
|
||||
import { getRestApi } from '@xen-orchestra/rest-api'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -117,7 +118,11 @@ async function createExpressApp(config) {
|
||||
|
||||
app.use(helmet(config.http.helmet))
|
||||
|
||||
app.use(compression())
|
||||
app.use(
|
||||
compression({
|
||||
filter: req => req.url !== '/rest/v1/events', // compression not compatible with EventSource?? it supposed to work with eventsource@3
|
||||
})
|
||||
)
|
||||
|
||||
let { sessionSecret } = config.http
|
||||
if (sessionSecret === undefined) {
|
||||
@@ -920,6 +925,51 @@ export default async function main(args) {
|
||||
|
||||
if (!safeMode) {
|
||||
await registerPlugins(xo)
|
||||
// test to register a route on the fly
|
||||
await getRestApi().registerRoute(
|
||||
'netbox/sync',
|
||||
(req, res) => {
|
||||
res.send('route registered')
|
||||
},
|
||||
{
|
||||
get: {
|
||||
operationId: 'getSyncNetbox',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Ok',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: '#/components/schemas/Dashboard',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [],
|
||||
parameters: [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await getRestApi().registerRoute(
|
||||
'/foo/bar',
|
||||
(req, res) => {
|
||||
res.send('fo bar')
|
||||
},
|
||||
{
|
||||
get: {
|
||||
operationId: 'getFoobar',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Ok',
|
||||
},
|
||||
},
|
||||
security: [],
|
||||
parameters: [],
|
||||
},
|
||||
}
|
||||
)
|
||||
xo.emit('plugins:registered')
|
||||
}
|
||||
|
||||
|
||||
11
packages/xo-server/src/xo-mixins/rest-api-poc.mjs
Normal file
11
packages/xo-server/src/xo-mixins/rest-api-poc.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
import setupRestApi from '@xen-orchestra/rest-api'
|
||||
|
||||
export default class ClassRestApiPoc {
|
||||
constructor(app, { express }) {
|
||||
if (express === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
setupRestApi(express, app)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user