feat(xo-server/api): backupNg.{,un}mountPartition (#7176)

Manual method to mount a backup partition on the XOA.
This commit is contained in:
Julien Fontanet 2023-11-24 09:47:23 +01:00 committed by GitHub
parent e108cb0990
commit 865461bfb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 209 additions and 1 deletions

View File

@ -17,4 +17,14 @@ map.get(['foo', 'bar']) // 2
map.get(['bar', 'foo']) // 3
map.get([OBJ]) // 4
map.get([{}]) // undefined
map.delete([])
for (const [key, value] of map.entries() {
console.log(key, value)
}
for (const value of map.values()) {
console.log(value)
}
```

View File

@ -35,6 +35,16 @@ map.get(['foo', 'bar']) // 2
map.get(['bar', 'foo']) // 3
map.get([OBJ]) // 4
map.get([{}]) // undefined
map.delete([])
for (const [key, value] of map.entries() {
console.log(key, value)
}
for (const value of map.values()) {
console.log(value)
}
```
## Contributions

View File

@ -36,6 +36,23 @@ function del(node, i, keys) {
return node
}
function* entries(node, key) {
if (node !== undefined) {
if (node instanceof Node) {
const { value } = node
if (value !== undefined) {
yield [key, node.value]
}
for (const [childKey, child] of node.children.entries()) {
yield* entries(child, key.concat(childKey))
}
} else {
yield [key, node]
}
}
}
function get(node, i, keys) {
return i === keys.length
? node instanceof Node
@ -69,6 +86,22 @@ function set(node, i, keys, value) {
return node
}
function* values(node) {
if (node !== undefined) {
if (node instanceof Node) {
const { value } = node
if (value !== undefined) {
yield node.value
}
for (const child of node.children.values()) {
yield* values(child)
}
} else {
yield node
}
}
}
exports.MultiKeyMap = class MultiKeyMap {
constructor() {
// each node is either a value or a Node if it contains children
@ -79,6 +112,10 @@ exports.MultiKeyMap = class MultiKeyMap {
this._root = del(this._root, 0, keys)
}
entries() {
return entries(this._root, [])
}
get(keys) {
return get(this._root, 0, keys)
}
@ -86,4 +123,8 @@ exports.MultiKeyMap = class MultiKeyMap {
set(keys, value) {
this._root = set(this._root, 0, keys, value)
}
values() {
return values(this._root)
}
}

View File

@ -19,7 +19,7 @@ describe('MultiKeyMap', () => {
// reverse composite key
['bar', 'foo'],
]
const values = keys.map(() => ({}))
const values = keys.map(() => Math.random())
// set all values first to make sure they are all stored and not only the
// last one
@ -27,6 +27,12 @@ describe('MultiKeyMap', () => {
map.set(key, values[i])
})
assert.deepEqual(
Array.from(map.entries()),
keys.map((key, i) => [key, values[i]])
)
assert.deepEqual(Array.from(map.values()), values)
keys.forEach((key, i) => {
// copy the key to make sure the array itself is not the key
assert.strictEqual(map.get(key.slice()), values[i])

View File

@ -14,6 +14,7 @@
- [VM Creation] Added ISO option in new VM form when creating from template with a disk [#3464](https://github.com/vatesfr/xen-orchestra/issues/3464) (PR [#7166](https://github.com/vatesfr/xen-orchestra/pull/7166))
- [REST API] `tags` property can be updated (PR [#7196](https://github.com/vatesfr/xen-orchestra/pull/7196))
- [REST API] A VDI export can now be imported in an existing VDI (PR [#7199](https://github.com/vatesfr/xen-orchestra/pull/7199))
- [File Restore] API method `backupNg.mountPartition` to manually mount a backup disk on the XOA
### Bug fixes
@ -40,6 +41,7 @@
<!--packages-start-->
- @vates/multi-key-map minor
- @vates/nbd-client patch
- @xen-orchestra/backups minor
- @xen-orchestra/cr-seed-cli major
@ -51,5 +53,6 @@
- xo-server-netbox minor
- xo-vmdk-to-vhd patch
- xo-web minor
- xo-server minor
<!--packages-end-->

View File

@ -76,6 +76,8 @@ defaultSignInPage = '/signin'
throttlingDelay = '2 seconds'
[backups]
autoUnmountPartitionDelay = '24h'
disableMergeWorker = false
# Mode to use for newly created backup directories

View File

@ -380,3 +380,47 @@ fetchFiles.params = {
type: 'string',
},
}
export function listMountedPartitions() {
return this.listMountedPartitions()
}
listMountedPartitions.permission = 'admin'
export function mountPartition({ remote, disk, partition }) {
return this.mountPartition(remote, disk, partition)
}
mountPartition.permission = 'admin'
mountPartition.params = {
disk: {
type: 'string',
},
partition: {
optional: true,
type: 'string',
},
remote: {
type: 'string',
},
}
export function unmountPartition({ remote, disk, partition }) {
return this.unmountPartition(remote, disk, partition)
}
unmountPartition.permission = 'admin'
unmountPartition.params = {
disk: {
type: 'string',
},
partition: {
optional: true,
type: 'string',
},
remote: {
type: 'string',
},
}

View File

@ -1,5 +1,12 @@
import Disposable from 'promise-toolbox/Disposable'
import isPromise from 'promise-toolbox/isPromise'
import { asyncEach } from '@vates/async-each'
import { createLogger } from '@xen-orchestra/log'
import { decorateWith } from '@vates/decorate-with'
import { execa } from 'execa'
import { MultiKeyMap } from '@vates/multi-key-map'
const { warn } = createLogger('xo:mixins:file-restore-ng')
// - [x] list partitions
// - [x] list files in a partition
@ -22,6 +29,8 @@ import { execa } from 'execa'
// - [ ] getMountedPartitions
// - [ ] unmountPartition
export default class BackupNgFileRestore {
#mounts = new MultiKeyMap()
constructor(app) {
this._app = app
@ -31,6 +40,16 @@ export default class BackupNgFileRestore {
await Promise.all([execa('losetup', ['-D']), execa('vgchange', ['-an'])])
await execa('pvscan', ['--cache'])
})
app.hooks.on('stop', () =>
asyncEach(
this.#mounts.values(),
async pDisposable => {
await (await pDisposable).dispose()
},
{ stopOnError: false }
)
)
}
async fetchBackupNgPartitionFiles(remoteId, diskId, partitionId, paths, format) {
@ -108,4 +127,77 @@ export default class BackupNgFileRestore {
adapter.listPartitionFiles(diskId, partitionId, path)
)
}
listMountedPartitions() {
const mounts = []
for (const [key, disposable] of this.#mounts.entries()) {
if (!isPromise(disposable)) {
const [remote, disk, partition] = key
mounts.push({ remote, disk, partition, path: disposable.value })
}
}
return mounts
}
@decorateWith(Disposable.factory)
*_mountPartition(remoteId, diskId, partitionId) {
const adapter = yield this._app.getBackupsRemoteAdapter(remoteId)
// yield(2) the disposable to use it
// yield(1) the value to make it available
yield yield adapter.getPartition(diskId, partitionId)
}
async mountPartition(remoteId, diskId, partitionId) {
const mounts = this.#mounts
const key = [remoteId, diskId, partitionId]
let pDisposable = mounts.get(key)
if (pDisposable !== undefined) {
return (await pDisposable).value
}
pDisposable = this._mountPartition(remoteId, diskId, partitionId)
mounts.set(key, pDisposable)
pDisposable.catch(() => mounts.delete(key))
const disposable = await pDisposable
// replace the promise by it's value so that it can be used directly in
// listMountedPartitions without breaking other uses
mounts.set(key, disposable)
const delay = await this._app.config.getDuration('backups.autoUnmountPartitionDelay')
if (delay !== 0) {
const dispose = disposable.dispose.bind(disposable)
const handle = setTimeout(
() =>
disposable.dispose().catch(error => {
warn('unmounting partition', { error })
}),
delay
)
disposable.dispose = () => {
clearTimeout(handle)
return dispose()
}
}
return disposable.value
}
async unmountPartition(remoteId, diskId, partitionId) {
const mounts = this.#mounts
const key = [remoteId, diskId, partitionId]
const pDisposable = mounts.get(key)
if (pDisposable === undefined) {
return
}
mounts.delete(key)
await (await pDisposable).dispose()
}
}