mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-09-10 22:14:48 -05:00
feat(rest-api): add POST /srs route to create a storage repository (#9990)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export default {
|
||||
create: true,
|
||||
delete: true,
|
||||
forget: true,
|
||||
import: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
121
@xen-orchestra/rest-api/src/srs/sr.service.test.mts
Normal file
121
@xen-orchestra/rest-api/src/srs/sr.service.test.mts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -116,9 +116,9 @@ Actions are written using the exact string you pass in a privilege. A parent act
|
||||
| `vdi` | `read`, `create`, `delete`, `boot`, `export-content`, `import-content`, `migrate-send`, `update:name_description`, `update:name_label`, `update:size`, `update:tags` |
|
||||
| `vdi-snapshot` | `read`, `delete`, `export`, `update:tags` |
|
||||
| `vdi-unmanaged` | `read` |
|
||||
| `vif` | `connect`, `create`, `delete`, `disconnect`, `read`, `update:allowedIpv4Addresses`, `update:allowedIpv6Addresses`, `update:lockingMode`, `update:rateLimit`, `update:txChecksumming`,`update:other_config` |
|
||||
| `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` |
|
||||
|
||||
Reference in New Issue
Block a user