feat(xo-server/rest-api): basic VM actions (#6652)
This commit is contained in:
@@ -2,6 +2,8 @@ import { createLogger } from '@xen-orchestra/log'
|
||||
import { noSuchObject } from 'xo-common/api-errors.js'
|
||||
import { Task } from '@vates/task'
|
||||
|
||||
import { TreeMap } from './_TreeMap.mjs'
|
||||
|
||||
export { Task }
|
||||
|
||||
const { debug } = createLogger('xo:mixins:Tasks')
|
||||
@@ -10,28 +12,42 @@ export default class Tasks {
|
||||
// contains instance of running tasks (required to interact with running tasks)
|
||||
#tasks = new Map()
|
||||
|
||||
async create({ name }) {
|
||||
// contains tasks grouped by objects (tasks unrelated to objects are available under undefined)
|
||||
#tasksByObject = new TreeMap()
|
||||
|
||||
create({ name, objectId }) {
|
||||
const tasks = this.#tasks
|
||||
let id
|
||||
do {
|
||||
id = Math.random().toString(36).slice(2)
|
||||
} while (tasks.has(id))
|
||||
|
||||
const byObject = this.#tasksByObject
|
||||
|
||||
const task = new Task({
|
||||
name,
|
||||
onProgress: event => {
|
||||
debug('task event', event)
|
||||
if (event.type === 'end' && event.id === id) {
|
||||
tasks.delete(id)
|
||||
setTimeout(() => {
|
||||
tasks.delete(id)
|
||||
byObject.delete([objectId, id])
|
||||
}, 600e3)
|
||||
}
|
||||
},
|
||||
})
|
||||
task.id = id
|
||||
|
||||
tasks.set(id, task)
|
||||
byObject.set([objectId, id], task)
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
getByObject(objectId) {
|
||||
return this.#tasksByObject.get(objectId)
|
||||
}
|
||||
|
||||
async abort(id) {
|
||||
const task = this.#tasks.get(id)
|
||||
if (task === undefined) {
|
||||
|
||||
66
@xen-orchestra/mixins/_TreeMap.mjs
Normal file
66
@xen-orchestra/mixins/_TreeMap.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
function splitKey(key) {
|
||||
return Array.isArray(key) ? [key[0], key.length < 2 ? undefined : key.slice(1)] : [key, undefined]
|
||||
}
|
||||
|
||||
export class TreeMap extends Map {
|
||||
delete(key) {
|
||||
const [head, tail] = splitKey(key)
|
||||
|
||||
if (tail === undefined) {
|
||||
return super.delete(head)
|
||||
}
|
||||
|
||||
const value = super.get(head)
|
||||
if (value instanceof TreeMap) {
|
||||
if (value.delete(tail)) {
|
||||
if (value.size === 0) {
|
||||
return super.delete(head)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const [head, tail] = splitKey(key)
|
||||
|
||||
const value = super.get(head)
|
||||
if (tail === undefined) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (value instanceof TreeMap) {
|
||||
return value.get(tail)
|
||||
}
|
||||
}
|
||||
|
||||
has(key) {
|
||||
const [head, tail] = splitKey(key)
|
||||
|
||||
if (!super.has(head)) {
|
||||
return false
|
||||
}
|
||||
if (tail === undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
const value = super.get(head)
|
||||
return value instanceof TreeMap && value.has(tail)
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
const [head, tail] = splitKey(key)
|
||||
|
||||
if (tail === undefined) {
|
||||
return super.set(head, value)
|
||||
}
|
||||
|
||||
let map = super.get(head)
|
||||
if (!(map instanceof TreeMap)) {
|
||||
map = new TreeMap()
|
||||
super.set(head, map)
|
||||
}
|
||||
map.set(tail, value)
|
||||
return this
|
||||
}
|
||||
}
|
||||
28
@xen-orchestra/mixins/_TreeMap.test.mjs
Normal file
28
@xen-orchestra/mixins/_TreeMap.test.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import { strictEqual as assertEq } from 'node:assert'
|
||||
import test from 'test'
|
||||
|
||||
import { TreeMap } from './_TreeMap.mjs'
|
||||
|
||||
test(function () {
|
||||
const tree = new TreeMap()
|
||||
|
||||
assertEq(tree instanceof Map, true)
|
||||
|
||||
assertEq(tree.set([0, 1], 'foo'), tree)
|
||||
|
||||
assertEq(tree.has(0), true)
|
||||
assertEq(tree.has([0, 1]), true)
|
||||
|
||||
assertEq(tree.get(0).get(1), 'foo')
|
||||
assertEq(tree.get([0, 1]), 'foo')
|
||||
|
||||
assertEq(tree.delete([0, 1]), true)
|
||||
|
||||
assertEq(tree.has(0), false)
|
||||
assertEq(tree.has([0, 1]), false)
|
||||
|
||||
assertEq(tree.get(0), undefined)
|
||||
assertEq(tree.get([0, 1]), undefined)
|
||||
|
||||
assertEq(tree.delete([0, 1]), false)
|
||||
})
|
||||
@@ -31,6 +31,10 @@
|
||||
"xo-common": "^0.8.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- [VM/Advanced] Warning message when enabling Windows update tools [#6627](https://github.com/vatesfr/xen-orchestra/issues/6627) (PR [#6681](https://github.com/vatesfr/xen-orchestra/issues/6681))
|
||||
- [Continuous Replication] : add HealthCheck support to Continuous Replication (PR [#6668](https://github.com/vatesfr/xen-orchestra/pull/6668))
|
||||
- [Plugin/auth-oidc] [OpenID Connect](<https://en.wikipedia.org/wiki/OpenID#OpenID_Connect_(OIDC)>) authentication plugin [#6641](https://github.com/vatesfr/xen-orchestra/issues/6641) (PR [#6684](https://github.com/vatesfr/xen-orchestra/issues/6684))
|
||||
- [REST API] Possibility to start, shutdown, reboot and snapshot VMs
|
||||
|
||||
### Bug fixes
|
||||
|
||||
@@ -38,6 +39,7 @@
|
||||
- @xen-orchestra/backups minor
|
||||
- xen-api patch
|
||||
- xo-cli minor
|
||||
- xo-server minor
|
||||
- xo-server-auth-oidc minor
|
||||
- xo-web minor
|
||||
- xo-server patch
|
||||
|
||||
@@ -198,6 +198,59 @@ The following query parameters are supported to customize the created VDI:
|
||||
- `name_description`
|
||||
- `raw`: this parameter must be used if importing a raw export instead of a VHD
|
||||
|
||||
## Actions
|
||||
|
||||
### Available actions
|
||||
|
||||
To see the actions available on a given object, get the collection at `/rest/v0/<type>/<uuid>/actions`.
|
||||
|
||||
For example, to list all actions on a given VM:
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
|
||||
'https://xo.example.org/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/actions'
|
||||
```
|
||||
|
||||
### Start an action
|
||||
|
||||
Post at the action endpoint which is `/rest/v0/<type>/<uuid>/actions/<action>`.
|
||||
|
||||
For instance, to reboot a VM:
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-X POST \
|
||||
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
|
||||
'https://xo.example.org/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/actions/clean_reboot'
|
||||
```
|
||||
|
||||
Some actions accept parameters, they should be provided in a JSON-encoded object as the request body:
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-X POST \
|
||||
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json' \
|
||||
-d '{ "name_label": "My snapshot" }' \
|
||||
'https://xo.example.org/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/actions/snapshot'
|
||||
```
|
||||
|
||||
By default, actions are asynchronous and return the reference of the task associated with the request.
|
||||
|
||||
> Tasks monitoring is still under construcration and will come in a future release :)
|
||||
|
||||
The `?sync` flag can be used to run the action synchronously without requiring task monitoring. The result of the action will be returned encoded as JSON:
|
||||
|
||||
```console
|
||||
$ curl \
|
||||
-X POST \
|
||||
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
|
||||
'https://xo.example.org/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/actions/clean_reboot'
|
||||
"2b0266aa-c753-6fbc-e4dd-c79be7782052"
|
||||
```
|
||||
|
||||
## The future
|
||||
|
||||
We are adding features and improving the REST API step by step. If you have interesting use cases or feedback, please ask directly at <https://xcp-ng.org/forum/category/12/xen-orchestra>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
- [VDI destruction](#vdi-destruction)
|
||||
- [VDI Export](#vdi-export)
|
||||
- [VDI Import](#vdi-import)
|
||||
- [Actions](#actions)
|
||||
- [Available actions](#available-actions)
|
||||
- [Start an action](#start-an-action)
|
||||
- [The future](#the-future)
|
||||
|
||||
> This [REST](https://en.wikipedia.org/wiki/Representational_state_transfer)-oriented API is experimental. Non-backward compatible changes or removal may occur in any future release. Use of the feature is not recommended in production environments.
|
||||
@@ -196,6 +199,59 @@ The following query parameters are supported to customize the created VDI:
|
||||
- `name_description`
|
||||
- `raw`: this parameter must be used if importing a raw export instead of a VHD
|
||||
|
||||
## Actions
|
||||
|
||||
### Available actions
|
||||
|
||||
To see the actions available on a given object, get the collection at `/rest/v0/<type>/<uuid>/actions`.
|
||||
|
||||
For example, to list all actions on a given VM:
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
|
||||
'https://xo.company.lan/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/actions'
|
||||
```
|
||||
|
||||
### Start an action
|
||||
|
||||
Post at the action endpoint which is `/rest/v0/<type>/<uuid>/actions/<action>`.
|
||||
|
||||
For instance, to reboot a VM:
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-X POST \
|
||||
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
|
||||
'https://xo.company.lan/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/actions/clean_reboot'
|
||||
```
|
||||
|
||||
Some actions accept parameters, they should be provided in a JSON-encoded object as the request body:
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-X POST \
|
||||
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json' \
|
||||
-d '{ "name_label": "My snapshot" }' \
|
||||
'https://xo.company.lan/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/actions/snapshot'
|
||||
```
|
||||
|
||||
By default, actions are asynchronous and return the reference of the task associated with the request.
|
||||
|
||||
> Tasks monitoring is still under construcration and will come in a future release :)
|
||||
|
||||
The `?sync` flag can be used to run the action synchronously without requiring task monitoring. The result of the action will be returned encoded as JSON:
|
||||
|
||||
```console
|
||||
$ curl \
|
||||
-X POST \
|
||||
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
|
||||
'https://xo.company.lan/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/actions/clean_reboot'
|
||||
"2b0266aa-c753-6fbc-e4dd-c79be7782052"
|
||||
```
|
||||
|
||||
## The future
|
||||
|
||||
We are adding features and improving the REST API step by step. If you have interesting use cases or feedback, please ask directly at <https://xcp-ng.org/forum/category/12/xen-orchestra>
|
||||
|
||||
@@ -10,9 +10,11 @@ import * as CM from 'complex-matcher'
|
||||
import fromCallback from 'promise-toolbox/fromCallback'
|
||||
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
|
||||
|
||||
function sendObjects(objects, req, res) {
|
||||
const noop = Function.prototype
|
||||
|
||||
function sendObjects(objects, req, res, path = req.path) {
|
||||
const { query } = req
|
||||
const basePath = req.baseUrl + req.path
|
||||
const basePath = req.baseUrl + path
|
||||
const makeUrl = object => basePath + '/' + object.id
|
||||
|
||||
let { fields } = query
|
||||
@@ -110,6 +112,20 @@ export default class RestApi {
|
||||
})
|
||||
)
|
||||
|
||||
collections.vms.actions = {
|
||||
__proto__: null,
|
||||
|
||||
clean_reboot: vm => vm.$callAsync('clean_reboot').then(noop),
|
||||
clean_shutdown: vm => vm.$callAsync('clean_shutdown').then(noop),
|
||||
hard_reboot: vm => vm.$callAsync('hard_reboot').then(noop),
|
||||
hard_shutdown: vm => vm.$callAsync('hard_shutdown').then(noop),
|
||||
snapshot: async (vm, { name_label }) => {
|
||||
const ref = await vm.$snapshot({ name_label })
|
||||
return vm.$xapi.getField('VM', ref, 'uuid')
|
||||
},
|
||||
start: vm => vm.$callAsync('start', false, false).then(noop),
|
||||
}
|
||||
|
||||
api.param('collection', (req, res, next) => {
|
||||
const id = req.params.collection
|
||||
const collection = collections[id]
|
||||
@@ -198,6 +214,32 @@ export default class RestApi {
|
||||
})
|
||||
)
|
||||
|
||||
api.get('/:collection/:object/tasks', (req, res) => {
|
||||
const tasks = app.tasks.getByObject(req.xoObject.id)
|
||||
sendObjects(tasks === undefined ? [] : Array.from(tasks.values()), req, res, '/tasks')
|
||||
})
|
||||
|
||||
api.get('/:collection/:object/actions', (req, res) => {
|
||||
const { actions } = req.collection
|
||||
sendObjects(actions === undefined ? [] : Array.from(Object.keys(actions), id => ({ id })), req, res)
|
||||
})
|
||||
api.post('/:collection/:object/actions/:action', json(), (req, res, next) => {
|
||||
const { action } = req.params
|
||||
const fn = req.collection.actions?.[action]
|
||||
if (fn === undefined) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const task = app.tasks.create({ name: `REST: ${action} ${req.collection.type}`, objectId: req.xoObject.id })
|
||||
const pResult = task.run(() => fn(req.xapiObject, req.body))
|
||||
if (Object.hasOwn(req.query, 'sync')) {
|
||||
pResult.then(result => res.json(result), next)
|
||||
} else {
|
||||
pResult.catch(noop)
|
||||
res.end(req.baseUrl + '/tasks/' + task.id)
|
||||
}
|
||||
})
|
||||
|
||||
api.post(
|
||||
'/:collection(srs)/:object/vdis',
|
||||
wrap(async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user