feat(rest-api): add POST /srs route to create a storage repository (#9990)

This commit is contained in:
Mathieu
2026-07-27 10:37:55 +02:00
committed by GitHub
parent 76b45a9b3b
commit bcb91e8b4f
10 changed files with 270 additions and 8 deletions

View File

@@ -233,6 +233,36 @@ export interface Xapi {
powerOnHost(hostId: XoHost['id']): Promise<void>
rebootHost(hostId: XoHost['id'], force?: boolean): Promise<void>
shutdownHost(hostId: XoHost['id'], opts?: { force?: boolean; bypassEvacuate?: boolean }): Promise<void>
SR_create(params: {
content_type?: XoSr['content_type']
// index signature, not `Record<string, string>`: tsoa emits an empty schema for `Record`
// (https://github.com/lukeautry/tsoa/pull/1858). device_config keys are driver-specific (SR_type).
device_config: {
chapuser?: string
chappassword?: string
device?: string
legacy_mode?: string
location?: string
nfsversion?: string
options?: string
password?: string
SCSIid?: string
server?: string
serverpath?: string
target?: string
targetIQN?: string
username?: string
} & {
[key: string]: string
}
host: XenApiHost['$ref']
name_description?: XoSr['name_description']
name_label: XoSr['name_label']
physical_size?: number
shared: boolean
sm_config?: { [key: string]: string }
type: XoSr['SR_type']
}): Promise<XenApiSr['$ref']>
SR_importVdi(
ref: XenApiSr['$ref'],
stream: Readable,

View File

@@ -614,7 +614,7 @@ export type XoSr = BaseXapiXo & {
VDIs: AnyXoVdi['id'][]
allocationStrategy?: 'thin' | 'thick' | 'unknown'
content_type: string
content_type: 'user' | 'iso' | 'disk'
current_operations: Record<string, STORAGE_OPERATIONS>
id: Branded<'SR'>
inMaintenanceMode: boolean
@@ -625,7 +625,28 @@ export type XoSr = BaseXapiXo & {
shared: boolean
size: number
sm_config: Record<string, string>
SR_type: string
// SM drivers shipped with XCP-ng/XenServer
SR_type:
| 'cephfs'
| 'dummy'
| 'ext'
| 'file'
| 'glusterfs'
| 'hba'
| 'iso'
| 'largeblock'
| 'linstor'
| 'lvm'
| 'lvmofcoe'
| 'lvmohba'
| 'lvmoiscsi'
| 'moosefs'
| 'nfs'
| 'shm'
| 'smb'
| 'udev'
| 'xfs'
| 'zfs'
tags: string[]
type: 'SR'
usage: number

View File

@@ -1,4 +1,5 @@
export default {
create: true,
delete: true,
forget: true,
import: {

View File

@@ -52,6 +52,11 @@ export const internalServerErrorResp = {
description: 'Internal server error, XenServer/XCP-ng error',
} as const
export const notImplementedResp = {
status: 501,
description: 'Not implemented',
} as const
export const resourceAlreadyExists = {
status: 409,
description: 'Resource already exists',

View File

@@ -1,3 +1,7 @@
export const srId = {
id: 'c4284e12-37c9-7967-b9e8-83ef229c3e03',
}
export const srIds = [
'/rest/v0/srs/e46e7ea5-1bbe-e499-69a5-6bfb395eb146',
'/rest/v0/srs/3d1227f3-7d40-a104-efc6-fb797b58f258',

View File

@@ -1,4 +1,5 @@
import {
Body,
Delete,
Example,
Extension,
@@ -17,7 +18,7 @@ import {
} from 'tsoa'
import { inject } from 'inversify'
import { provide } from 'inversify-binding-decorators'
import { Request as ExRequest } from 'express'
import { json, Request as ExRequest } from 'express'
import type { XenApiVdi, XoMessage, XoTask, XoVdi, XoAlarm, XoSr } from '@vates/types'
import { SUPPORTED_VDI_FORMAT } from '@vates/types'
@@ -35,10 +36,11 @@ import {
invalidParameters as invalidParametersResp,
noContentResp,
notFoundResp,
notImplementedResp,
unauthorizedResp,
type Unbrand,
} from '../open-api/common/response.common.mjs'
import { partialSrs, sr, srIds } from '../open-api/oa-examples/sr.oa-example.mjs'
import { partialSrs, sr, srId, srIds } from '../open-api/oa-examples/sr.oa-example.mjs'
import { vdiId } from '../open-api/oa-examples/vdi.oa-example.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import type { SendObjects } from '../helpers/helper.type.mjs'
@@ -46,7 +48,7 @@ import { XapiXoController } from '../abstract-classes/xapi-xo-controller.mjs'
import { messageIds, partialMessages } from '../open-api/oa-examples/message.oa-example.mjs'
import { taskIds, partialTasks, taskLocation } from '../open-api/oa-examples/task.oa-example.mjs'
import type { CreateActionReturnType } from '../abstract-classes/base-controller.mjs'
import { SrService } from './sr.service.mjs'
import { SrService, type CreateSrBody } from './sr.service.mjs'
@Route('srs')
@Security('*')
@@ -110,6 +112,38 @@ export class SrController extends XapiXoController<XoSr> {
return this.getObject(id as XoSr['id'])
}
/**
* Create a storage repository (SR) on the given host.
*
* `device_config` is passed as-is to XAPI and its keys depend on `SR_type`
* (e.g. nfs: `{ server, serverpath }`, smb: `{ server, username, password }`,
* lvmoiscsi: `{ target, targetIQN, SCSIid }`).
*
* `shared` is computed from `SR_type`: network/SAN backed SRs are shared, local ones are not.
*
* XOSTOR (`SR_type: "linstor"`) creation is not supported yet: such requests fail with `501 Not Implemented`.
*
* Required privilege:
* - resource: sr, action: create
*
* @example body { "hostId": "f8b8d2a5-7d40-a104-efc6-fb797b58f258", "name_label": "NFS store", "SR_type": "nfs", "device_config": { "server": "10.0.0.1", "serverpath": "/data" } }
*/
@Example(srId)
@Extension('x-mcp-exposure', 'confirm')
@Post('')
@Middlewares([json(), acl({ resource: 'sr', action: 'create', object: ({ req }) => req.body })])
@SuccessResponse(createdResp.status, createdResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(invalidParametersResp.status, invalidParametersResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
@Response(notImplementedResp.status, notImplementedResp.description)
async createSr(@Body() body: CreateSrBody): Promise<{ id: string }> {
const id = await this.#srService.create(body)
this.setHeader('Location', `${BASE_URL}/srs/${id}`)
return { id }
}
/**
* Returns all alarms that match the following privilege:
* - resource: alarm, action: read

View File

@@ -1,7 +1,31 @@
import type { XoSr } from '@vates/types'
import type { Xapi, XenApiSr, XoHost, XoSr } from '@vates/types'
import type { RestApi } from '../rest-api/rest-api.mjs'
import { ApiError } from '../helpers/error.helper.mjs'
import { LicenseService } from '../licenses/license.service.mjs'
// Derived from `xapi.SR_create` params, renamed to stay consistent with the XO SR
// representation returned by `GET /srs/:id`: `hostId` (XO id, not XAPI ref),
// `SR_type` and `size` instead of XAPI's `type`/`physical_size`.
// `device_config` is passed through as-is, `shared` is computed from `SR_type`.
export type CreateSrBody = Omit<Parameters<Xapi['SR_create']>[0], 'host' | 'type' | 'physical_size' | 'shared'> & {
hostId: string
SR_type: XoSr['SR_type']
size?: number
}
// SR types backed by network/SAN storage, every other type is local to the host
const SHARED_SR_TYPES: ReadonlySet<XoSr['SR_type']> = new Set([
'cephfs',
'glusterfs',
'hba',
'lvmofcoe',
'lvmohba',
'lvmoiscsi',
'moosefs',
'nfs',
'smb',
])
export class SrService {
#restApi: RestApi
@@ -9,6 +33,26 @@ export class SrService {
this.#restApi = restApi
}
async create(body: CreateSrBody): Promise<XoSr['id']> {
const { hostId, SR_type, size, ...rest } = body
if (SR_type === 'linstor') {
// XOSTOR creation requires dedicated logic (licenses, multi-host setup), not supported yet
throw new ApiError('SR creation with SR_type "linstor" (XOSTOR) is not implemented yet', 501)
}
const xapiHost = this.#restApi.getXapiObject<XoHost>(hostId as XoHost['id'], 'host')
const ref = await xapiHost.$xapi.SR_create({
...rest,
// XAPI expects ISO libraries to have an `iso` content type
...(SR_type === 'iso' && { content_type: 'iso' as const }),
host: xapiHost.$ref,
physical_size: size,
// an ISO SR is local only when it targets a local path (`legacy_mode`), same rules as xo-server `sr.createIso`
shared: SR_type === 'iso' ? rest.device_config.legacy_mode !== 'true' : SHARED_SR_TYPES.has(SR_type),
type: SR_type,
})
return (await xapiHost.$xapi.getField<XenApiSr, 'uuid'>('SR', ref, 'uuid')) as XoSr['id']
}
async delete(id: XoSr['id']): Promise<void> {
const sr = this.#restApi.getObject<XoSr>(id, 'SR')
const xapiSr = this.#restApi.getXapiObject<XoSr>(id, 'SR')

View File

@@ -0,0 +1,121 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { SrService, type CreateSrBody } from './sr.service.mjs'
import { ApiError } from '../helpers/error.helper.mjs'
import type { RestApi } from '../rest-api/rest-api.mjs'
describe('SrService.create', () => {
it('resolves the host id to its XAPI ref, maps XO names to XAPI names, and returns the SR uuid', async () => {
let srCreateParams: Record<string, unknown> | undefined
let getFieldArgs: unknown[] | undefined
let getXapiObjectArgs: unknown[] | undefined
const fakeXapi = {
SR_create: async (params: Record<string, unknown>) => {
srCreateParams = params
return 'OpaqueRef:new-sr'
},
getField: async (...args: unknown[]) => {
getFieldArgs = args
return 'sr-uuid-123'
},
}
const xapiHost = { $ref: 'OpaqueRef:host-1', $xapi: fakeXapi }
const restApi = {
getXapiObject: (...args: unknown[]) => {
getXapiObjectArgs = args
return xapiHost
},
} as unknown as RestApi
const service = new SrService(restApi)
const body = {
hostId: 'host-uuid',
name_label: 'NFS store',
SR_type: 'nfs',
size: 1234,
device_config: { server: '10.0.0.1', serverpath: '/data' },
} as CreateSrBody
const id = await service.create(body)
// host id is resolved against the 'host' collection
assert.deepEqual(getXapiObjectArgs, ['host-uuid', 'host'])
// body.hostId (XO id) is replaced by the XAPI ref, SR_type/size are mapped to
// XAPI's type/physical_size, shared is computed from SR_type (nfs -> shared),
// device_config is forwarded untouched
assert.deepEqual(srCreateParams, {
name_label: 'NFS store',
type: 'nfs',
physical_size: 1234,
shared: true,
device_config: { server: '10.0.0.1', serverpath: '/data' },
host: 'OpaqueRef:host-1',
})
// uuid is read back from the freshly created SR ref
assert.deepEqual(getFieldArgs, ['SR', 'OpaqueRef:new-sr', 'uuid'])
assert.equal(id, 'sr-uuid-123')
})
it('computes shared from SR_type and forces content_type for ISO SRs', async () => {
const srCreateCalls: Record<string, unknown>[] = []
const fakeXapi = {
SR_create: async (params: Record<string, unknown>) => {
srCreateCalls.push(params)
return 'OpaqueRef:new-sr'
},
getField: async () => 'sr-uuid-123',
}
const restApi = {
getXapiObject: () => ({ $ref: 'OpaqueRef:host-1', $xapi: fakeXapi }),
} as unknown as RestApi
const service = new SrService(restApi)
// local SR type -> not shared
await service.create({
hostId: 'host-uuid',
name_label: 'local',
SR_type: 'ext',
device_config: {},
} as CreateSrBody)
assert.equal(srCreateCalls[0].shared, false)
assert.equal('content_type' in srCreateCalls[0], false)
// remote ISO library -> shared, content_type forced to 'iso'
await service.create({
hostId: 'host-uuid',
name_label: 'isos',
SR_type: 'iso',
device_config: { location: '10.0.0.1:/isos' },
} as CreateSrBody)
assert.equal(srCreateCalls[1].shared, true)
assert.equal(srCreateCalls[1].content_type, 'iso')
// local ISO library (legacy_mode) -> not shared
await service.create({
hostId: 'host-uuid',
name_label: 'local isos',
SR_type: 'iso',
device_config: { legacy_mode: 'true', location: '/media/isos' },
} as CreateSrBody)
assert.equal(srCreateCalls[2].shared, false)
assert.equal(srCreateCalls[2].content_type, 'iso')
})
it('rejects linstor (XOSTOR) SR creation with 501', async () => {
const service = new SrService({} as RestApi)
const body = {
hostId: 'host-uuid',
name_label: 'XOSTOR',
SR_type: 'linstor',
device_config: {},
} as CreateSrBody
await assert.rejects(service.create(body), (error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 501)
return true
})
})
})

View File

@@ -51,6 +51,7 @@
- [XO6/Users] Add Users Table (PR [#10029](https://github.com/vatesfr/xen-orchestra/pull/10029))
- [XO6] Input fields now support prefix/suffix sections, and display validation messages ordered by severity (PR [#10009](https://github.com/vatesfr/xen-orchestra/pull/10009))
- [XO6/New VM] Add the possibility to set the HA restart priority when creating a VM (PR [#10072](https://github.com/vatesfr/xen-orchestra/pull/10072))
- [REST API] Add `POST /srs` REST route to create a storage repository (PR [#9990](https://github.com/vatesfr/xen-orchestra/pull/9990))
### Bug fixes
@@ -89,6 +90,7 @@
<!--packages-start-->
- @vates/types minor
- @xen-orchestra/acl minor
- @xen-orchestra/backup-archive minor
- @xen-orchestra/backups patch
- @xen-orchestra/disk-cli minor

View File

@@ -118,7 +118,7 @@ Actions are written using the exact string you pass in a privilege. A parent act
| `vdi-unmanaged` | `read` |
| `vif` | `connect`, `create`, `delete`, `disconnect`, `read`, `update:allowedIpv4Addresses`, `update:allowedIpv6Addresses`, `update:lockingMode`, `update:rateLimit`, `update:txChecksumming`,`update:other_config` |
| `vbd` | `read`, `create`, `delete`, `connect`, `disconnect` |
| `sr` | `read`, `delete`, `forget`, `migrate-receive`, `reclaim-space`, `scan`, `import:vdi`, `import:vm`, `update:tags` |
| `sr` | `read`, `create`, `delete`, `forget`, `migrate-receive`, `reclaim-space`, `scan`, `import:vdi`, `import:vm`, `update:tags` |
| `host` | `read`, `allow-vm`, `join-pool`, `export:logs`, `update:tags`, `migrate-receive`, `disable`, `enable`, `evacuate`, `detach`, `shutdown:clean`, `shutdown:emergency`, `forget`, `reboot:clean`, `reboot:smart`, `restart-toolstack`, `start` |
| `pool` | `add-host`, `read`, `emergency-shutdown`, `rolling-reboot`, `rolling-update`, `create:network`, `create:vm`, `update:tags` |
| `network` | `read`, `create`, `delete`, `update:tags`,`update:other_config` |