diff --git a/@vates/types/package.json b/@vates/types/package.json index 1a1c0fe806..d0afdfbe9c 100644 --- a/@vates/types/package.json +++ b/@vates/types/package.json @@ -13,6 +13,10 @@ "./common": { "default": "./dist/common.mjs", "types": "./dist/common.d.mts" + }, + "./lib/vates/*": { + "default": "./dist/lib/vates-*.mjs", + "type": "./dist/lib/vates-*.d.mts" } }, "type": "module", diff --git a/@vates/types/src/lib/vates-task.mts b/@vates/types/src/lib/vates-task.mts new file mode 100644 index 0000000000..6cee22dee4 --- /dev/null +++ b/@vates/types/src/lib/vates-task.mts @@ -0,0 +1,7 @@ +import { Branded } from '../common.mjs' + +export type Task = { + id: Branded<'task'> + + run: (fn: () => T) => Promise +} diff --git a/@vates/types/src/xen-api.mts b/@vates/types/src/xen-api.mts index b01d24ee03..11a559dd7a 100644 --- a/@vates/types/src/xen-api.mts +++ b/@vates/types/src/xen-api.mts @@ -68,9 +68,22 @@ import type { // https://github.com/vatesfr/xen-orchestra/tree/wip-xapi-types-generator/%40xen-orchestra/xapi-generator // All properties added after XenServer 7.0 (dundee) release have been marked as optional +/** + * Add properties injected by `xen-api`. + * $ref property is also injected by XOLite, so she is not present here. + */ +type WrapperXenApi = T & { + $call: Fn + $callAsync: Fn + $type: Type + $xapi: { + call: (...args: unknown[]) => Promise + callAsync: (...args: unknown[]) => Promise + } +} + export interface XenApiSession { $ref: Branded<'session'> - $type: 'session' auth_user_name: string auth_user_sid: string client_certificate?: boolean @@ -91,12 +104,10 @@ export interface XenApiSession { export interface XenApiAuth { $ref: Branded<'auth'> - $type: 'auth' } export interface XenApiSubject { $ref: Branded<'subject'> - $type: 'subject' other_config: Record roles: XenApiRole['$ref'][] subject_identifier: string @@ -105,7 +116,6 @@ export interface XenApiSubject { export interface XenApiRole { $ref: Branded<'role'> - $type: 'role' is_internal?: boolean name_description: string name_label: string @@ -115,7 +125,6 @@ export interface XenApiRole { export interface XenApiTask { $ref: Branded<'task'> - $type: 'task' allowed_operations: TASK_ALLOWED_OPERATIONS[] backtrace: string created?: string @@ -137,7 +146,6 @@ export interface XenApiTask { export interface XenApiEvent { $ref: Branded<'event'> - $type: 'event' class?: string id?: number /** @deprecated */ @@ -151,7 +159,6 @@ export interface XenApiEvent { export interface XenApiPool { $ref: Branded<'pool'> - $type: 'pool' allowed_operations: POOL_ALLOWED_OPERATIONS[] blobs: Record client_certificate_auth_enabled?: boolean @@ -212,10 +219,10 @@ export interface XenApiPool { /** @deprecated */ wlb_verify_cert?: boolean } +export type XenApiPoolWrapped = WrapperXenApi export interface XenApiPoolPatch { $ref: Branded<'pool_patch'> - $type: 'pool_patch' after_apply_guidance: AFTER_APPLY_GUIDANCE[] host_patches: XenApiHostPatch['$ref'][] name_description: string @@ -230,7 +237,6 @@ export interface XenApiPoolPatch { export interface XenApiPoolUpdate { $ref: Branded<'pool_update'> - $type: 'pool_update' after_apply_guidance?: UPDATE_AFTER_APPLY_GUIDANCE[] enforce_homogeneity?: boolean hosts?: XenApiHost['$ref'][] @@ -244,9 +250,11 @@ export interface XenApiPoolUpdate { version?: string } +type XenApiVmCallMethods = { + (method: 'start', start_paused: boolean, force: boolean): Promise +} export interface XenApiVm { $ref: Branded<'VM'> - $type: 'VM' actions_after_crash?: ON_CRASH_BEHAVIOUR actions_after_reboot?: ON_NORMAL_EXIT actions_after_shutdown?: ON_NORMAL_EXIT @@ -344,10 +352,10 @@ export interface XenApiVm { VUSBs: XenApiVusb['$ref'][] xenstore_data: Record } +export type XenApiVmWrapped = WrapperXenApi export interface XenApiVmMetrics { $ref: Branded<'VM_metrics'> - $type: 'VM_metrics' current_domain_type?: DOMAIN_TYPE hvm?: boolean install_time?: string @@ -369,7 +377,6 @@ export interface XenApiVmMetrics { export interface XenApiVmGuestMetrics { $ref: Branded<'VM_guest_metrics'> - $type: 'VM_guest_metrics' can_use_hotplug_vbd: TRISTATE_TYPE can_use_hotplug_vif: TRISTATE_TYPE /** @deprecated */ @@ -391,7 +398,6 @@ export interface XenApiVmGuestMetrics { export interface XenApiVmpp { $ref: Branded<'VMPP'> - $type: 'VMPP' /** @deprecated */ alarm_config?: Record /** @deprecated */ @@ -434,7 +440,6 @@ export interface XenApiVmpp { export interface XenApiVmss { $ref: Branded<'VMSS'> - $type: 'VMSS' enabled: boolean frequency?: VMSS_FREQUENCY last_run_time: string @@ -449,7 +454,6 @@ export interface XenApiVmss { export interface XenApiVmAppliance { $ref: Branded<'VM_appliance'> - $type: 'VM_appliance' allowed_operations: VM_APPLIANCE_OPERATION[] current_operations: Record name_description: string @@ -460,14 +464,12 @@ export interface XenApiVmAppliance { export interface XenApiDrTask { $ref: Branded<'DR_task'> - $type: 'DR_task' introduced_SRs: XenApiSr['$ref'][] uuid: string } export interface XenApiHost { $ref: Branded<'host'> - $type: 'host' address?: string allowed_operations: HOST_ALLOWED_OPERATIONS[] API_version_major?: number @@ -542,10 +544,10 @@ export interface XenApiHost { uuid: string virtual_hardware_platform_versions: number[] } +export type XenApiHostWrapped = WrapperXenApi export interface XenApiHostCrashdump { $ref: Branded<'host_crashdump'> - $type: 'host_crashdump' host?: XenApiHost['$ref'] other_config: Record size?: number @@ -555,7 +557,6 @@ export interface XenApiHostCrashdump { export interface XenApiHostPatch { $ref: Branded<'host_patch'> - $type: 'host_patch' applied?: boolean host?: XenApiHost['$ref'] name_description: string @@ -570,7 +571,6 @@ export interface XenApiHostPatch { export interface XenApiHostMetrics { $ref: Branded<'host_metrics'> - $type: 'host_metrics' last_updated?: string live?: boolean /** @deprecated */ @@ -582,7 +582,6 @@ export interface XenApiHostMetrics { export interface XenApiHostCpu { $ref: Branded<'host_cpu'> - $type: 'host_cpu' family?: number features?: string flags?: string @@ -600,7 +599,6 @@ export interface XenApiHostCpu { export interface XenApiNetwork { $ref: Branded<'network'> - $type: 'network' allowed_operations: NETWORK_OPERATIONS[] assigned_ips: Record blobs: Record @@ -621,7 +619,6 @@ export interface XenApiNetwork { export interface XenApiVif { $ref: Branded<'VIF'> - $type: 'VIF' allowed_operations: VIF_OPERATIONS[] current_operations: Record currently_attached: boolean @@ -651,10 +648,10 @@ export interface XenApiVif { uuid: string VM?: XenApiVm['$ref'] } +export type XenApiVifWrapped = WrapperXenApi export interface XenApiVifMetrics { $ref: Branded<'VIF_metrics'> - $type: 'VIF_metrics' /** @deprecated */ io_read_kbs?: number /** @deprecated */ @@ -666,7 +663,6 @@ export interface XenApiVifMetrics { export interface XenApiPif { $ref: Branded<'PIF'> - $type: 'PIF' bond_master_of: XenApiBond['$ref'][] bond_slave_of?: XenApiBond['$ref'] capabilities: string[] @@ -706,7 +702,6 @@ export interface XenApiPif { export interface XenApiPifMetrics { $ref: Branded<'PIF_metrics'> - $type: 'PIF_metrics' carrier?: boolean device_id?: string device_name?: string @@ -726,7 +721,6 @@ export interface XenApiPifMetrics { export interface XenApiBond { $ref: Branded<'Bond'> - $type: 'Bond' auto_update_mac?: boolean links_up: number master?: XenApiPif['$ref'] @@ -740,7 +734,6 @@ export interface XenApiBond { export interface XenApiVlan { $ref: Branded<'VLAN'> - $type: 'VLAN' other_config: Record tag: number tagged_PIF?: XenApiPif['$ref'] @@ -750,7 +743,6 @@ export interface XenApiVlan { export interface XenApiSm { $ref: Branded<'SM'> - $type: 'SM' /** @deprecated */ capabilities?: string[] configuration?: Record @@ -770,7 +762,6 @@ export interface XenApiSm { export interface XenApiSr { $ref: Branded<'SR'> - $type: 'SR' allowed_operations: STORAGE_OPERATIONS[] blobs: Record clustered: boolean @@ -793,10 +784,10 @@ export interface XenApiSr { VDIs: XenApiVdi['$ref'][] virtual_allocation?: number } +export type XenApiSrWrapped = WrapperXenApi export interface XenApiSrStat { $ref: Branded<'sr_stat'> - $type: 'sr_stat' clustered?: boolean free_space?: number health?: SR_HEALTH @@ -808,7 +799,6 @@ export interface XenApiSrStat { export interface XenApiProbeResult { $ref: Branded<'probe_result'> - $type: 'probe_result' complete?: boolean configuration?: Record extra_info?: Record @@ -817,13 +807,11 @@ export interface XenApiProbeResult { export interface XenApiLvhd { $ref: Branded<'LVHD'> - $type: 'LVHD' uuid: string } export interface XenApiVdi { $ref: Branded<'VDI'> - $type: 'VDI' allow_caching: boolean allowed_operations: VDI_OPERATIONS[] cbt_enabled?: boolean @@ -858,10 +846,10 @@ export interface XenApiVdi { virtual_size?: number xenstore_data: Record } +export type XenApiVdiWrapped = WrapperXenApi export interface XenApiVbd { $ref: Branded<'VBD'> - $type: 'VBD' allowed_operations: VBD_OPERATIONS[] bootable?: boolean current_operations: Record @@ -886,10 +874,10 @@ export interface XenApiVbd { VDI?: XenApiVdi['$ref'] VM?: XenApiVm['$ref'] } +export type XenApiVbdWrapped = WrapperXenApi export interface XenApiVbdMetrics { $ref: Branded<'VBD_metrics'> - $type: 'VBD_metrics' /** @deprecated */ io_read_kbs?: number /** @deprecated */ @@ -903,7 +891,6 @@ export interface XenApiVbdMetrics { export interface XenApiPbd { $ref: Branded<'PBD'> - $type: 'PBD' currently_attached?: boolean device_config?: Record host?: XenApiHost['$ref'] @@ -914,7 +901,6 @@ export interface XenApiPbd { export interface XenApiCrashdump { $ref: Branded<'crashdump'> - $type: 'crashdump' other_config: Record uuid: string VDI?: XenApiVdi['$ref'] @@ -923,7 +909,6 @@ export interface XenApiCrashdump { export interface XenApiVtpm { $ref: Branded<'VTPM'> - $type: 'VTPM' allowed_operations: VTPM_OPERATIONS[] backend?: XenApiVm['$ref'] current_operations: Record @@ -933,10 +918,10 @@ export interface XenApiVtpm { uuid: string VM?: XenApiVm['$ref'] } +export type XenApiVtpmWrapped = WrapperXenApi export interface XenApiConsole { $ref: Branded<'console'> - $type: 'console' location?: string other_config?: Record protocol?: CONSOLE_PROTOCOL @@ -946,7 +931,6 @@ export interface XenApiConsole { export interface XenApiUser { $ref: Branded<'user'> - $type: 'user' fullname?: string other_config: Record short_name?: string @@ -955,7 +939,6 @@ export interface XenApiUser { export interface XenApiDataSource { $ref: Branded<'data_source'> - $type: 'data_source' enabled?: boolean max?: number min?: number @@ -968,7 +951,6 @@ export interface XenApiDataSource { export interface XenApiBlob { $ref: Branded<'blob'> - $type: 'blob' last_updated?: string mime_type?: string name_description: string @@ -980,7 +962,6 @@ export interface XenApiBlob { export interface XenApiMessage { $ref: Branded<'message'> - $type: 'message' body?: string cls?: CLS name?: string @@ -992,7 +973,6 @@ export interface XenApiMessage { export interface XenApiSecret { $ref: Branded<'secret'> - $type: 'secret' other_config: Record uuid: string value?: string @@ -1000,7 +980,6 @@ export interface XenApiSecret { export interface XenApiTunnel { $ref: Branded<'tunnel'> - $type: 'tunnel' access_PIF?: XenApiPif['$ref'] other_config: Record protocol?: TUNNEL_PROTOCOL @@ -1011,7 +990,6 @@ export interface XenApiTunnel { export interface XenApiNetworkSriov { $ref: Branded<'network_sriov'> - $type: 'network_sriov' configuration_mode?: SRIOV_CONFIGURATION_MODE logical_PIF?: XenApiPif['$ref'] physical_PIF?: XenApiPif['$ref'] @@ -1021,7 +999,6 @@ export interface XenApiNetworkSriov { export interface XenApiPci { $ref: Branded<'PCI'> - $type: 'PCI' class_name: string dependencies: XenApiPci['$ref'][] device_name: string @@ -1037,7 +1014,6 @@ export interface XenApiPci { export interface XenApiPgpu { $ref: Branded<'PGPU'> - $type: 'PGPU' compatibility_metadata?: Record dom0_access: PGPU_DOM0_ACCESS enabled_VGPU_types: XenApiVgpuType['$ref'][] @@ -1054,7 +1030,6 @@ export interface XenApiPgpu { export interface XenApiGpuGroup { $ref: Branded<'GPU_group'> - $type: 'GPU_group' allocation_algorithm: ALLOCATION_ALGORITHM enabled_VGPU_types: XenApiVgpuType['$ref'][] GPU_types: string[] @@ -1069,7 +1044,6 @@ export interface XenApiGpuGroup { export interface XenApiVgpu { $ref: Branded<'VGPU'> - $type: 'VGPU' compatibility_metadata?: Record currently_attached: boolean device: string @@ -1083,10 +1057,10 @@ export interface XenApiVgpu { uuid: string VM?: XenApiVm['$ref'] } +export type XenApiVgpuWrapped = WrapperXenApi export interface XenApiVgpuType { $ref: Branded<'VGPU_type'> - $type: 'VGPU_type' compatible_types_in_vm?: XenApiVgpuType['$ref'][] enabled_on_GPU_groups: XenApiGpuGroup['$ref'][] enabled_on_PGPUs: XenApiPgpu['$ref'][] @@ -1107,7 +1081,6 @@ export interface XenApiVgpuType { export interface XenApiPvsSite { $ref: Branded<'PVS_site'> - $type: 'PVS_site' cache_storage?: XenApiPvsCacheStorage['$ref'][] name_description?: string name_label?: string @@ -1119,7 +1092,6 @@ export interface XenApiPvsSite { export interface XenApiPvsServer { $ref: Branded<'PVS_server'> - $type: 'PVS_server' addresses?: string[] first_port?: number last_port?: number @@ -1129,7 +1101,6 @@ export interface XenApiPvsServer { export interface XenApiPvsProxy { $ref: Branded<'PVS_proxy'> - $type: 'PVS_proxy' currently_attached?: boolean site?: XenApiPvsSite['$ref'] status?: PVS_PROXY_STATUS @@ -1139,7 +1110,6 @@ export interface XenApiPvsProxy { export interface XenApiPvsCacheStorage { $ref: Branded<'PVS_cache_storage'> - $type: 'PVS_cache_storage' host?: XenApiHost['$ref'] site?: XenApiPvsSite['$ref'] size?: number @@ -1150,7 +1120,6 @@ export interface XenApiPvsCacheStorage { export interface XenApiFeature { $ref: Branded<'Feature'> - $type: 'Feature' enabled?: boolean experimental?: boolean host?: XenApiHost['$ref'] @@ -1162,7 +1131,6 @@ export interface XenApiFeature { export interface XenApiSdnController { $ref: Branded<'SDN_controller'> - $type: 'SDN_controller' address?: string port?: number protocol?: SDN_CONTROLLER_PROTOCOL @@ -1171,7 +1139,6 @@ export interface XenApiSdnController { export interface XenApiVdiNbdServerInfo { $ref: Branded<'vdi_nbd_server_info'> - $type: 'vdi_nbd_server_info' address?: string cert?: string exportname?: string @@ -1181,7 +1148,6 @@ export interface XenApiVdiNbdServerInfo { export interface XenApiPusb { $ref: Branded<'PUSB'> - $type: 'PUSB' description?: string host?: XenApiHost['$ref'] other_config?: Record @@ -1200,7 +1166,6 @@ export interface XenApiPusb { export interface XenApiUsbGroup { $ref: Branded<'USB_group'> - $type: 'USB_group' name_description?: string name_label?: string other_config?: Record @@ -1211,7 +1176,6 @@ export interface XenApiUsbGroup { export interface XenApiVusb { $ref: Branded<'VUSB'> - $type: 'VUSB' allowed_operations: VUSB_OPERATIONS[] current_operations: Record currently_attached: boolean @@ -1223,7 +1187,6 @@ export interface XenApiVusb { export interface XenApiCluster { $ref: Branded<'Cluster'> - $type: 'Cluster' allowed_operations: CLUSTER_OPERATION[] cluster_config?: Record cluster_hosts?: XenApiClusterHost['$ref'][] @@ -1243,7 +1206,6 @@ export interface XenApiCluster { export interface XenApiClusterHost { $ref: Branded<'Cluster_host'> - $type: 'Cluster_host' allowed_operations: CLUSTER_HOST_OPERATION[] cluster?: XenApiCluster['$ref'] current_operations: Record @@ -1259,7 +1221,6 @@ export interface XenApiClusterHost { export interface XenApiCertificate { $ref: Branded<'Certificate'> - $type: 'Certificate' fingerprint?: string host?: XenApiHost['$ref'] name?: string @@ -1271,7 +1232,6 @@ export interface XenApiCertificate { export interface XenApiRepository { $ref: Branded<'Repository'> - $type: 'Repository' binary_url?: string gpgkey_path?: string hash?: string @@ -1286,7 +1246,6 @@ export interface XenApiRepository { export interface XenApiObserver { $ref: Branded<'Observer'> - $type: 'Observer' attributes?: Record components?: string[] enabled?: boolean @@ -1365,3 +1324,14 @@ export type XenApiRecord = | XenApiCertificate | XenApiRepository | XenApiObserver + +export type WrappedXenApiRecord = + | XenApiHostWrapped + | XenApiPoolWrapped + | XenApiSrWrapped + | XenApiVbdWrapped + | XenApiVdiWrapped + | XenApiVgpuWrapped + | XenApiVifWrapped + | XenApiVmWrapped + | XenApiVtpmWrapped diff --git a/@xen-orchestra/rest-api/.USAGE.md b/@xen-orchestra/rest-api/.USAGE.md index 0a0695db70..39b3b09b65 100644 --- a/@xen-orchestra/rest-api/.USAGE.md +++ b/@xen-orchestra/rest-api/.USAGE.md @@ -30,6 +30,7 @@ class Foo extends Controller { @Example(['foo', 'bar']) @Get('{id}') @Security('*') + @SuccessResponse(202) @Response(404) getFoo(@Path() id: string) { return this.getFoo(id) diff --git a/@xen-orchestra/rest-api/README.md b/@xen-orchestra/rest-api/README.md index 94e759b392..c1ab09ae12 100644 --- a/@xen-orchestra/rest-api/README.md +++ b/@xen-orchestra/rest-api/README.md @@ -48,6 +48,7 @@ class Foo extends Controller { @Example(['foo', 'bar']) @Get('{id}') @Security('*') + @SuccessResponse(202) @Response(404) getFoo(@Path() id: string) { return this.getFoo(id) diff --git a/@xen-orchestra/rest-api/src/abstract-classes/xapi-xo-controller.mts b/@xen-orchestra/rest-api/src/abstract-classes/xapi-xo-controller.mts index 3a93dd89e5..83aaeaded4 100644 --- a/@xen-orchestra/rest-api/src/abstract-classes/xapi-xo-controller.mts +++ b/@xen-orchestra/rest-api/src/abstract-classes/xapi-xo-controller.mts @@ -1,12 +1,15 @@ import * as CM from 'complex-matcher' -import { Controller } from 'tsoa' +import { Controller, HttpStatusCodeLiteral } from 'tsoa' import { Request } from 'express' import type { XapiXoRecord } from '@vates/types/xo' +import { BASE_URL } from '../index.mjs' import { RestApi } from '../rest-api/rest-api.mjs' import { makeObjectMapper } from '../helpers/object-wrapper.helper.mjs' import type { WithHref } from '../helpers/helper.type.mjs' +const noop = () => {} + export abstract class XapiXoController extends Controller { #type: T['type'] restApi: RestApi @@ -34,4 +37,43 @@ export abstract class XapiXoController extends Controlle return mappedObjects } + + getXapiObject(maybeId: T['id'] | T) { + return this.restApi.getXapiObject(maybeId, this.#type) + } + + /** + * statusCode must represent the status code in case of a synchronous request. Default 200 + */ + async createAction( + cb: () => CbType, + { + statusCode = 200, + sync = false, + taskProperties, + }: { + statusCode?: HttpStatusCodeLiteral + sync?: boolean + taskProperties: { name: string; objectId: T['id']; args?: Record; [key: string]: unknown } + } + ) { + taskProperties.name = 'REST API: ' + taskProperties.name + taskProperties.type = 'xo:rest-api:action' + + const task = this.restApi.tasks.create(taskProperties) + const pResult = task.run(cb) + + if (sync) { + this.setStatus(statusCode) + return pResult + } else { + pResult.catch(noop) + const location = `${BASE_URL}/tasks/${task.id}` + this.setStatus(202) + this.setHeader('Location', location) + this.setHeader('Content-Type', 'text/plain') + + return location + } + } } diff --git a/@xen-orchestra/rest-api/src/helpers/object-wrapper.helper.mts b/@xen-orchestra/rest-api/src/helpers/object-wrapper.helper.mts index 3d28e8d52e..ebc5cbc967 100644 --- a/@xen-orchestra/rest-api/src/helpers/object-wrapper.helper.mts +++ b/@xen-orchestra/rest-api/src/helpers/object-wrapper.helper.mts @@ -8,10 +8,10 @@ import type { WithHref } from './helper.type.mjs' const { join } = path.posix export function makeObjectMapper(req: Request, path = req.path) { - const makeUrl = ({ id }: T) => join(baseUrl, path, typeof id === 'number' ? String(id) : id) + const makeUrl = ({ id }: T) => join(path, typeof id === 'number' ? String(id) : id) let objectMapper: (object: T) => string | WithHref> | WithHref - const { query, baseUrl } = req + const { query } = req const { fields } = query if (fields === '*') { objectMapper = object => ({ diff --git a/@xen-orchestra/rest-api/src/index.mts b/@xen-orchestra/rest-api/src/index.mts index c537cce939..bce35e5710 100644 --- a/@xen-orchestra/rest-api/src/index.mts +++ b/@xen-orchestra/rest-api/src/index.mts @@ -13,6 +13,8 @@ import type { XoApp } from './rest-api/rest-api.type.mjs' const require = createRequire(import.meta.url) const swaggerOpenApiSpec = require('../open-api/spec/swagger.json') +export const BASE_URL = '/rest/v0' + export default function setupRestApi(express: Express, xoApp: XoApp) { setupContainer(xoApp) RegisterRoutes(express) @@ -20,8 +22,8 @@ export default function setupRestApi(express: Express, xoApp: XoApp) { // do not register the doc at the root level, or it may lead to unwated behaviour // uncomment when all endpoints are migrated to this API // express.get('/rest/v0', (_req, res) => res.redirect('/rest/v0/docs')) - express.use('/rest/v0/docs', swaggerUi.serve, swaggerUi.setup(swaggerOpenApiSpec)) + express.use(`${BASE_URL}/docs`, swaggerUi.serve, swaggerUi.setup(swaggerOpenApiSpec)) - express.use('/rest/v0', tsoaToXoErrorHandler) - express.use('/rest/v0', genericErrorHandler) + express.use(BASE_URL, tsoaToXoErrorHandler) + express.use(BASE_URL, genericErrorHandler) } diff --git a/@xen-orchestra/rest-api/src/middlewares/generic-error-handler.middleware.mts b/@xen-orchestra/rest-api/src/middlewares/generic-error-handler.middleware.mts index e4c288ccc6..6af30e67b3 100644 --- a/@xen-orchestra/rest-api/src/middlewares/generic-error-handler.middleware.mts +++ b/@xen-orchestra/rest-api/src/middlewares/generic-error-handler.middleware.mts @@ -21,6 +21,7 @@ export default function genericErrorHandler(error: unknown, req: Request, res: R return } + const responseError: { error: string; info?: string } = { error: error.message } if (noSuchObject.is(error)) { res.status(404) } else if (unauthorized.is(error) || forbiddenOperation.is(error) || featureUnauthorized.is(error)) { @@ -32,11 +33,14 @@ export default function genericErrorHandler(error: unknown, req: Request, res: R } else if (notImplemented.is(error)) { res.status(501) } else { + if (error.name === 'XapiError') { + responseError.info = 'This is a XenServer/XCP-ng error, not an XO error' + } res.status(500) log.error(error) } log.info(`[${req.method}] ${req.path} (${res.statusCode})`) - res.json({ error: error.message }) + res.json(responseError) } 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 4c9960bff6..bc375b102f 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 @@ -13,6 +13,12 @@ export type Unbrand = { : T[K] } +export const actionAsyncroneResp = { + status: 202, + description: 'Action executed asynchronously', + produce: 'text/plain', +} as const + export const unauthorizedResp = { status: 401, description: 'Authentication required', @@ -23,6 +29,11 @@ export const notFoundResp = { description: 'Resource not found', } as const +export const noContentResp = { + status: 204, + description: 'No content', +} as const + export const internalServerErrorResp = { status: 500, description: 'Internal server error, XenServer/XCP-ng error', diff --git a/@xen-orchestra/rest-api/src/open-api/oa-examples/task.oa-example.mts b/@xen-orchestra/rest-api/src/open-api/oa-examples/task.oa-example.mts new file mode 100644 index 0000000000..70c8d3f946 --- /dev/null +++ b/@xen-orchestra/rest-api/src/open-api/oa-examples/task.oa-example.mts @@ -0,0 +1 @@ +export const taskLocation = '/rest/v0/tasks/0m7kl0j9l' diff --git a/@xen-orchestra/rest-api/src/rest-api/rest-api.mts b/@xen-orchestra/rest-api/src/rest-api/rest-api.mts index 9c99e3ad45..d067df979a 100644 --- a/@xen-orchestra/rest-api/src/rest-api/rest-api.mts +++ b/@xen-orchestra/rest-api/src/rest-api/rest-api.mts @@ -9,6 +9,10 @@ export class RestApi { this.#xoApp = xoApp } + get tasks() { + return this.#xoApp.tasks + } + get xoApp() { return this.#xoApp } @@ -25,6 +29,10 @@ export class RestApi { return this.#xoApp.getObjectsByType(type, opts) } + getXapiObject(maybeId: T['id'] | T, type: T['type']) { + return this.#xoApp.getXapiObject(maybeId, type) + } + runWithApiContext(...args: Parameters) { return this.#xoApp.runWithApiContext(...args) } diff --git a/@xen-orchestra/rest-api/src/rest-api/rest-api.type.mts b/@xen-orchestra/rest-api/src/rest-api/rest-api.type.mts index c771d6a92d..bccd135556 100644 --- a/@xen-orchestra/rest-api/src/rest-api/rest-api.type.mts +++ b/@xen-orchestra/rest-api/src/rest-api/rest-api.type.mts @@ -1,7 +1,40 @@ +import type { EventEmitter } from 'node:events' +import type { Task } from '@vates/types/lib/vates/task' import type { XapiHostStats, XapiVmStats, XapiStatsGranularity } from '@vates/types/common' +import type { + XenApiHostWrapped, + XenApiPoolWrapped, + XenApiSrWrapped, + XenApiVbdWrapped, + XenApiVdiWrapped, + XenApiVgpuWrapped, + XenApiVifWrapped, + XenApiVmWrapped, + XenApiVtpmWrapped, +} from '@vates/types/xen-api' import type { XoHost, XoUser, XapiXoRecord, XoVm } from '@vates/types/xo' +type XapiRecordByXapiXoRecord = { + host: XenApiHostWrapped + pool: XenApiPoolWrapped + SR: XenApiSrWrapped + VBD: XenApiVbdWrapped + VDI: XenApiVdiWrapped + VGPU: XenApiVgpuWrapped + VIF: XenApiVifWrapped + VM: XenApiVmWrapped + 'VM-controller': XenApiVmWrapped + 'VM-snapshot': XenApiVmWrapped + 'VM-template': XenApiVmWrapped + VTPM: XenApiVtpmWrapped +} + export type XoApp = { + tasks: EventEmitter & { + create: (params: { name: string; objectId?: string; type?: string }) => Task + } + + // methods ------------ authenticateUser: ( credentials: { token?: string; username?: string; password?: string }, userData?: { ip?: string }, @@ -13,6 +46,7 @@ export type XoApp = { opts?: { filter?: string; limit?: number } ) => Record getXapiHostStats: (hostId: XoHost['id'], granularity?: XapiStatsGranularity) => Promise + getXapiObject: (maybeId: T['id'] | T, type: T['type']) => XapiRecordByXapiXoRecord[T['type']] getXapiVmStats: (vmId: XoVm['id'], granularity?: XapiStatsGranularity) => Promise runWithApiContext: (user: XoUser, fn: () => void) => Promise } diff --git a/@xen-orchestra/rest-api/src/vms/vm.controller.mts b/@xen-orchestra/rest-api/src/vms/vm.controller.mts index bf22fca95a..51c33a9b93 100644 --- a/@xen-orchestra/rest-api/src/vms/vm.controller.mts +++ b/@xen-orchestra/rest-api/src/vms/vm.controller.mts @@ -1,4 +1,17 @@ -import { Example, Get, Path, Queries, Query, Request, Response, Route, Security, Tags } from 'tsoa' +import { + Example, + Get, + Path, + Post, + Queries, + Query, + Request, + Response, + Route, + Security, + Tags, + SuccessResponse, +} from 'tsoa' import { Request as ExRequest } from 'express' import { inject } from 'inversify' import { incorrectState, invalidParameters } from 'xo-common/api-errors.js' @@ -6,9 +19,17 @@ import { provide } from 'inversify-binding-decorators' import type { XapiStatsGranularity, XapiVmStats, XoVm } from '@vates/types' import { CollectionQueryParams } from '../open-api/common/request.common.mjs' -import { notFoundResp, unauthorizedResp, type Unbrand } from '../open-api/common/response.common.mjs' +import { + actionAsyncroneResp, + internalServerErrorResp, + noContentResp, + notFoundResp, + unauthorizedResp, + type Unbrand, +} from '../open-api/common/response.common.mjs' import { partialVms, vm, vmIds, vmStatsExample } from '../open-api/oa-examples/vm.oa-example.mjs' import { RestApi } from '../rest-api/rest-api.mjs' +import { taskLocation } from '../open-api/oa-examples/task.oa-example.mjs' import type { WithHref } from '../helpers/helper.type.mjs' import { XapiXoController } from '../abstract-classes/xapi-xo-controller.mjs' @@ -76,4 +97,27 @@ export class VmController extends XapiXoController { throw error } } + + /** + * @example id "f07ab729-c0e8-721c-45ec-f11276377030" + */ + @Example(taskLocation) + @Post('{id}/actions/start') + @SuccessResponse(actionAsyncroneResp.status, actionAsyncroneResp.description, actionAsyncroneResp.produce) + @Response(noContentResp.status, noContentResp.description) + @Response(notFoundResp.status, notFoundResp.description) + @Response(internalServerErrorResp.status, internalServerErrorResp.description) + async startVm(@Path() id: string, @Query() sync?: boolean) { + const vmId = id as XoVm['id'] + const action = () => this.getXapiObject(vmId).$callAsync('start', false, false) + + return this.createAction(action, { + sync, + statusCode: noContentResp.status, + taskProperties: { + name: 'start VM', + objectId: vmId, + }, + }) + } } diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 5067d91a86..138837008b 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -13,6 +13,7 @@ - **Migrated REST API endpoints**: - `/rest/v0/vms//stats` (PR [#8359](https://github.com/vatesfr/xen-orchestra/pull/8359)) + - `/rest/v0/vms//actions/start` (PR [#8379](https://github.com/vatesfr/xen-orchestra/pull/8379)) - `/rest/v0/hosts` (PR [#8372](https://github.com/vatesfr/xen-orchestra/pull/8372)) - `/rest/v0/hosts/` (PR [#8372](https://github.com/vatesfr/xen-orchestra/pull/8372)) - `/rest/v0/hosts//stats` (PR [#8372](https://github.com/vatesfr/xen-orchestra/pull/8372)) diff --git a/packages/xo-server/src/xo-mixins/rest-api.mjs b/packages/xo-server/src/xo-mixins/rest-api.mjs index e1843e800f..388188d673 100644 --- a/packages/xo-server/src/xo-mixins/rest-api.mjs +++ b/packages/xo-server/src/xo-mixins/rest-api.mjs @@ -605,7 +605,16 @@ export default class RestApi { const collections = { __proto__: null } // add migrated collections to maintain their discoverability - const swaggerEndpoints = ['docs', 'vms', 'hosts', 'srs'] + const swaggerEndpoints = { + docs: {}, + vms: { + actions: { + start: true, + }, + }, + hosts: {}, + srs: {}, + } const withParams = (fn, paramsSchema) => { fn.params = paramsSchema @@ -1146,7 +1155,7 @@ export default class RestApi { api.get( '/', wrap((req, res) => { - const endpoints = new Set([...Object.keys(collections), ...swaggerEndpoints]) + const endpoints = new Set([...Object.keys(collections), ...Object.keys(swaggerEndpoints)]) return sendObjects(endpoints, req, res) }) ) @@ -1283,7 +1292,7 @@ export default class RestApi { '/:collection', wrap(async (req, res, next) => { const { collection, query } = req - if (swaggerEndpoints.includes(collection.id)) { + if (swaggerEndpoints[collection.id] !== undefined) { return next('route') } @@ -1358,7 +1367,7 @@ export default class RestApi { api.get('/:collection/:object', (req, res, next) => { const { collection } = req - if (swaggerEndpoints.includes(collection.id)) { + if (swaggerEndpoints[collection.id] !== undefined) { return next('route') } let result = req.object @@ -1522,9 +1531,10 @@ export default class RestApi { res.json({ ...action }) }) api.post('/:collection/:object/actions/:action', json(), (req, res, next) => { + const { collection } = req const { action } = req.params const fn = req.collection.actions?.[action] - if (fn === undefined) { + if (fn === undefined || swaggerEndpoints[collection.id]?.actions?.[action]) { return next() }