feat(rest-api): backup-repository health/bench routes (#9847)

This commit is contained in:
spacotte-vates
2026-06-29 17:12:51 +02:00
committed by GitHub
parent 26038fdaec
commit 235fc07404
8 changed files with 154 additions and 19 deletions

View File

@@ -312,6 +312,7 @@ export type XoApp = {
backupRepositoryIds: XoBackupRepository['id'][],
opts?: { _forceRefresh?: boolean; vmId: XoVm['id'] }
): Promise<Record<XoBackupRepository['id'], Record<XoVm['id'], XoVmBackupArchive[]>>>
pingRemote(id: XoBackupRepository['id']): Promise<{ success: true }>
/** Allow to add a new server in the DB (XCP-ng/XenServer) */
registerXenServer(
body: Pick<XoServer, 'host' | 'httpProxy' | 'label' | 'username'> & {
@@ -327,6 +328,12 @@ export type XoApp = {
removeUserFromGroup(userId: XoUser['id'], id: XoGroup['id']): Promise<void>
runJob(job: AnyXoJob, schedule: XoSchedule): void
runWithApiContext: (user: XoUser | undefined, fn: () => void) => Promise<unknown>
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<void>
updateUser(

View File

@@ -1,4 +1,5 @@
export default {
benchmark: true,
create: true,
read: true,
forget: true,

View File

@@ -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 => ({

View File

@@ -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<ReturnType<XoApp['testRemote']>>
@Route('backup-repositories')
@Security('*')
@@ -224,4 +231,87 @@ export class BackupRepositoryController extends XoController<XoBackupRepository>
): Promise<void> {
await this.restApi.xoApp.updateRemote(id as XoBackupRepository['id'], body as Parameters<XoApp['updateRemote']>[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<XoApp['pingRemote']> {
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<BenchmarkRepositoryResult> {
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<BenchmarkRepositoryResult>(action, {
sync,
statusCode: 200,
taskProperties: {
name: 'benchmark backup repository',
objectId: backupRepositoryId,
},
})
}
}

View File

@@ -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,
}

View File

@@ -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 @@
<!--packages-start-->
- @vates/types minor
- @xen-orchestra/acl minor
- @xen-orchestra/proxy minor
- @xen-orchestra/rest-api minor
- xo-server minor
<!--packages-end-->

View File

@@ -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

View File

@@ -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() {