From bcb91e8b4fb6beaa08ec30b1a8b0f024423e8dc8 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Mon, 27 Jul 2026 10:37:55 +0200 Subject: [PATCH] feat(rest-api): add POST /srs route to create a storage repository (#9990) --- @vates/types/src/lib/xen-orchestra-xapi.mts | 30 +++++ @vates/types/src/xo.mts | 25 +++- @xen-orchestra/acl/src/actions/sr.mts | 1 + .../src/open-api/common/response.common.mts | 5 + .../open-api/oa-examples/sr.oa-example.mts | 4 + .../rest-api/src/srs/sr.controller.mts | 40 +++++- .../rest-api/src/srs/sr.service.mts | 46 ++++++- .../rest-api/src/srs/sr.service.test.mts | 121 ++++++++++++++++++ CHANGELOG.unreleased.md | 2 + docs/docs/xo6/acl-v2.md | 4 +- 10 files changed, 270 insertions(+), 8 deletions(-) create mode 100644 @xen-orchestra/rest-api/src/srs/sr.service.test.mts diff --git a/@vates/types/src/lib/xen-orchestra-xapi.mts b/@vates/types/src/lib/xen-orchestra-xapi.mts index 327bf2e23e..d8880b20ed 100644 --- a/@vates/types/src/lib/xen-orchestra-xapi.mts +++ b/@vates/types/src/lib/xen-orchestra-xapi.mts @@ -233,6 +233,36 @@ export interface Xapi { powerOnHost(hostId: XoHost['id']): Promise rebootHost(hostId: XoHost['id'], force?: boolean): Promise shutdownHost(hostId: XoHost['id'], opts?: { force?: boolean; bypassEvacuate?: boolean }): Promise + SR_create(params: { + content_type?: XoSr['content_type'] + // index signature, not `Record`: 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 SR_importVdi( ref: XenApiSr['$ref'], stream: Readable, diff --git a/@vates/types/src/xo.mts b/@vates/types/src/xo.mts index 4ec49d3a8e..08dc514089 100644 --- a/@vates/types/src/xo.mts +++ b/@vates/types/src/xo.mts @@ -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 id: Branded<'SR'> inMaintenanceMode: boolean @@ -625,7 +625,28 @@ export type XoSr = BaseXapiXo & { shared: boolean size: number sm_config: Record - 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 diff --git a/@xen-orchestra/acl/src/actions/sr.mts b/@xen-orchestra/acl/src/actions/sr.mts index be0e34abd2..0b07a6a3de 100644 --- a/@xen-orchestra/acl/src/actions/sr.mts +++ b/@xen-orchestra/acl/src/actions/sr.mts @@ -1,4 +1,5 @@ export default { + create: true, delete: true, forget: true, import: { diff --git a/@xen-orchestra/rest-api/src/open-api/common/response.common.mts b/@xen-orchestra/rest-api/src/open-api/common/response.common.mts index d3ca6d49cb..7ef9d53986 100644 --- a/@xen-orchestra/rest-api/src/open-api/common/response.common.mts +++ b/@xen-orchestra/rest-api/src/open-api/common/response.common.mts @@ -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', diff --git a/@xen-orchestra/rest-api/src/open-api/oa-examples/sr.oa-example.mts b/@xen-orchestra/rest-api/src/open-api/oa-examples/sr.oa-example.mts index be9260ccdb..b71811e5e5 100644 --- a/@xen-orchestra/rest-api/src/open-api/oa-examples/sr.oa-example.mts +++ b/@xen-orchestra/rest-api/src/open-api/oa-examples/sr.oa-example.mts @@ -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', diff --git a/@xen-orchestra/rest-api/src/srs/sr.controller.mts b/@xen-orchestra/rest-api/src/srs/sr.controller.mts index d981bfeeb4..e522cc1461 100644 --- a/@xen-orchestra/rest-api/src/srs/sr.controller.mts +++ b/@xen-orchestra/rest-api/src/srs/sr.controller.mts @@ -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 { 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 diff --git a/@xen-orchestra/rest-api/src/srs/sr.service.mts b/@xen-orchestra/rest-api/src/srs/sr.service.mts index 700b885de5..a7b51e5a15 100644 --- a/@xen-orchestra/rest-api/src/srs/sr.service.mts +++ b/@xen-orchestra/rest-api/src/srs/sr.service.mts @@ -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[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 = 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 { + 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(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('SR', ref, 'uuid')) as XoSr['id'] + } + async delete(id: XoSr['id']): Promise { const sr = this.#restApi.getObject(id, 'SR') const xapiSr = this.#restApi.getXapiObject(id, 'SR') diff --git a/@xen-orchestra/rest-api/src/srs/sr.service.test.mts b/@xen-orchestra/rest-api/src/srs/sr.service.test.mts new file mode 100644 index 0000000000..761be33920 --- /dev/null +++ b/@xen-orchestra/rest-api/src/srs/sr.service.test.mts @@ -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 | undefined + let getFieldArgs: unknown[] | undefined + let getXapiObjectArgs: unknown[] | undefined + + const fakeXapi = { + SR_create: async (params: Record) => { + 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[] = [] + const fakeXapi = { + SR_create: async (params: Record) => { + 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 + }) + }) +}) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 947330b462..ac6fd7dd39 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -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 @@ - @vates/types minor +- @xen-orchestra/acl minor - @xen-orchestra/backup-archive minor - @xen-orchestra/backups patch - @xen-orchestra/disk-cli minor diff --git a/docs/docs/xo6/acl-v2.md b/docs/docs/xo6/acl-v2.md index e9bf09c99a..71184f30bb 100644 --- a/docs/docs/xo6/acl-v2.md +++ b/docs/docs/xo6/acl-v2.md @@ -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` |