Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement file restore on top of FUSE instead of vhdimount #6409

Merged
merged 17 commits into from
Sep 20, 2022
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions @vates/fuse-vhd/.npmignore
74 changes: 74 additions & 0 deletions @vates/fuse-vhd/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use strict'

const LRU = require('lru-cache')
const Fuse = require('fuse-native')
const { VhdSynthetic } = require('vhd-lib')
const { Disposable, fromCallback } = require('promise-toolbox')
const { createLogger } = require('@xen-orchestra/log')

const { warn } = createLogger('vates:fuse-vhd')

// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
const stat = st => ({
mtime: st.mtime || new Date(),
atime: st.atime || new Date(),
ctime: st.ctime || new Date(),
size: st.size !== undefined ? st.size : 0,
mode: st.mode === 'dir' ? 16877 : st.mode === 'file' ? 33188 : st.mode === 'link' ? 41453 : st.mode,
uid: st.uid !== undefined ? st.uid : process.getuid(),
gid: st.gid !== undefined ? st.gid : process.getgid(),
})

exports.mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) {
const vhd = yield VhdSynthetic.fromVhdChain(handler, diskPath)

const cache = new LRU({
max: 16, // each cached block is 2MB in size
})
await vhd.readBlockAllocationTable()
const fuse = new Fuse(mountDir, {
async readdir(path, cb) {
if (path === '/') {
return cb(null, ['vhd0'])
}
cb(Fuse.ENOENT)
},
async getattr(path, cb) {
if (path === '/') {
return cb(
null,
stat({
mode: 'dir',
size: 4096,
})
)
}
if (path === '/vhd0') {
return cb(
null,
stat({
mode: 'file',
size: vhd.footer.currentSize,
})
)
}

cb(Fuse.ENOENT)
},
read(path, fd, buf, len, pos, cb) {
if (path === '/vhd0') {
return vhd
.readRawData(pos, len, cache, buf)
.then(cb)
.catch(err => {
throw err
})
fbeauchamp marked this conversation as resolved.
Show resolved Hide resolved
}
throw new Error(`read file ${path} not exists`)
},
})
return new Disposable(
() => fromCallback(fuse.unmount),
fbeauchamp marked this conversation as resolved.
Show resolved Hide resolved
fromCallback(() => fuse.mount())
)
})
30 changes: 30 additions & 0 deletions @vates/fuse-vhd/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@vates/fuse-vhd",
"version": "0.0.1",
"license": "ISC",
"private": false,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/fuse-vhd",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"engines": {
"node": ">=10.0"
},
"dependencies": {
"@xen-orchestra/log": "^0.3.0",
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.0.1"
},
"scripts": {
"postversion": "npm publish --access public"
}
}
59 changes: 41 additions & 18 deletions @xen-orchestra/backups/RemoteAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const { isMetadataFile } = require('./_backupType.js')
const { isValidXva } = require('./_isValidXva.js')
const { listPartitions, LVM_PARTITION_TYPE } = require('./_listPartitions.js')
const { lvs, pvs } = require('./_lvm.js')
// @todo : this import is marked extraneous , sould be fixed when lib is published
const { mount } = require('@vates/fuse-vhd')
const { asyncEach } = require('@vates/async-each')

const DIR_XO_CONFIG_BACKUPS = 'xo-config-backups'
exports.DIR_XO_CONFIG_BACKUPS = DIR_XO_CONFIG_BACKUPS
Expand All @@ -45,8 +48,6 @@ const resolveRelativeFromFile = (file, path) => resolve('/', dirname(file), path

const resolveSubpath = (root, path) => resolve(root, `.${resolve('/', path)}`)

const RE_VHDI = /^vhdi(\d+)$/

async function addDirectory(files, realPath, metadataPath) {
const stats = await lstat(realPath)
if (stats.isDirectory()) {
Expand Down Expand Up @@ -75,12 +76,14 @@ const debounceResourceFactory = factory =>
}

class RemoteAdapter {
constructor(handler, { debounceResource = res => res, dirMode, vhdDirectoryCompression } = {}) {
constructor(handler, { debounceResource = res => res, dirMode, vhdDirectoryCompression, useGetDiskLegacy=false } = {}) {
this._debounceResource = debounceResource
this._dirMode = dirMode
this._handler = handler
this._vhdDirectoryCompression = vhdDirectoryCompression
this._readCacheListVmBackups = synchronized.withKey()(this._readCacheListVmBackups)
this._useGetDiskLegacy = useGetDiskLegacy

}

get handler() {
Expand Down Expand Up @@ -321,7 +324,10 @@ class RemoteAdapter {
return this.#useVhdDirectory()
}

async *getDisk(diskId) {

async *#getDiskLegacy(diskId) {

const RE_VHDI = /^vhdi(\d+)$/
const handler = this._handler

const diskPath = handler._getFilePath('/' + diskId)
Expand Down Expand Up @@ -351,6 +357,20 @@ class RemoteAdapter {
}
}

async *getDisk(diskId) {
if(this._useGetDiskLegacy){
yield * this.#getDiskLegacy(diskId)
return
}
const handler = this._handler
// this is a disposable
const mountDir = yield getTmpDir()
// this is also a disposable
yield mount(handler, diskId, mountDir)
// this will yield disk path to caller
yield `${mountDir}/vhd0`
}

// partitionId values:
//
// - undefined: raw disk
Expand Down Expand Up @@ -401,22 +421,25 @@ class RemoteAdapter {
listPartitionFiles(diskId, partitionId, path) {
return Disposable.use(this.getPartition(diskId, partitionId), async rootPath => {
path = resolveSubpath(rootPath, path)

const entriesMap = {}
await asyncMap(await readdir(path), async name => {
try {
const stats = await lstat(`${path}/${name}`)
if (stats.isDirectory()) {
entriesMap[name + '/'] = {}
} else if (stats.isFile()) {
entriesMap[name] = {}
}
} catch (error) {
if (error == null || error.code !== 'ENOENT') {
throw error
await asyncEach(
await readdir(path),
async name => {
try {
const stats = await lstat(`${path}/${name}`)
if (stats.isDirectory()) {
entriesMap[name + '/'] = {}
} else if (stats.isFile()) {
entriesMap[name] = {}
}
} catch (error) {
if (error == null || error.code !== 'ENOENT') {
throw error
}
}
}
})
},
{ concurrency: 1 }
)

return entriesMap
})
Expand Down
2 changes: 2 additions & 0 deletions @xen-orchestra/backups/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
"postversion": "npm publish --access public"
},
"dependencies": {
"@vates/async-each": "^1.0.0",
"@vates/cached-dns.lookup": "^1.0.0",
"@vates/compose": "^2.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.1",
"@vates/fuse-vhd": "^0.0.1",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^3.1.0",
Expand Down
1 change: 1 addition & 0 deletions @xen-orchestra/proxy/app/mixins/backups.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@ export default class Backups {
debounceResource: app.debounceResource.bind(app),
dirMode: app.config.get('backups.dirMode'),
vhdDirectoryCompression: app.config.get('backups.vhdDirectoryCompression'),
useGetDiskLegacy: app.config.getOptional('backups.useGetDiskLegacy'),
})
}

Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

> Users must be able to say: “Nice enhancement, I'm eager to test it”

- [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))

### Bug fixes
Expand All @@ -33,8 +35,11 @@

<!--packages-start-->

- @vates/fuse-vhd major
- @xen-orchestra/backups minor
- vhd-lib minor
- xo-server-auth-saml patch
- xo-web patch
- xo-server minor
- xo-web minor

<!--packages-end-->
34 changes: 34 additions & 0 deletions packages/vhd-lib/Vhd/VhdAbstract.js
Original file line number Diff line number Diff line change
Expand Up @@ -360,4 +360,38 @@ exports.VhdAbstract = class VhdAbstract {
}
return true
}

async readRawData(start, length, cache, buf) {
const header = this.header
const blockSize = header.blockSize
const startBlockId = Math.floor(start / blockSize)
const endBlockId = Math.floor((start + length) / blockSize)

const startOffset = start % blockSize
let copied = 0
for (let blockId = startBlockId; blockId <= endBlockId; blockId++) {
let data
if (this.containsBlock(blockId)) {
if (!cache.has(blockId)) {
cache.set(
blockId,
// promise is awaited later, so it won't generate unbounded error
this.readBlock(blockId).then(block => {
return block.data
})
)
}
// the cache contains a promise
data = await cache.get(blockId)
} else {
data = Buffer.alloc(blockSize, 0)
}
const offsetStart = blockId === startBlockId ? startOffset : 0
const offsetEnd = blockId === endBlockId ? (start + length) % blockSize : blockSize
data.copy(buf, copied, offsetStart, offsetEnd)
copied += offsetEnd - offsetStart
}
assert.strictEqual(copied, length, 'invalid length')
return copied
}
}
2 changes: 2 additions & 0 deletions packages/xo-server/src/xo-mixins/backups-remote-adapter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export default class BackupsRemoteAdapter {
debounceResource: app.debounceResource.bind(app),
dirMode: app.config.get('backups.dirMode'),
vhdDirectoryCompression: app.config.get('backups.vhdDirectoryCompression'),
// this adapter is also used for file restore
useGetDiskLegacy: app.config.getOptional('backups.useGetDiskLegacy'),
})
}
}
1 change: 0 additions & 1 deletion packages/xo-server/src/xo-mixins/remotes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { Remotes } from '../models/remote.mjs'

const obfuscateRemote = ({ url, ...remote }) => {
const parsedUrl = parse(url)
remote.supportFileRestore = parsedUrl.type !== 's3'
remote.url = format(sensitiveValues.obfuscate(parsedUrl))
return remote
}
Expand Down
3 changes: 0 additions & 3 deletions packages/xo-web/src/common/intl/locales/es.js
Original file line number Diff line number Diff line change
Expand Up @@ -2676,9 +2676,6 @@ export default {
// Original text: 'Click on a VM to display restore options'
restoreBackupsInfo: undefined,

// Original text: 'Only the files of Delta Backup which are not on a SMB remote can be restored'
restoreDeltaBackupsInfo: undefined,

// Original text: "Enabled"
remoteEnabled: 'activado',

Expand Down
4 changes: 0 additions & 4 deletions packages/xo-web/src/common/intl/locales/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -2702,10 +2702,6 @@ export default {
// Original text: "Click on a VM to display restore options"
restoreBackupsInfo: 'Cliquez sur une VM pour afficher les options de récupération',

// Original text: "Only the files of Delta Backup which are not on a SMB remote can be restored"
restoreDeltaBackupsInfo:
'Seuls les fichiers de Delta Backup qui ne sont pas sur un emplacement SMB peuvent être restaurés',

// Original text: "Enabled"
remoteEnabled: 'activé',

Expand Down
3 changes: 0 additions & 3 deletions packages/xo-web/src/common/intl/locales/it.js
Original file line number Diff line number Diff line change
Expand Up @@ -3906,9 +3906,6 @@ export default {
// Original text: 'Click on a VM to display restore options'
restoreBackupsInfo: 'Fare clic su una VM per visualizzare le opzioni di ripristino',

// Original text: 'Only the files of Delta Backup which are not on a SMB remote can be restored'
restoreDeltaBackupsInfo: 'È possibile ripristinare solo i file di Delta Backup che non si trovano su un SMB remoto',

// Original text: 'Enabled'
remoteEnabled: 'Abilitato',

Expand Down
3 changes: 0 additions & 3 deletions packages/xo-web/src/common/intl/locales/tr.js
Original file line number Diff line number Diff line change
Expand Up @@ -3343,9 +3343,6 @@ export default {
// Original text: "Click on a VM to display restore options"
restoreBackupsInfo: "Geri getirme seçenekleri için bir VM'e tıklayın",

// Original text: "Only the files of Delta Backup which are not on a SMB remote can be restored"
restoreDeltaBackupsInfo: 'Yalnızca SMB hedefinde olmayan fark yedeklerinden dosya alınabilir',

// Original text: "Enabled"
remoteEnabled: 'Etkin',

Expand Down
1 change: 0 additions & 1 deletion packages/xo-web/src/common/intl/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -1636,7 +1636,6 @@ const messages = {
getRemote: 'Get remote',
noBackups: 'There are no backups!',
restoreBackupsInfo: 'Click on a VM to display restore options',
restoreDeltaBackupsInfo: 'Only the files of Delta Backup which are not on a SMB or S3 remote can be restored',
remoteEnabled: 'Enabled',
remoteDisabled: 'Disabled',
enableRemote: 'Enable',
Expand Down
6 changes: 1 addition & 5 deletions packages/xo-web/src/xo-app/backup/file-restore/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import Icon from 'icon'
import React from 'react'
import SortedTable from 'sorted-table'
import Upgrade from 'xoa-upgrade'
Expand Down Expand Up @@ -87,7 +86,7 @@ export default class Restore extends Component {

_refreshBackupList = async (_remotes = this.props.remotes, jobs = this.props.jobs) => {
const remotes = keyBy(
filter(_remotes, remote => remote.enabled && remote.supportFileRestore),
filter(_remotes, remote => remote.enabled),
'id'
)
const backupsByRemote = await listVmBackups(toArray(remotes))
Expand Down Expand Up @@ -204,9 +203,6 @@ export default class Restore extends Component {
{_('refreshBackupList')}
</ActionButton>
</div>
<em>
<Icon icon='info' /> {_('restoreDeltaBackupsInfo')}
</em>
<SortedTable
actions={this._actions}
collection={this.state.backupDataByVm}
Expand Down
Loading