feat(xapi/VM_{checkpoint,snapshot}): HTTP sync hook (#6423)
This commit is contained in:
parent
f1ab62524c
commit
f82eb8aeb4
131
@xen-orchestra/xapi/docs/vm-sync-hook.md
Normal file
131
@xen-orchestra/xapi/docs/vm-sync-hook.md
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
# VM Sync Hook
|
||||||
|
|
||||||
|
> This feature is currently _unstable_ and might change or be removed in the future.
|
||||||
|
>
|
||||||
|
> Feedbacks are very welcome on the [project bugtracker](https://github.com/vatesfr/xen-orchestra/issues).
|
||||||
|
|
||||||
|
> This feature is not currently supported for backups done with XO Proxy.
|
||||||
|
|
||||||
|
Before snapshotting (with or without memory, ie checkpoint), XO can notify the VM via an HTTP request.
|
||||||
|
|
||||||
|
A typical use case is to make sure the VM is in a consistent state during the snapshot process, for instance by making sure database writes are flushed to the disk.
|
||||||
|
|
||||||
|
> This request will only be sent if the VM is in a running state.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The feature is opt-in via a tag on the VM: `xo:notify-on-snapshot`.
|
||||||
|
|
||||||
|
By default, it will be an HTTPS request on the port `1727`, on the first IP address reported by the VM.
|
||||||
|
|
||||||
|
If the _VM Tools_ (i.e. management agent) are not installed on the VM or if you which to use another URL, you can specify this in the tag: `xo:notify-on-snapshot=<URL>`.
|
||||||
|
|
||||||
|
To guarantee the request comes from XO, a secret must be provided in the `xo-server`'s (and `xo-proxy` if relevant) configuration:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[xapiOptions]
|
||||||
|
syncHookSecret = 'unique long string to ensure the request comes from XO'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Specification
|
||||||
|
|
||||||
|
XO will waits for the request to be answered before starting the snapshot, but will not wait longer than _1 minute_.
|
||||||
|
|
||||||
|
If the request fails for any reason, XO will go ahead with snapshot immediately.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /sync HTTP/1.1
|
||||||
|
Authorization: Bearer dW5pcXVlIGxvbmcgc3RyaW5nIHRvIGVuc3VyZSB0aGUgcmVxdWVzdCBjb21lcyBmcm9tIFhP
|
||||||
|
```
|
||||||
|
|
||||||
|
When the snapshot is finished, another request will be sent:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /post-sync HTTP/1.1
|
||||||
|
Authorization: Bearer dW5pcXVlIGxvbmcgc3RyaW5nIHRvIGVuc3VyZSB0aGUgcmVxdWVzdCBjb21lcyBmcm9tIFhP
|
||||||
|
```
|
||||||
|
|
||||||
|
The created snapshot will have the special `xo:synced` tag set to make it identifiable.
|
||||||
|
|
||||||
|
## Example server in Node
|
||||||
|
|
||||||
|
`index.cjs`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const exec = require('node:util').promisify(require('node:child_process').execFile)
|
||||||
|
|
||||||
|
const SECRET = 'unique long string to ensure the request comes from XO'
|
||||||
|
|
||||||
|
const HANDLERS = {
|
||||||
|
__proto__: null,
|
||||||
|
|
||||||
|
async '/sync'() {
|
||||||
|
// actions to do before the VM is snapshotted
|
||||||
|
|
||||||
|
// in this example, the Linux command `sync` is called:
|
||||||
|
await exec('sync')
|
||||||
|
},
|
||||||
|
|
||||||
|
async '/post-sync'() {
|
||||||
|
// actions to do after the VM is snapshotted
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAuthorization(req) {
|
||||||
|
try {
|
||||||
|
const { authorization } = req.headers
|
||||||
|
if (authorization !== undefined) {
|
||||||
|
const parts = authorization.split(' ')
|
||||||
|
if (parts.length >= 1 && parts[0].toLowerCase() === 'bearer') {
|
||||||
|
return Buffer.from(parts[1], 'base64').toString() === SECRET
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('checkAuthorization', error)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// generate a self-signed certificate
|
||||||
|
const [, key, cert] =
|
||||||
|
/^(-----BEGIN PRIVATE KEY-----.+-----END PRIVATE KEY-----\n)(-----BEGIN CERTIFICATE-----.+-----END CERTIFICATE-----\n)$/s.exec(
|
||||||
|
(await exec('openssl', ['req', '-batch', '-new', '-x509', '-nodes', '-newkey', 'rsa:2048', '-keyout', '-']))
|
||||||
|
.stdout
|
||||||
|
)
|
||||||
|
|
||||||
|
const server = require('node:https').createServer({ cert, key }, async function onRequest(req, res) {
|
||||||
|
if (!checkAuthorization(req)) {
|
||||||
|
res.statusCode = 403
|
||||||
|
return res.end('Forbidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = HANDLERS[req.url.split('?')[0]]
|
||||||
|
if (handler === undefined || req.method !== 'GET') {
|
||||||
|
res.statusCode = 404
|
||||||
|
return res.end('Not Found')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handler()
|
||||||
|
|
||||||
|
res.statusCode = 200
|
||||||
|
res.end('Ok')
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.statusCode = 500
|
||||||
|
res.write('Internal Error')
|
||||||
|
}
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.on('close', resolve).on('error', reject).listen(1727)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.warn)
|
||||||
|
```
|
@ -102,6 +102,8 @@ class Xapi extends Base {
|
|||||||
constructor({
|
constructor({
|
||||||
callRetryWhenTooManyPendingTasks = { delay: 5e3, tries: 10 },
|
callRetryWhenTooManyPendingTasks = { delay: 5e3, tries: 10 },
|
||||||
maxUncoalescedVdis,
|
maxUncoalescedVdis,
|
||||||
|
syncHookSecret,
|
||||||
|
syncHookTimeout,
|
||||||
vdiDestroyRetryWhenInUse = { delay: 5e3, tries: 10 },
|
vdiDestroyRetryWhenInUse = { delay: 5e3, tries: 10 },
|
||||||
...opts
|
...opts
|
||||||
}) {
|
}) {
|
||||||
@ -112,6 +114,8 @@ class Xapi extends Base {
|
|||||||
when: { code: 'TOO_MANY_PENDING_TASKS' },
|
when: { code: 'TOO_MANY_PENDING_TASKS' },
|
||||||
}
|
}
|
||||||
this._maxUncoalescedVdis = maxUncoalescedVdis
|
this._maxUncoalescedVdis = maxUncoalescedVdis
|
||||||
|
this._syncHookSecret = syncHookSecret
|
||||||
|
this._syncHookTimeout = syncHookTimeout
|
||||||
this._vdiDestroyRetryWhenInUse = {
|
this._vdiDestroyRetryWhenInUse = {
|
||||||
...vdiDestroyRetryWhenInUse,
|
...vdiDestroyRetryWhenInUse,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
@ -26,6 +26,7 @@
|
|||||||
"@xen-orchestra/log": "^0.3.0",
|
"@xen-orchestra/log": "^0.3.0",
|
||||||
"d3-time-format": "^3.0.0",
|
"d3-time-format": "^3.0.0",
|
||||||
"golike-defer": "^0.5.1",
|
"golike-defer": "^0.5.1",
|
||||||
|
"http-request-plus": "^0.14.0",
|
||||||
"json-rpc-protocol": "^0.13.2",
|
"json-rpc-protocol": "^0.13.2",
|
||||||
"lodash": "^4.17.15",
|
"lodash": "^4.17.15",
|
||||||
"promise-toolbox": "^0.21.0",
|
"promise-toolbox": "^0.21.0",
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const CancelToken = require('promise-toolbox/CancelToken')
|
const CancelToken = require('promise-toolbox/CancelToken')
|
||||||
const groupBy = require('lodash/groupBy.js')
|
const groupBy = require('lodash/groupBy.js')
|
||||||
|
const hrp = require('http-request-plus')
|
||||||
const ignoreErrors = require('promise-toolbox/ignoreErrors')
|
const ignoreErrors = require('promise-toolbox/ignoreErrors')
|
||||||
const pickBy = require('lodash/pickBy.js')
|
const pickBy = require('lodash/pickBy.js')
|
||||||
const omit = require('lodash/omit.js')
|
const omit = require('lodash/omit.js')
|
||||||
@ -46,6 +47,31 @@ const cleanBiosStrings = biosStrings => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// See: https://github.com/xapi-project/xen-api/blob/324bc6ee6664dd915c0bbe57185f1d6243d9ed7e/ocaml/xapi/xapi_guest_agent.ml#L59-L81
|
||||||
|
//
|
||||||
|
// Returns <min(n)>/ip || <min(n)>/ipv4/<min(m)> || <min(n)>/ipv6/<min(m)> || undefined
|
||||||
|
// where n corresponds to the network interface and m to its IP
|
||||||
|
const IPV4_KEY_RE = /^\d+\/ip(?:v4\/\d+)?$/
|
||||||
|
const IPV6_KEY_RE = /^\d+\/ipv6\/\d+$/
|
||||||
|
function getVmAddress(networks) {
|
||||||
|
if (networks !== undefined) {
|
||||||
|
let ipv6
|
||||||
|
for (const key of Object.keys(networks).sort()) {
|
||||||
|
if (IPV4_KEY_RE.test(key)) {
|
||||||
|
return networks[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ipv6 === undefined && IPV6_KEY_RE.test(key)) {
|
||||||
|
ipv6 = networks[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ipv6 !== undefined) {
|
||||||
|
return ipv6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('no VM address found')
|
||||||
|
}
|
||||||
|
|
||||||
async function listNobakVbds(xapi, vbdRefs) {
|
async function listNobakVbds(xapi, vbdRefs) {
|
||||||
const vbds = []
|
const vbds = []
|
||||||
await asyncMap(vbdRefs, async vbdRef => {
|
await asyncMap(vbdRefs, async vbdRef => {
|
||||||
@ -132,6 +158,51 @@ class Vm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _httpHook({ guest_metrics, power_state, tags, uuid }, pathname) {
|
||||||
|
if (power_state !== 'Running') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let url
|
||||||
|
let i = tags.length
|
||||||
|
do {
|
||||||
|
if (i === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const tag = tags[--i]
|
||||||
|
if (tag === 'xo:notify-on-snapshot') {
|
||||||
|
const { networks } = await this.getRecord('VM_guest_metrics', guest_metrics)
|
||||||
|
url = Object.assign(new URL('https://locahost'), {
|
||||||
|
hostname: getVmAddress(networks),
|
||||||
|
port: 1727,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const prefix = 'xo:notify-on-snapshot='
|
||||||
|
if (tag.startsWith(prefix)) {
|
||||||
|
url = new URL(tag.slice(prefix.length))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (url === undefined)
|
||||||
|
|
||||||
|
url.pathname = pathname
|
||||||
|
|
||||||
|
const headers = {}
|
||||||
|
const secret = this._asyncHookSecret
|
||||||
|
if (secret !== undefined) {
|
||||||
|
headers.authorization = 'Bearer ' + Buffer.from(secret).toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await hrp(url, {
|
||||||
|
headers,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
timeout: this._syncHookTimeout ?? 60e3,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
warn('HTTP hook failed', { error, url, vm: uuid })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async assertHealthyVdiChains(vmRef, tolerance = this._maxUncoalescedVdis) {
|
async assertHealthyVdiChains(vmRef, tolerance = this._maxUncoalescedVdis) {
|
||||||
const vdiRefs = {}
|
const vdiRefs = {}
|
||||||
;(await this.getRecords('VBD', await this.getField('VM', vmRef, 'VBDs'))).forEach(({ VDI: ref }) => {
|
;(await this.getRecords('VBD', await this.getField('VM', vmRef, 'VBDs'))).forEach(({ VDI: ref }) => {
|
||||||
@ -148,6 +219,8 @@ class Vm {
|
|||||||
async checkpoint($defer, vmRef, { cancelToken = CancelToken.none, ignoreNobakVdis = false, name_label } = {}) {
|
async checkpoint($defer, vmRef, { cancelToken = CancelToken.none, ignoreNobakVdis = false, name_label } = {}) {
|
||||||
const vm = await this.getRecord('VM', vmRef)
|
const vm = await this.getRecord('VM', vmRef)
|
||||||
|
|
||||||
|
await this._httpHook(vm, '/sync')
|
||||||
|
|
||||||
let destroyNobakVdis = false
|
let destroyNobakVdis = false
|
||||||
|
|
||||||
if (ignoreNobakVdis) {
|
if (ignoreNobakVdis) {
|
||||||
@ -168,6 +241,9 @@ class Vm {
|
|||||||
try {
|
try {
|
||||||
const ref = await this.callAsync(cancelToken, 'VM.checkpoint', vmRef, name_label).then(extractOpaqueRef)
|
const ref = await this.callAsync(cancelToken, 'VM.checkpoint', vmRef, name_label).then(extractOpaqueRef)
|
||||||
|
|
||||||
|
// detached async
|
||||||
|
this._httpHook(vm, '/post-sync').catch(noop)
|
||||||
|
|
||||||
// VM checkpoints are marked as templates, unfortunately it does not play well with XVA export/import
|
// VM checkpoints are marked as templates, unfortunately it does not play well with XVA export/import
|
||||||
// which will import them as templates and not VM checkpoints or plain VMs
|
// which will import them as templates and not VM checkpoints or plain VMs
|
||||||
await pCatch.call(
|
await pCatch.call(
|
||||||
@ -544,6 +620,8 @@ class Vm {
|
|||||||
) {
|
) {
|
||||||
const vm = await this.getRecord('VM', vmRef)
|
const vm = await this.getRecord('VM', vmRef)
|
||||||
|
|
||||||
|
await this._httpHook(vm, '/sync')
|
||||||
|
|
||||||
const isHalted = vm.power_state === 'Halted'
|
const isHalted = vm.power_state === 'Halted'
|
||||||
|
|
||||||
// requires the VM to be halted because it's not possible to re-plug VUSB on a live VM
|
// requires the VM to be halted because it's not possible to re-plug VUSB on a live VM
|
||||||
@ -646,6 +724,9 @@ class Vm {
|
|||||||
ref = await this.callAsync(cancelToken, 'VM.snapshot', vmRef, name_label).then(extractOpaqueRef)
|
ref = await this.callAsync(cancelToken, 'VM.snapshot', vmRef, name_label).then(extractOpaqueRef)
|
||||||
} while (false)
|
} while (false)
|
||||||
|
|
||||||
|
// detached async
|
||||||
|
this._httpHook(vm, '/post-sync').catch(noop)
|
||||||
|
|
||||||
// VM snapshots are marked as templates, unfortunately it does not play well with XVA export/import
|
// VM snapshots are marked as templates, unfortunately it does not play well with XVA export/import
|
||||||
// which will import them as templates and not VM snapshots or plain VMs
|
// which will import them as templates and not VM snapshots or plain VMs
|
||||||
await pCatch.call(
|
await pCatch.call(
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
- [Backup/Restore file] Implement File level restore for s3 and encrypted backups (PR [#6409](https://github.com/vatesfr/xen-orchestra/pull/6409))
|
- [Backup/Restore file] Implement File level restore for s3 and encrypted backups (PR [#6409](https://github.com/vatesfr/xen-orchestra/pull/6409))
|
||||||
- [Backup] Improve listing speed by updating caches instead of regenerating them on backup creation/deletion (PR [#6411](https://github.com/vatesfr/xen-orchestra/pull/6411))
|
- [Backup] Improve listing speed by updating caches instead of regenerating them on backup creation/deletion (PR [#6411](https://github.com/vatesfr/xen-orchestra/pull/6411))
|
||||||
- [Backup] Add `mergeBlockConcurrency` and `writeBlockConcurrency` to allow tuning of backup resources consumptions (PR [#6416](https://github.com/vatesfr/xen-orchestra/pull/6416))
|
- [Backup] Add `mergeBlockConcurrency` and `writeBlockConcurrency` to allow tuning of backup resources consumptions (PR [#6416](https://github.com/vatesfr/xen-orchestra/pull/6416))
|
||||||
|
- [Sync hook] VM can now be notified before being snapshot, please [see the documentation](https://github.com/vatesfr/xen-orchestra/blob/master/@xen-orchestra/xapi/docs/vm-sync-hook.md) (PR [#6423](https://github.com/vatesfr/xen-orchestra/pull/6423))
|
||||||
|
|
||||||
### Bug fixes
|
### Bug fixes
|
||||||
|
|
||||||
@ -40,6 +41,7 @@
|
|||||||
|
|
||||||
- @vates/fuse-vhd major
|
- @vates/fuse-vhd major
|
||||||
- @xen-orchestra/backups minor
|
- @xen-orchestra/backups minor
|
||||||
|
- @xen-orchestra/xapi minor
|
||||||
- vhd-lib minor
|
- vhd-lib minor
|
||||||
- xo-server-auth-saml patch
|
- xo-server-auth-saml patch
|
||||||
- xo-server minor
|
- xo-server minor
|
||||||
|
Loading…
Reference in New Issue
Block a user