From 235fc074040f633d480b27fc938a3c43f0359d4a Mon Sep 17 00:00:00 2001 From: spacotte-vates Date: Mon, 29 Jun 2026 17:12:51 +0200 Subject: [PATCH] feat(rest-api): backup-repository health/bench routes (#9847) --- @vates/types/src/xo-app.mts | 7 ++ .../acl/src/actions/backup-repository.mts | 1 + @xen-orchestra/proxy/app/mixins/remotes.mjs | 9 ++ .../backup-repositories.controller.mts | 90 +++++++++++++++++++ .../backup-repository.oa-example.mts | 8 ++ CHANGELOG.unreleased.md | 8 ++ docs/docs/xo6/acl-v2.md | 26 +++--- packages/xo-server/src/xo-mixins/remotes.mjs | 24 +++-- 8 files changed, 154 insertions(+), 19 deletions(-) diff --git a/@vates/types/src/xo-app.mts b/@vates/types/src/xo-app.mts index dd8ce2d534..b2e4909516 100644 --- a/@vates/types/src/xo-app.mts +++ b/@vates/types/src/xo-app.mts @@ -312,6 +312,7 @@ export type XoApp = { backupRepositoryIds: XoBackupRepository['id'][], opts?: { _forceRefresh?: boolean; vmId: XoVm['id'] } ): Promise>> + pingRemote(id: XoBackupRepository['id']): Promise<{ success: true }> /** Allow to add a new server in the DB (XCP-ng/XenServer) */ registerXenServer( body: Pick & { @@ -327,6 +328,12 @@ export type XoApp = { removeUserFromGroup(userId: XoUser['id'], id: XoGroup['id']): Promise runJob(job: AnyXoJob, schedule: XoSchedule): void runWithApiContext: (user: XoUser | undefined, fn: () => void) => Promise + testRemote( + id: XoBackupRepository['id'] + ): Promise< + | { success: true; readRate: number; writeRate: number } + | { success: false; step: string; file: string; error: unknown } + > /** Remove a server from the DB (XCP-ng/XenServer) */ unregisterXenServer(id: XoServer['id']): Promise updateUser( diff --git a/@xen-orchestra/acl/src/actions/backup-repository.mts b/@xen-orchestra/acl/src/actions/backup-repository.mts index ed60fec1fa..3f980187d8 100644 --- a/@xen-orchestra/acl/src/actions/backup-repository.mts +++ b/@xen-orchestra/acl/src/actions/backup-repository.mts @@ -1,4 +1,5 @@ export default { + benchmark: true, create: true, read: true, forget: true, diff --git a/@xen-orchestra/proxy/app/mixins/remotes.mjs b/@xen-orchestra/proxy/app/mixins/remotes.mjs index 816ae36c48..6dc3dfccfc 100644 --- a/@xen-orchestra/proxy/app/mixins/remotes.mjs +++ b/@xen-orchestra/proxy/app/mixins/remotes.mjs @@ -20,6 +20,15 @@ export default class Remotes { }, ], + ping: [ + ({ remote }) => Disposable.use(this.getHandler(remote), () => ({ success: true })), + { + params: { + remote: { type: 'object' }, + }, + }, + ], + test: [ ({ remote }) => Disposable.use(this.getHandler(remote), handler => handler.test()).catch(error => ({ diff --git a/@xen-orchestra/rest-api/src/backup-repositories/backup-repositories.controller.mts b/@xen-orchestra/rest-api/src/backup-repositories/backup-repositories.controller.mts index ab25b61add..7b0a19ee29 100644 --- a/@xen-orchestra/rest-api/src/backup-repositories/backup-repositories.controller.mts +++ b/@xen-orchestra/rest-api/src/backup-repositories/backup-repositories.controller.mts @@ -23,11 +23,13 @@ import { forbiddenOperation } from 'xo-common/api-errors.js' import { acl, actionsFromBody } from '../middlewares/acl.middleware.mjs' import { + asynchronousActionResp, badRequestResp, createdResp, forbiddenOperationResp, invalidParameters, noContentResp, + internalServerErrorResp, notFoundResp, unauthorizedResp, type Unbrand, @@ -37,6 +39,8 @@ import { partialBackupRepositories, backupRepository, backupRepositoryId, + backupRepositoryBenchmark, + backupRepositoryHeath, } from '../open-api/oa-examples/backup-repository.oa-example.mjs' import type { SendObjects } from '../helpers/helper.type.mjs' import { XoController } from '../abstract-classes/xo-controller.mjs' @@ -44,6 +48,9 @@ import { RestApi } from '../rest-api/rest-api.mjs' import { BackupRepositoryService } from './backup-repository.service.mjs' import { CreateActionReturnType } from '../abstract-classes/base-controller.mjs' import { taskLocation } from '../open-api/oa-examples/task.oa-example.mjs' +import { ApiError } from '../helpers/error.helper.mjs' + +type BenchmarkRepositoryResult = Awaited> @Route('backup-repositories') @Security('*') @@ -224,4 +231,87 @@ export class BackupRepositoryController extends XoController ): Promise { await this.restApi.xoApp.updateRemote(id as XoBackupRepository['id'], body as Parameters[1]) } + + /** + * Pings the backup-repository to check its health + * + * Required privilege: + * - resource: backup-repository, action: read + * + * @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03" + */ + @Example(backupRepositoryHeath) + @Extension('x-mcp-exposure', 'allow') + @Get('{id}/health') + @Middlewares( + acl({ + resource: 'backup-repository', + action: 'read', + objectId: 'params.id', + getObject: ({ restApi }) => restApi.xoApp.getRemote, + }) + ) + @SuccessResponse(200, 'OK') + @Response(forbiddenOperationResp.status, forbiddenOperationResp.description) + @Response(internalServerErrorResp.status, internalServerErrorResp.description) + async getBackupRepositoryHealth(@Path() id: string): ReturnType { + return this.restApi.xoApp.pingRemote(id as XoBackupRepository['id']) + } + + /** + * Runs a benchmark for write and read speed on the Backup-repository. + * Saves the benchmark result on the BR and returns the results, speeds are in bytes/sec. + * 502 if the BR cannot be reached, 400 if there was a problem during the benchmark with the error. + * + * Required privilege: + * - resource: backup-repository, action: benchmark + * + * @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03" + */ + @Example(taskLocation) + @Example(backupRepositoryBenchmark) + @Extension('x-mcp-exposure', 'confirm') + @Post('{id}/actions/benchmark') + @Middlewares( + acl({ + resource: 'backup-repository', + action: 'benchmark', + objectId: 'params.id', + getObject: ({ restApi }) => restApi.xoApp.getRemote, + }) + ) + @SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description) + @Response(200, 'Ok') + @Response(400, 'Benchmark failed') + @Response(notFoundResp.status, notFoundResp.description) + @Response(forbiddenOperationResp.status, forbiddenOperationResp.description) + @Response(internalServerErrorResp.status, internalServerErrorResp.description) + @Response(502, 'Backup repository unreachable') + benchmarkBackupRepository( + @Path() id: string, + @Query() sync?: boolean + ): CreateActionReturnType { + const backupRepositoryId = id as XoBackupRepository['id'] + const action = async () => { + let result: BenchmarkRepositoryResult + try { + result = await this.restApi.xoApp.testRemote(backupRepositoryId) + } catch (error) { + throw new ApiError('Backup repository unreachable', 502) + } + if (!result.success) { + throw new ApiError('Benchmark failed', 400, { data: result }) + } + return result + } + + return this.createAction(action, { + sync, + statusCode: 200, + taskProperties: { + name: 'benchmark backup repository', + objectId: backupRepositoryId, + }, + }) + } } diff --git a/@xen-orchestra/rest-api/src/open-api/oa-examples/backup-repository.oa-example.mts b/@xen-orchestra/rest-api/src/open-api/oa-examples/backup-repository.oa-example.mts index d85095016c..a89aa0a1d7 100644 --- a/@xen-orchestra/rest-api/src/open-api/oa-examples/backup-repository.oa-example.mts +++ b/@xen-orchestra/rest-api/src/open-api/oa-examples/backup-repository.oa-example.mts @@ -33,3 +33,11 @@ export const backupRepository = { } export const backupRepositoryId = { id: '677e50c5-8d8a-4c89-b1ac-e2f4593d0ebb' } + +export const backupRepositoryHeath = { success: true } + +export const backupRepositoryBenchmark = { + success: true, + readRate: 7999965, + writeRate: 7767798, +} diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 28976f1ba1..ba1b4d0bd6 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -11,6 +11,8 @@ > Users must be able to say: "Nice enhancement, I'm eager to test it" +- [REST API] Expose `GET /backup-repositories/:id/health` and `POST /backup-repositories/:id/actions/benchmark` routes (PR [#9847](https://github.com/vatesfr/xen-orchestra/pull/9847)) + ### Bug fixes > Users must be able to say: "I had this issue, happy to know it's fixed" @@ -31,4 +33,10 @@ +- @vates/types minor +- @xen-orchestra/acl minor +- @xen-orchestra/proxy minor +- @xen-orchestra/rest-api minor +- xo-server minor + diff --git a/docs/docs/xo6/acl-v2.md b/docs/docs/xo6/acl-v2.md index 1797e30720..65f036a0ad 100644 --- a/docs/docs/xo6/acl-v2.md +++ b/docs/docs/xo6/acl-v2.md @@ -134,19 +134,19 @@ Actions are written using the exact string you pass in a privilege. A parent act ### XO management resources -| Resource | Available actions | -| ------------------- | ---------------------------------------------------------------------------------------------------------- | -| `backup-job` | `read` | -| `backup-archive` | `read` | -| `backup-log` | `read` | -| `backup-repository` | `create`, `read`,`forget`, `update:enabled`, `update:name`, `update:options`, `update:proxy`, `update:url` | -| `schedule` | `read`, `run` | -| `restore-log` | `read` | -| `proxy` | `read` | -| `server` | `read`, `create`, `delete`, `connect`, `disconnect` | -| `task` | `read`, `abort`, `delete` | -| `alarm` | `read` | -| `message` | `read` | +| Resource | Available actions | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `backup-job` | `read` | +| `backup-archive` | `read` | +| `backup-log` | `read` | +| `backup-repository` | `benchmark`, `create`, `read`, `forget`, `update:enabled`, `update:name`, `update:options`, `update:proxy`, `update:url` | +| `schedule` | `read`, `run` | +| `restore-log` | `read` | +| `proxy` | `read` | +| `server` | `read`, `create`, `delete`, `connect`, `disconnect` | +| `task` | `read`, `abort`, `delete` | +| `alarm` | `read` | +| `message` | `read` | ### User management resources diff --git a/packages/xo-server/src/xo-mixins/remotes.mjs b/packages/xo-server/src/xo-mixins/remotes.mjs index f9ce489512..0809217643 100644 --- a/packages/xo-server/src/xo-mixins/remotes.mjs +++ b/packages/xo-server/src/xo-mixins/remotes.mjs @@ -126,21 +126,33 @@ export default class { return handler } + async pingRemote(remoteId) { + const remote = await this.getRemoteWithCredentials(remoteId) + + remote.proxy !== undefined + ? await this._app.callProxyMethod(remote.proxy, 'remote.ping', { + remote, + }) + : await this.getRemoteHandler(remoteId) + + return { success: true } + } + async testRemote(remoteId) { const remote = await this.getRemoteWithCredentials(remoteId) - const { readRate, writeRate, ...answer } = + const result = remote.proxy !== undefined ? await this._app.callProxyMethod(remote.proxy, 'remote.test', { remote, }) : await this.getRemoteHandler(remoteId).then(handler => handler.test()) - if (answer.success) { + if (result.success) { const benchmark = { - readRate, + readRate: result.readRate, timestamp: Date.now(), - writeRate, + writeRate: result.writeRate, } await this._updateRemote(remoteId, { error: null, @@ -151,11 +163,11 @@ export default class { }) } else { await this._updateRemote(remoteId, { - error: answer.error, + error: result.error, }) } - return answer + return result } async getAllRemotesInfo() {