Compare commits
70 Commits
refactor-r
...
bugfix-rem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11ed381c7 | ||
|
|
265b545c0c | ||
|
|
86ccdd8f72 | ||
|
|
f0da94081b | ||
|
|
cd44a6e28c | ||
|
|
70b09839c7 | ||
|
|
12140143d2 | ||
|
|
e68236c9f2 | ||
|
|
8a1a0d76f7 | ||
|
|
4a5bc5dccc | ||
|
|
0ccdfbd6f4 | ||
|
|
75af7668b5 | ||
|
|
0b454fa670 | ||
|
|
2dcb5cb7cd | ||
|
|
a5aeeceb7f | ||
|
|
b2f2c3cbc4 | ||
|
|
0f7ac004ad | ||
|
|
7faa82a9c8 | ||
|
|
4b3f60b280 | ||
|
|
b29d5ba95c | ||
|
|
408fc5af84 | ||
|
|
2748aea4e9 | ||
|
|
a5acc7d267 | ||
|
|
87a9fbe237 | ||
|
|
9d0b7242f0 | ||
|
|
20ec44c3b3 | ||
|
|
6f68456bae | ||
|
|
b856c1a6b4 | ||
|
|
61e1f83a9f | ||
|
|
5820e19731 | ||
|
|
cdb51f8fe3 | ||
|
|
57940e0a52 | ||
|
|
6cc95efe51 | ||
|
|
b0ff2342ab | ||
|
|
0f67692be4 | ||
|
|
865461bfb9 | ||
|
|
e108cb0990 | ||
|
|
c4535c6bae | ||
|
|
ad8eaaa771 | ||
|
|
9419cade3d | ||
|
|
272e6422bd | ||
|
|
547908a8f9 | ||
|
|
8abfaa0bd5 | ||
|
|
a9fbcf3962 | ||
|
|
887b49ebbf | ||
|
|
858ecbc217 | ||
|
|
ffd523679d | ||
|
|
bd9db437f1 | ||
|
|
0365bacfbb | ||
|
|
f3e0227c55 | ||
|
|
4504141cbf | ||
|
|
ecbbf878d0 | ||
|
|
c1faaa3107 | ||
|
|
59f04b4a6b | ||
|
|
781b070e74 | ||
|
|
1911386aba | ||
|
|
5b0339315f | ||
|
|
5fe53dfa99 | ||
|
|
06068cdcc6 | ||
|
|
c88cc2b020 | ||
|
|
03de8ad481 | ||
|
|
08ba7e7253 | ||
|
|
9ca3f3df26 | ||
|
|
511908bb7d | ||
|
|
4351aad312 | ||
|
|
af7aa29c91 | ||
|
|
315d626055 | ||
|
|
7af0899800 | ||
|
|
46ec2dfd56 | ||
|
|
b2348474c3 |
@@ -68,6 +68,11 @@ module.exports = {
|
||||
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
|
||||
// this rule can prevent race condition bugs like parallel `a += await foo()`
|
||||
//
|
||||
// as it has a lots of false positive, it is only enabled as a warning for now
|
||||
'require-atomic-updates': 'warn',
|
||||
|
||||
strict: 'error',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
@@ -23,7 +23,7 @@
|
||||
"test": "node--test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/multi-key-map": "^0.1.0",
|
||||
"@vates/multi-key-map": "^0.2.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"ensure-array": "^1.0.0"
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"fuse-native": "^2.2.6",
|
||||
"lru-cache": "^7.14.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.6.1"
|
||||
"vhd-lib": "^4.7.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,14 +36,31 @@ 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
|
||||
? node.value
|
||||
: node
|
||||
: node instanceof Node
|
||||
? get(node.children.get(keys[i]), i + 1, keys)
|
||||
: undefined
|
||||
? get(node.children.get(keys[i]), i + 1, keys)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function set(node, i, keys, value) {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert'
|
||||
import { Socket } from 'node:net'
|
||||
import { connect } from 'node:tls'
|
||||
import { fromCallback, pRetry, pDelay, pTimeout } from 'promise-toolbox'
|
||||
import { fromCallback, pRetry, pDelay, pTimeout, pFromCallback } from 'promise-toolbox'
|
||||
import { readChunkStrict } from '@vates/read-chunk'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
OPTS_MAGIC,
|
||||
NBD_CMD_DISC,
|
||||
} from './constants.mjs'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
const { warn } = createLogger('vates:nbd-client')
|
||||
|
||||
@@ -40,6 +41,7 @@ export default class NbdClient {
|
||||
#readBlockRetries
|
||||
#reconnectRetry
|
||||
#connectTimeout
|
||||
#messageTimeout
|
||||
|
||||
// AFAIK, there is no guaranty the server answers in the same order as the queries
|
||||
// so we handle a backlog of command waiting for response and handle concurrency manually
|
||||
@@ -52,7 +54,14 @@ export default class NbdClient {
|
||||
#reconnectingPromise
|
||||
constructor(
|
||||
{ address, port = NBD_DEFAULT_PORT, exportname, cert },
|
||||
{ connectTimeout = 6e4, waitBeforeReconnect = 1e3, readAhead = 10, readBlockRetries = 5, reconnectRetry = 5 } = {}
|
||||
{
|
||||
connectTimeout = 6e4,
|
||||
messageTimeout = 6e4,
|
||||
waitBeforeReconnect = 1e3,
|
||||
readAhead = 10,
|
||||
readBlockRetries = 5,
|
||||
reconnectRetry = 5,
|
||||
} = {}
|
||||
) {
|
||||
this.#serverAddress = address
|
||||
this.#serverPort = port
|
||||
@@ -63,6 +72,7 @@ export default class NbdClient {
|
||||
this.#readBlockRetries = readBlockRetries
|
||||
this.#reconnectRetry = reconnectRetry
|
||||
this.#connectTimeout = connectTimeout
|
||||
this.#messageTimeout = messageTimeout
|
||||
}
|
||||
|
||||
get exportSize() {
|
||||
@@ -116,12 +126,24 @@ export default class NbdClient {
|
||||
return
|
||||
}
|
||||
|
||||
const queryId = this.#nextCommandQueryId
|
||||
this.#nextCommandQueryId++
|
||||
|
||||
const buffer = Buffer.alloc(28)
|
||||
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
|
||||
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
|
||||
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
|
||||
await this.#write(buffer)
|
||||
await this.#serverSocket.destroy()
|
||||
buffer.writeBigUInt64BE(queryId, 8)
|
||||
buffer.writeBigUInt64BE(0n, 16)
|
||||
buffer.writeInt32BE(0, 24)
|
||||
const promise = pFromCallback(cb => {
|
||||
this.#serverSocket.end(buffer, 'utf8', cb)
|
||||
})
|
||||
try {
|
||||
await pTimeout.call(promise, this.#messageTimeout)
|
||||
} catch (error) {
|
||||
this.#serverSocket.destroy()
|
||||
}
|
||||
this.#serverSocket = undefined
|
||||
this.#connected = false
|
||||
}
|
||||
@@ -195,11 +217,13 @@ export default class NbdClient {
|
||||
}
|
||||
|
||||
#read(length) {
|
||||
return readChunkStrict(this.#serverSocket, length)
|
||||
const promise = readChunkStrict(this.#serverSocket, length)
|
||||
return pTimeout.call(promise, this.#messageTimeout)
|
||||
}
|
||||
|
||||
#write(buffer) {
|
||||
return fromCallback.call(this.#serverSocket, 'write', buffer)
|
||||
const promise = fromCallback.call(this.#serverSocket, 'write', buffer)
|
||||
return pTimeout.call(promise, this.#messageTimeout)
|
||||
}
|
||||
|
||||
async #readInt32() {
|
||||
@@ -232,19 +256,20 @@ export default class NbdClient {
|
||||
}
|
||||
try {
|
||||
this.#waitingForResponse = true
|
||||
const magic = await this.#readInt32()
|
||||
const buffer = await this.#read(16)
|
||||
const magic = buffer.readInt32BE(0)
|
||||
|
||||
if (magic !== NBD_REPLY_MAGIC) {
|
||||
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
|
||||
}
|
||||
|
||||
const error = await this.#readInt32()
|
||||
const error = buffer.readInt32BE(4)
|
||||
if (error !== 0) {
|
||||
// @todo use error code from constants.mjs
|
||||
throw new Error(`GOT ERROR CODE : ${error}`)
|
||||
}
|
||||
|
||||
const blockQueryId = await this.#readInt64()
|
||||
const blockQueryId = buffer.readBigUInt64BE(8)
|
||||
const query = this.#commandQueryBacklog.get(blockQueryId)
|
||||
if (!query) {
|
||||
throw new Error(` no query associated with id ${blockQueryId}`)
|
||||
@@ -281,7 +306,13 @@ export default class NbdClient {
|
||||
buffer.writeInt16BE(NBD_CMD_READ, 6) // we want to read a data block
|
||||
buffer.writeBigUInt64BE(queryId, 8)
|
||||
// byte offset in the raw disk
|
||||
buffer.writeBigUInt64BE(BigInt(index) * BigInt(size), 16)
|
||||
const offset = BigInt(index) * BigInt(size)
|
||||
const remaining = this.#exportSize - offset
|
||||
if (remaining < BigInt(size)) {
|
||||
size = Number(remaining)
|
||||
}
|
||||
|
||||
buffer.writeBigUInt64BE(offset, 16)
|
||||
buffer.writeInt32BE(size, 24)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -307,14 +338,15 @@ export default class NbdClient {
|
||||
})
|
||||
}
|
||||
|
||||
async *readBlocks(indexGenerator) {
|
||||
async *readBlocks(indexGenerator = 2 * 1024 * 1024) {
|
||||
// default : read all blocks
|
||||
if (indexGenerator === undefined) {
|
||||
const exportSize = this.#exportSize
|
||||
const chunkSize = 2 * 1024 * 1024
|
||||
if (typeof indexGenerator === 'number') {
|
||||
const exportSize = Number(this.#exportSize)
|
||||
const chunkSize = indexGenerator
|
||||
|
||||
indexGenerator = function* () {
|
||||
const nbBlocks = Math.ceil(Number(exportSize / BigInt(chunkSize)))
|
||||
for (let index = 0; BigInt(index) < nbBlocks; index++) {
|
||||
const nbBlocks = Math.ceil(exportSize / chunkSize)
|
||||
for (let index = 0; index < nbBlocks; index++) {
|
||||
yield { index, size: chunkSize }
|
||||
}
|
||||
}
|
||||
@@ -348,4 +380,15 @@ export default class NbdClient {
|
||||
yield readAhead.shift()
|
||||
}
|
||||
}
|
||||
|
||||
stream(chunkSize) {
|
||||
async function* iterator() {
|
||||
for await (const chunk of this.readBlocks(chunkSize)) {
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
// create a readable stream instead of returning the iterator
|
||||
// since iterators don't like unshift and partial reading
|
||||
return Readable.from(iterator())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
},
|
||||
@@ -24,7 +24,7 @@
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"xen-api": "^1.3.6"
|
||||
"xen-api": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^16.3.0",
|
||||
|
||||
@@ -22,41 +22,41 @@ const readChunk = (stream, size) =>
|
||||
stream.errored != null
|
||||
? Promise.reject(stream.errored)
|
||||
: stream.closed || stream.readableEnded
|
||||
? Promise.resolve(null)
|
||||
: new Promise((resolve, reject) => {
|
||||
if (size !== undefined) {
|
||||
assert(size > 0)
|
||||
? Promise.resolve(null)
|
||||
: new Promise((resolve, reject) => {
|
||||
if (size !== undefined) {
|
||||
assert(size > 0)
|
||||
|
||||
// per Node documentation:
|
||||
// > The size argument must be less than or equal to 1 GiB.
|
||||
assert(size < 1073741824)
|
||||
}
|
||||
// per Node documentation:
|
||||
// > The size argument must be less than or equal to 1 GiB.
|
||||
assert(size < 1073741824)
|
||||
}
|
||||
|
||||
function onEnd() {
|
||||
resolve(null)
|
||||
removeListeners()
|
||||
}
|
||||
function onError(error) {
|
||||
reject(error)
|
||||
removeListeners()
|
||||
}
|
||||
function onReadable() {
|
||||
const data = stream.read(size)
|
||||
if (data !== null) {
|
||||
resolve(data)
|
||||
function onEnd() {
|
||||
resolve(null)
|
||||
removeListeners()
|
||||
}
|
||||
}
|
||||
function removeListeners() {
|
||||
stream.removeListener('end', onEnd)
|
||||
stream.removeListener('error', onError)
|
||||
stream.removeListener('readable', onReadable)
|
||||
}
|
||||
stream.on('end', onEnd)
|
||||
stream.on('error', onError)
|
||||
stream.on('readable', onReadable)
|
||||
onReadable()
|
||||
})
|
||||
function onError(error) {
|
||||
reject(error)
|
||||
removeListeners()
|
||||
}
|
||||
function onReadable() {
|
||||
const data = stream.read(size)
|
||||
if (data !== null) {
|
||||
resolve(data)
|
||||
removeListeners()
|
||||
}
|
||||
}
|
||||
function removeListeners() {
|
||||
stream.removeListener('end', onEnd)
|
||||
stream.removeListener('error', onError)
|
||||
stream.removeListener('readable', onReadable)
|
||||
}
|
||||
stream.on('end', onEnd)
|
||||
stream.on('error', onError)
|
||||
stream.on('readable', onReadable)
|
||||
onReadable()
|
||||
})
|
||||
exports.readChunk = readChunk
|
||||
|
||||
/**
|
||||
@@ -111,42 +111,42 @@ async function skip(stream, size) {
|
||||
return stream.errored != null
|
||||
? Promise.reject(stream.errored)
|
||||
: size === 0 || stream.closed || stream.readableEnded
|
||||
? Promise.resolve(0)
|
||||
: new Promise((resolve, reject) => {
|
||||
let left = size
|
||||
function onEnd() {
|
||||
resolve(size - left)
|
||||
removeListeners()
|
||||
}
|
||||
function onError(error) {
|
||||
reject(error)
|
||||
removeListeners()
|
||||
}
|
||||
function onReadable() {
|
||||
const data = stream.read()
|
||||
left -= data === null ? 0 : data.length
|
||||
if (left > 0) {
|
||||
// continue to read
|
||||
} else {
|
||||
// if more than wanted has been read, push back the rest
|
||||
if (left < 0) {
|
||||
stream.unshift(data.slice(left))
|
||||
}
|
||||
|
||||
resolve(size)
|
||||
? Promise.resolve(0)
|
||||
: new Promise((resolve, reject) => {
|
||||
let left = size
|
||||
function onEnd() {
|
||||
resolve(size - left)
|
||||
removeListeners()
|
||||
}
|
||||
}
|
||||
function removeListeners() {
|
||||
stream.removeListener('end', onEnd)
|
||||
stream.removeListener('error', onError)
|
||||
stream.removeListener('readable', onReadable)
|
||||
}
|
||||
stream.on('end', onEnd)
|
||||
stream.on('error', onError)
|
||||
stream.on('readable', onReadable)
|
||||
onReadable()
|
||||
})
|
||||
function onError(error) {
|
||||
reject(error)
|
||||
removeListeners()
|
||||
}
|
||||
function onReadable() {
|
||||
const data = stream.read()
|
||||
left -= data === null ? 0 : data.length
|
||||
if (left > 0) {
|
||||
// continue to read
|
||||
} else {
|
||||
// if more than wanted has been read, push back the rest
|
||||
if (left < 0) {
|
||||
stream.unshift(data.slice(left))
|
||||
}
|
||||
|
||||
resolve(size)
|
||||
removeListeners()
|
||||
}
|
||||
}
|
||||
function removeListeners() {
|
||||
stream.removeListener('end', onEnd)
|
||||
stream.removeListener('error', onError)
|
||||
stream.removeListener('readable', onReadable)
|
||||
}
|
||||
stream.on('end', onEnd)
|
||||
stream.on('error', onError)
|
||||
stream.on('readable', onReadable)
|
||||
onReadable()
|
||||
})
|
||||
}
|
||||
exports.skip = skip
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.43.2",
|
||||
"@xen-orchestra/fs": "^4.1.2",
|
||||
"@xen-orchestra/backups": "^0.44.2",
|
||||
"@xen-orchestra/fs": "^4.1.3",
|
||||
"filenamify": "^6.0.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
@@ -27,7 +27,7 @@
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.14",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -4,23 +4,229 @@ import { formatFilenameDate } from './_filenameDate.mjs'
|
||||
import { importIncrementalVm } from './_incrementalVm.mjs'
|
||||
import { Task } from './Task.mjs'
|
||||
import { watchStreamSize } from './_watchStreamSize.mjs'
|
||||
import { VhdNegative, VhdSynthetic } from 'vhd-lib'
|
||||
import { decorateClass } from '@vates/decorate-with'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { dirname, join } from 'node:path'
|
||||
import pickBy from 'lodash/pickBy.js'
|
||||
import { defer } from 'golike-defer'
|
||||
|
||||
const { debug, info, warn } = createLogger('xo:backups:importVmBackup')
|
||||
async function resolveUuid(xapi, cache, uuid, type) {
|
||||
if (uuid == null) {
|
||||
return uuid
|
||||
}
|
||||
const ref = cache.get(uuid)
|
||||
if (ref === undefined) {
|
||||
cache.set(uuid, xapi.call(`${type}.get_by_uuid`, uuid))
|
||||
}
|
||||
return cache.get(uuid)
|
||||
}
|
||||
export class ImportVmBackup {
|
||||
constructor({ adapter, metadata, srUuid, xapi, settings: { newMacAddresses, mapVdisSrs = {} } = {} }) {
|
||||
constructor({
|
||||
adapter,
|
||||
metadata,
|
||||
srUuid,
|
||||
xapi,
|
||||
settings: { additionnalVmTag, newMacAddresses, mapVdisSrs = {}, useDifferentialRestore = false } = {},
|
||||
}) {
|
||||
this._adapter = adapter
|
||||
this._importIncrementalVmSettings = { newMacAddresses, mapVdisSrs }
|
||||
this._importIncrementalVmSettings = { additionnalVmTag, newMacAddresses, mapVdisSrs, useDifferentialRestore }
|
||||
this._metadata = metadata
|
||||
this._srUuid = srUuid
|
||||
this._xapi = xapi
|
||||
}
|
||||
|
||||
async #getPathOfVdiSnapshot(snapshotUuid) {
|
||||
const metadata = this._metadata
|
||||
if (this._pathToVdis === undefined) {
|
||||
const backups = await this._adapter.listVmBackups(
|
||||
this._metadata.vm.uuid,
|
||||
({ mode, timestamp }) => mode === 'delta' && timestamp >= metadata.timestamp
|
||||
)
|
||||
const map = new Map()
|
||||
for (const backup of backups) {
|
||||
for (const [vdiRef, vdi] of Object.entries(backup.vdis)) {
|
||||
map.set(vdi.uuid, backup.vhds[vdiRef])
|
||||
}
|
||||
}
|
||||
this._pathToVdis = map
|
||||
}
|
||||
return this._pathToVdis.get(snapshotUuid)
|
||||
}
|
||||
|
||||
async _reuseNearestSnapshot($defer, ignoredVdis) {
|
||||
const metadata = this._metadata
|
||||
const { mapVdisSrs } = this._importIncrementalVmSettings
|
||||
const { vbds, vhds, vifs, vm, vmSnapshot } = metadata
|
||||
const streams = {}
|
||||
const metdataDir = dirname(metadata._filename)
|
||||
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
|
||||
|
||||
for (const [vdiRef, vdi] of Object.entries(vdis)) {
|
||||
const vhdPath = join(metdataDir, vhds[vdiRef])
|
||||
|
||||
let xapiDisk
|
||||
try {
|
||||
xapiDisk = await this._xapi.getRecordByUuid('VDI', vdi.$snapshot_of$uuid)
|
||||
} catch (err) {
|
||||
// if this disk is not present anymore, fall back to default restore
|
||||
warn(err)
|
||||
}
|
||||
|
||||
let snapshotCandidate, backupCandidate
|
||||
if (xapiDisk !== undefined) {
|
||||
debug('found disks, wlll search its snapshots', { snapshots: xapiDisk.snapshots })
|
||||
for (const snapshotRef of xapiDisk.snapshots) {
|
||||
const snapshot = await this._xapi.getRecord('VDI', snapshotRef)
|
||||
debug('handling snapshot', { snapshot })
|
||||
|
||||
// take only the first snapshot
|
||||
if (snapshotCandidate && snapshotCandidate.snapshot_time < snapshot.snapshot_time) {
|
||||
debug('already got a better candidate')
|
||||
continue
|
||||
}
|
||||
|
||||
// have a corresponding backup more recent than metadata ?
|
||||
const pathToSnapshotData = await this.#getPathOfVdiSnapshot(snapshot.uuid)
|
||||
if (pathToSnapshotData === undefined) {
|
||||
debug('no backup linked to this snaphot')
|
||||
continue
|
||||
}
|
||||
if (snapshot.$SR.uuid !== (mapVdisSrs[vdi.$snapshot_of$uuid] ?? this._srUuid)) {
|
||||
debug('not restored on the same SR', { snapshotSr: snapshot.$SR.uuid, mapVdisSrs, srUuid: this._srUuid })
|
||||
continue
|
||||
}
|
||||
|
||||
debug('got a candidate', pathToSnapshotData)
|
||||
|
||||
snapshotCandidate = snapshot
|
||||
backupCandidate = pathToSnapshotData
|
||||
}
|
||||
}
|
||||
|
||||
let stream
|
||||
const backupWithSnapshotPath = join(metdataDir, backupCandidate ?? '')
|
||||
if (vhdPath === backupWithSnapshotPath) {
|
||||
// all the data are already on the host
|
||||
debug('direct reuse of a snapshot')
|
||||
stream = null
|
||||
vdis[vdiRef].baseVdi = snapshotCandidate
|
||||
// go next disk , we won't use this stream
|
||||
continue
|
||||
}
|
||||
|
||||
let disposableDescendants
|
||||
|
||||
const disposableSynthetic = await VhdSynthetic.fromVhdChain(this._adapter._handler, vhdPath)
|
||||
|
||||
// this will also clean if another disk of this VM backup fails
|
||||
// if user really only need to restore non failing disks he can retry with ignoredVdis
|
||||
let disposed = false
|
||||
const disposeOnce = async () => {
|
||||
if (!disposed) {
|
||||
disposed = true
|
||||
try {
|
||||
await disposableDescendants?.dispose()
|
||||
await disposableSynthetic?.dispose()
|
||||
} catch (error) {
|
||||
warn('openVhd: failed to dispose VHDs', { error })
|
||||
}
|
||||
}
|
||||
}
|
||||
$defer.onFailure(() => disposeOnce())
|
||||
|
||||
const parentVhd = disposableSynthetic.value
|
||||
await parentVhd.readBlockAllocationTable()
|
||||
debug('got vhd synthetic of parents', parentVhd.length)
|
||||
|
||||
if (snapshotCandidate !== undefined) {
|
||||
try {
|
||||
debug('will try to use differential restore', {
|
||||
backupWithSnapshotPath,
|
||||
vhdPath,
|
||||
vdiRef,
|
||||
})
|
||||
|
||||
disposableDescendants = await VhdSynthetic.fromVhdChain(this._adapter._handler, backupWithSnapshotPath, {
|
||||
until: vhdPath,
|
||||
})
|
||||
const descendantsVhd = disposableDescendants.value
|
||||
await descendantsVhd.readBlockAllocationTable()
|
||||
debug('got vhd synthetic of descendants')
|
||||
const negativeVhd = new VhdNegative(parentVhd, descendantsVhd)
|
||||
debug('got vhd negative')
|
||||
|
||||
// update the stream with the negative vhd stream
|
||||
stream = await negativeVhd.stream()
|
||||
vdis[vdiRef].baseVdi = snapshotCandidate
|
||||
} catch (err) {
|
||||
// can be a broken VHD chain, a vhd chain with a key backup, ....
|
||||
// not an irrecuperable error, don't dispose parentVhd, and fallback to full restore
|
||||
warn(`can't use differential restore`, err)
|
||||
disposableDescendants?.dispose()
|
||||
}
|
||||
}
|
||||
// didn't make a negative stream : fallback to classic stream
|
||||
if (stream === undefined) {
|
||||
debug('use legacy restore')
|
||||
stream = await parentVhd.stream()
|
||||
}
|
||||
|
||||
stream.on('end', disposeOnce)
|
||||
stream.on('close', disposeOnce)
|
||||
stream.on('error', disposeOnce)
|
||||
info('everything is ready, will transfer', stream.length)
|
||||
streams[`${vdiRef}.vhd`] = stream
|
||||
}
|
||||
return {
|
||||
streams,
|
||||
vbds,
|
||||
vdis,
|
||||
version: '1.0.0',
|
||||
vifs,
|
||||
vm: { ...vm, suspend_VDI: vmSnapshot.suspend_VDI },
|
||||
}
|
||||
}
|
||||
|
||||
async #decorateIncrementalVmMetadata() {
|
||||
const { additionnalVmTag, mapVdisSrs, useDifferentialRestore } = this._importIncrementalVmSettings
|
||||
|
||||
const ignoredVdis = new Set(
|
||||
Object.entries(mapVdisSrs)
|
||||
.filter(([_, srUuid]) => srUuid === null)
|
||||
.map(([vdiUuid]) => vdiUuid)
|
||||
)
|
||||
let backup
|
||||
if (useDifferentialRestore) {
|
||||
backup = await this._reuseNearestSnapshot(ignoredVdis)
|
||||
} else {
|
||||
backup = await this._adapter.readIncrementalVmBackup(this._metadata, ignoredVdis)
|
||||
}
|
||||
const xapi = this._xapi
|
||||
|
||||
const cache = new Map()
|
||||
const mapVdisSrRefs = {}
|
||||
if (additionnalVmTag !== undefined) {
|
||||
backup.vm.tags.push(additionnalVmTag)
|
||||
}
|
||||
for (const [vdiUuid, srUuid] of Object.entries(mapVdisSrs)) {
|
||||
mapVdisSrRefs[vdiUuid] = await resolveUuid(xapi, cache, srUuid, 'SR')
|
||||
}
|
||||
const srRef = await resolveUuid(xapi, cache, this._srUuid, 'SR')
|
||||
Object.values(backup.vdis).forEach(vdi => {
|
||||
vdi.SR = mapVdisSrRefs[vdi.uuid] ?? srRef
|
||||
})
|
||||
return backup
|
||||
}
|
||||
|
||||
async run() {
|
||||
const adapter = this._adapter
|
||||
const metadata = this._metadata
|
||||
const isFull = metadata.mode === 'full'
|
||||
|
||||
const sizeContainer = { size: 0 }
|
||||
|
||||
const { newMacAddresses } = this._importIncrementalVmSettings
|
||||
let backup
|
||||
if (isFull) {
|
||||
backup = await adapter.readFullVmBackup(metadata)
|
||||
@@ -28,12 +234,7 @@ export class ImportVmBackup {
|
||||
} else {
|
||||
assert.strictEqual(metadata.mode, 'delta')
|
||||
|
||||
const ignoredVdis = new Set(
|
||||
Object.entries(this._importIncrementalVmSettings.mapVdisSrs)
|
||||
.filter(([_, srUuid]) => srUuid === null)
|
||||
.map(([vdiUuid]) => vdiUuid)
|
||||
)
|
||||
backup = await adapter.readIncrementalVmBackup(metadata, ignoredVdis)
|
||||
backup = await this.#decorateIncrementalVmMetadata()
|
||||
Object.values(backup.streams).forEach(stream => watchStreamSize(stream, sizeContainer))
|
||||
}
|
||||
|
||||
@@ -48,8 +249,7 @@ export class ImportVmBackup {
|
||||
const vmRef = isFull
|
||||
? await xapi.VM_import(backup, srRef)
|
||||
: await importIncrementalVm(backup, await xapi.getRecord('SR', srRef), {
|
||||
...this._importIncrementalVmSettings,
|
||||
detectBase: false,
|
||||
newMacAddresses,
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
@@ -59,6 +259,13 @@ export class ImportVmBackup {
|
||||
vmRef,
|
||||
`${metadata.vm.name_label} (${formatFilenameDate(metadata.timestamp)})`
|
||||
),
|
||||
xapi.call(
|
||||
'VM.set_name_description',
|
||||
vmRef,
|
||||
`Restored on ${formatFilenameDate(+new Date())} from ${adapter._handler._remote.name} -
|
||||
${metadata.vm.name_description}
|
||||
`
|
||||
),
|
||||
])
|
||||
|
||||
return {
|
||||
@@ -69,3 +276,5 @@ export class ImportVmBackup {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
decorateClass(ImportVmBackup, { _reuseNearestSnapshot: defer })
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import find from 'lodash/find.js'
|
||||
import groupBy from 'lodash/groupBy.js'
|
||||
import ignoreErrors from 'promise-toolbox/ignoreErrors'
|
||||
import omit from 'lodash/omit.js'
|
||||
@@ -12,24 +11,18 @@ import { cancelableMap } from './_cancelableMap.mjs'
|
||||
import { Task } from './Task.mjs'
|
||||
import pick from 'lodash/pick.js'
|
||||
|
||||
// in `other_config` of an incrementally replicated VM, contains the UUID of the source VM
|
||||
export const TAG_BASE_DELTA = 'xo:base_delta'
|
||||
|
||||
// in `other_config` of an incrementally replicated VM, contains the UUID of the target SR used for replication
|
||||
//
|
||||
// added after the complete replication
|
||||
export const TAG_BACKUP_SR = 'xo:backup:sr'
|
||||
|
||||
// in other_config of VDIs of an incrementally replicated VM, contains the UUID of the source VDI
|
||||
export const TAG_COPY_SRC = 'xo:copy_of'
|
||||
|
||||
const TAG_BACKUP_SR = 'xo:backup:sr'
|
||||
|
||||
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
|
||||
const resolveUuid = async (xapi, cache, uuid, type) => {
|
||||
if (uuid == null) {
|
||||
return uuid
|
||||
}
|
||||
let ref = cache.get(uuid)
|
||||
if (ref === undefined) {
|
||||
ref = await xapi.call(`${type}.get_by_uuid`, uuid)
|
||||
cache.set(uuid, ref)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
export async function exportIncrementalVm(
|
||||
vm,
|
||||
@@ -147,7 +140,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
$defer,
|
||||
incrementalVm,
|
||||
sr,
|
||||
{ cancelToken = CancelToken.none, detectBase = true, mapVdisSrs = {}, newMacAddresses = false } = {}
|
||||
{ cancelToken = CancelToken.none, newMacAddresses = false } = {}
|
||||
) {
|
||||
const { version } = incrementalVm
|
||||
if (compareVersions(version, '1.0.0') < 0) {
|
||||
@@ -157,35 +150,6 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
const vmRecord = incrementalVm.vm
|
||||
const xapi = sr.$xapi
|
||||
|
||||
let baseVm
|
||||
if (detectBase) {
|
||||
const remoteBaseVmUuid = vmRecord.other_config[TAG_BASE_DELTA]
|
||||
if (remoteBaseVmUuid) {
|
||||
baseVm = find(
|
||||
xapi.objects.all,
|
||||
obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid && obj[TAG_BACKUP_SR] === sr.$id
|
||||
)
|
||||
|
||||
if (!baseVm) {
|
||||
throw new Error(`could not find the base VM (copy of ${remoteBaseVmUuid})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cache = new Map()
|
||||
const mapVdisSrRefs = {}
|
||||
for (const [vdiUuid, srUuid] of Object.entries(mapVdisSrs)) {
|
||||
mapVdisSrRefs[vdiUuid] = await resolveUuid(xapi, cache, srUuid, 'SR')
|
||||
}
|
||||
|
||||
const baseVdis = {}
|
||||
baseVm &&
|
||||
baseVm.$VBDs.forEach(vbd => {
|
||||
const vdi = vbd.$VDI
|
||||
if (vdi !== undefined) {
|
||||
baseVdis[vbd.VDI] = vbd.$VDI
|
||||
}
|
||||
})
|
||||
const vdiRecords = incrementalVm.vdis
|
||||
|
||||
// 0. Create suspend_VDI
|
||||
@@ -197,18 +161,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
vm: pick(vmRecord, 'uuid', 'name_label', 'suspend_VDI'),
|
||||
})
|
||||
} else {
|
||||
suspendVdi = await xapi.getRecord(
|
||||
'VDI',
|
||||
await xapi.VDI_create({
|
||||
...vdi,
|
||||
other_config: {
|
||||
...vdi.other_config,
|
||||
[TAG_BASE_DELTA]: undefined,
|
||||
[TAG_COPY_SRC]: vdi.uuid,
|
||||
},
|
||||
SR: mapVdisSrRefs[vdi.uuid] ?? sr.$ref,
|
||||
})
|
||||
)
|
||||
suspendVdi = await xapi.getRecord('VDI', await xapi.VDI_create(vdi))
|
||||
$defer.onFailure(() => suspendVdi.$destroy())
|
||||
}
|
||||
}
|
||||
@@ -226,10 +179,6 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
ha_always_run: false,
|
||||
is_a_template: false,
|
||||
name_label: '[Importing…] ' + vmRecord.name_label,
|
||||
other_config: {
|
||||
...vmRecord.other_config,
|
||||
[TAG_COPY_SRC]: vmRecord.uuid,
|
||||
},
|
||||
},
|
||||
{
|
||||
bios_strings: vmRecord.bios_strings,
|
||||
@@ -250,14 +199,8 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
const vdi = vdiRecords[vdiRef]
|
||||
let newVdi
|
||||
|
||||
const remoteBaseVdiUuid = detectBase && vdi.other_config[TAG_BASE_DELTA]
|
||||
if (remoteBaseVdiUuid) {
|
||||
const baseVdi = find(baseVdis, vdi => vdi.other_config[TAG_COPY_SRC] === remoteBaseVdiUuid)
|
||||
if (!baseVdi) {
|
||||
throw new Error(`missing base VDI (copy of ${remoteBaseVdiUuid})`)
|
||||
}
|
||||
|
||||
newVdi = await xapi.getRecord('VDI', await baseVdi.$clone())
|
||||
if (vdi.baseVdi !== undefined) {
|
||||
newVdi = await xapi.getRecord('VDI', await vdi.baseVdi.$clone())
|
||||
$defer.onFailure(() => newVdi.$destroy())
|
||||
|
||||
await newVdi.update_other_config(TAG_COPY_SRC, vdi.uuid)
|
||||
@@ -268,18 +211,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
// suspendVDI has already created
|
||||
newVdi = suspendVdi
|
||||
} else {
|
||||
newVdi = await xapi.getRecord(
|
||||
'VDI',
|
||||
await xapi.VDI_create({
|
||||
...vdi,
|
||||
other_config: {
|
||||
...vdi.other_config,
|
||||
[TAG_BASE_DELTA]: undefined,
|
||||
[TAG_COPY_SRC]: vdi.uuid,
|
||||
},
|
||||
SR: mapVdisSrRefs[vdi.uuid] ?? sr.$ref,
|
||||
})
|
||||
)
|
||||
newVdi = await xapi.getRecord('VDI', await xapi.VDI_create(vdi))
|
||||
$defer.onFailure(() => newVdi.$destroy())
|
||||
}
|
||||
|
||||
@@ -318,13 +250,19 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
// Import VDI contents.
|
||||
cancelableMap(cancelToken, Object.entries(newVdis), async (cancelToken, [id, vdi]) => {
|
||||
for (let stream of ensureArray(streams[`${id}.vhd`])) {
|
||||
if (stream === null) {
|
||||
// we restore a backup and reuse completly a local snapshot
|
||||
continue
|
||||
}
|
||||
if (typeof stream === 'function') {
|
||||
stream = await stream()
|
||||
}
|
||||
if (stream.length === undefined) {
|
||||
stream = await createVhdStreamWithLength(stream)
|
||||
}
|
||||
await xapi.setField('VDI', vdi.$ref, 'name_label', `[Importing] ${vdiRecords[id].name_label}`)
|
||||
await vdi.$importContent(stream, { cancelToken, format: 'vhd' })
|
||||
await xapi.setField('VDI', vdi.$ref, 'name_label', vdiRecords[id].name_label)
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import cloneDeep from 'lodash/cloneDeep.js'
|
||||
import mapValues from 'lodash/mapValues.js'
|
||||
|
||||
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
|
||||
|
||||
export function forkDeltaExport(deltaExport) {
|
||||
return Object.create(deltaExport, {
|
||||
streams: {
|
||||
value: mapValues(deltaExport.streams, forkStreamUnpipe),
|
||||
},
|
||||
})
|
||||
const { streams, ...rest } = deltaExport
|
||||
const newMetadata = cloneDeep(rest)
|
||||
newMetadata.streams = mapValues(streams, forkStreamUnpipe)
|
||||
return newMetadata
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { dirname } from 'node:path'
|
||||
|
||||
import { formatFilenameDate } from '../../_filenameDate.mjs'
|
||||
import { getOldEntries } from '../../_getOldEntries.mjs'
|
||||
import { TAG_BASE_DELTA } from '../../_incrementalVm.mjs'
|
||||
import { Task } from '../../Task.mjs'
|
||||
|
||||
import { MixinRemoteWriter } from './_MixinRemoteWriter.mjs'
|
||||
@@ -195,7 +196,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
||||
assert.notStrictEqual(
|
||||
parentPath,
|
||||
undefined,
|
||||
`missing parent of ${id} in ${dirname(path)}, looking for ${vdi.other_config['xo:base_delta']}`
|
||||
`missing parent of ${id} in ${dirname(path)}, looking for ${vdi.other_config[TAG_BASE_DELTA]}`
|
||||
)
|
||||
|
||||
parentPath = parentPath.slice(1) // remove leading slash
|
||||
|
||||
@@ -4,12 +4,13 @@ import { formatDateTime } from '@xen-orchestra/xapi'
|
||||
|
||||
import { formatFilenameDate } from '../../_filenameDate.mjs'
|
||||
import { getOldEntries } from '../../_getOldEntries.mjs'
|
||||
import { importIncrementalVm, TAG_COPY_SRC } from '../../_incrementalVm.mjs'
|
||||
import { importIncrementalVm, TAG_BACKUP_SR, TAG_BASE_DELTA, TAG_COPY_SRC } from '../../_incrementalVm.mjs'
|
||||
import { Task } from '../../Task.mjs'
|
||||
|
||||
import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
|
||||
import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
|
||||
import { listReplicatedVms } from './_listReplicatedVms.mjs'
|
||||
import find from 'lodash/find.js'
|
||||
|
||||
export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
|
||||
async checkBaseVdis(baseUuidToSrcVdi, baseVm) {
|
||||
@@ -81,6 +82,54 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
||||
return asyncMapSettled(this._oldEntries, vm => vm.$destroy())
|
||||
}
|
||||
|
||||
#decorateVmMetadata(backup) {
|
||||
const { _warmMigration } = this._settings
|
||||
const sr = this._sr
|
||||
const xapi = sr.$xapi
|
||||
const vm = backup.vm
|
||||
vm.other_config[TAG_COPY_SRC] = vm.uuid
|
||||
const remoteBaseVmUuid = vm.other_config[TAG_BASE_DELTA]
|
||||
let baseVm
|
||||
if (remoteBaseVmUuid) {
|
||||
baseVm = find(
|
||||
xapi.objects.all,
|
||||
obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid && obj[TAG_BACKUP_SR] === sr.$id
|
||||
)
|
||||
|
||||
if (!baseVm) {
|
||||
throw new Error(`could not find the base VM (copy of ${remoteBaseVmUuid})`)
|
||||
}
|
||||
}
|
||||
const baseVdis = {}
|
||||
baseVm?.$VBDs.forEach(vbd => {
|
||||
const vdi = vbd.$VDI
|
||||
if (vdi !== undefined) {
|
||||
baseVdis[vbd.VDI] = vbd.$VDI
|
||||
}
|
||||
})
|
||||
|
||||
vm.other_config[TAG_COPY_SRC] = vm.uuid
|
||||
if (!_warmMigration) {
|
||||
vm.tags.push('Continuous Replication')
|
||||
}
|
||||
|
||||
Object.values(backup.vdis).forEach(vdi => {
|
||||
vdi.other_config[TAG_COPY_SRC] = vdi.uuid
|
||||
vdi.SR = sr.$ref
|
||||
// vdi.other_config[TAG_BASE_DELTA] is never defined on a suspend vdi
|
||||
if (vdi.other_config[TAG_BASE_DELTA]) {
|
||||
const remoteBaseVdiUuid = vdi.other_config[TAG_BASE_DELTA]
|
||||
const baseVdi = find(baseVdis, vdi => vdi.other_config[TAG_COPY_SRC] === remoteBaseVdiUuid)
|
||||
if (!baseVdi) {
|
||||
throw new Error(`missing base VDI (copy of ${remoteBaseVdiUuid})`)
|
||||
}
|
||||
vdi.baseVdi = baseVdi
|
||||
}
|
||||
})
|
||||
|
||||
return backup
|
||||
}
|
||||
|
||||
async _transfer({ timestamp, deltaExport, sizeContainers, vm }) {
|
||||
const { _warmMigration } = this._settings
|
||||
const sr = this._sr
|
||||
@@ -91,16 +140,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
||||
|
||||
let targetVmRef
|
||||
await Task.run({ name: 'transfer' }, async () => {
|
||||
targetVmRef = await importIncrementalVm(
|
||||
{
|
||||
__proto__: deltaExport,
|
||||
vm: {
|
||||
...deltaExport.vm,
|
||||
tags: _warmMigration ? deltaExport.vm.tags : [...deltaExport.vm.tags, 'Continuous Replication'],
|
||||
},
|
||||
},
|
||||
sr
|
||||
)
|
||||
targetVmRef = await importIncrementalVm(this.#decorateVmMetadata(deltaExport), sr)
|
||||
return {
|
||||
size: Object.values(sizeContainers).reduce((sum, { size }) => sum + size, 0),
|
||||
}
|
||||
@@ -121,13 +161,13 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
||||
)
|
||||
),
|
||||
targetVm.update_other_config({
|
||||
'xo:backup:sr': srUuid,
|
||||
[TAG_BACKUP_SR]: srUuid,
|
||||
|
||||
// these entries need to be added in case of offline backup
|
||||
'xo:backup:datetime': formatDateTime(timestamp),
|
||||
'xo:backup:job': job.id,
|
||||
'xo:backup:schedule': scheduleId,
|
||||
'xo:backup:vm': vm.uuid,
|
||||
[TAG_BASE_DELTA]: vm.uuid,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -96,6 +96,9 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
|
||||
metadata,
|
||||
srUuid,
|
||||
xapi,
|
||||
settings: {
|
||||
additionnalVmTag: 'xo:no-bak=Health Check',
|
||||
},
|
||||
}).run()
|
||||
const restoredVm = xapi.getObject(restoredId)
|
||||
try {
|
||||
|
||||
@@ -58,7 +58,7 @@ export const MixinXapiWriter = (BaseClass = Object) =>
|
||||
)
|
||||
}
|
||||
const healthCheckVm = xapi.getObject(healthCheckVmRef) ?? (await xapi.waitObject(healthCheckVmRef))
|
||||
|
||||
await healthCheckVm.add_tag('xo:no-bak=Health Check')
|
||||
await new HealthCheckVmBackup({
|
||||
restoredVm: healthCheckVm,
|
||||
xapi,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.43.2",
|
||||
"version": "0.44.2",
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
},
|
||||
@@ -23,12 +23,12 @@
|
||||
"@vates/cached-dns.lookup": "^1.0.0",
|
||||
"@vates/compose": "^2.1.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.4",
|
||||
"@vates/disposable": "^0.1.5",
|
||||
"@vates/fuse-vhd": "^2.0.0",
|
||||
"@vates/nbd-client": "^2.0.0",
|
||||
"@vates/nbd-client": "^2.0.1",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.3",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"app-conf": "^2.3.0",
|
||||
@@ -44,8 +44,8 @@
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"tar": "^6.1.15",
|
||||
"uuid": "^9.0.0",
|
||||
"vhd-lib": "^4.6.1",
|
||||
"xen-api": "^1.3.6",
|
||||
"vhd-lib": "^4.7.0",
|
||||
"xen-api": "^2.0.0",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -56,7 +56,7 @@
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^3.3.0"
|
||||
"@xen-orchestra/xapi": "^4.0.0"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict'
|
||||
import { defer } from 'golike-defer'
|
||||
import { readFileSync } from 'fs'
|
||||
import { Ref, Xapi } from 'xen-api'
|
||||
|
||||
const { Ref, Xapi } = require('xen-api')
|
||||
const { defer } = require('golike-defer')
|
||||
|
||||
const pkg = require('./package.json')
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)))
|
||||
|
||||
Xapi.prototype.getVmDisks = async function (vm) {
|
||||
const disks = { __proto__: null }
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/cr-seed-cli",
|
||||
"version": "0.2.0",
|
||||
"version": "1.0.0",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/cr-seed-cli",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
@@ -10,15 +10,15 @@
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"node": ">=10"
|
||||
},
|
||||
"bin": {
|
||||
"xo-cr-seed": "./index.js"
|
||||
"xo-cr-seed": "./index.mjs"
|
||||
},
|
||||
"preferGlobal": true,
|
||||
"dependencies": {
|
||||
"golike-defer": "^0.5.1",
|
||||
"xen-api": "^1.3.6"
|
||||
"xen-api": "^2.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "4.1.2",
|
||||
"version": "4.1.3",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "The File System for Xen Orchestra backups.",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
|
||||
|
||||
@@ -33,7 +33,7 @@ import { pRetry } from 'promise-toolbox'
|
||||
const MAX_PART_SIZE = 1024 * 1024 * 1024 * 5 // 5GB
|
||||
const MAX_PART_NUMBER = 10000
|
||||
const MIN_PART_SIZE = 5 * 1024 * 1024
|
||||
const { warn } = createLogger('xo:fs:s3')
|
||||
const { debug, info, warn } = createLogger('xo:fs:s3')
|
||||
|
||||
export default class S3Handler extends RemoteHandlerAbstract {
|
||||
#bucket
|
||||
@@ -453,10 +453,18 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
if (res.ObjectLockConfiguration?.ObjectLockEnabled === 'Enabled') {
|
||||
// Workaround for https://github.com/aws/aws-sdk-js-v3/issues/2673
|
||||
// will automatically add the contentMD5 header to any upload to S3
|
||||
debug(`Object Lock is enable, enable content md5 header`)
|
||||
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.Code !== 'ObjectLockConfigurationNotFoundError' && error.$metadata.httpStatusCode !== 501) {
|
||||
// maybe the account doesn't have enought privilege to query the object lock configuration
|
||||
// be defensive and apply the md5 just in case
|
||||
if (error.$metadata.httpStatusCode === 403) {
|
||||
info(`s3 user doesnt have enough privilege to check for Object Lock, enable content MD5 header`)
|
||||
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
|
||||
} else if (error.Code === 'ObjectLockConfigurationNotFoundError' || error.$metadata.httpStatusCode === 501) {
|
||||
info(`Object lock is not available or not configured, don't add the content MD5 header`)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,17 @@
|
||||
|
||||
## **next**
|
||||
|
||||
- [VM/Action] Ability to migrate a VM from its view (PR [#7164](https://github.com/vatesfr/xen-orchestra/pull/7164))
|
||||
- Ability to override host address with `master` URL query param (PR [#7187](https://github.com/vatesfr/xen-orchestra/pull/7187))
|
||||
- Added tooltip on CPU provisioning warning icon (PR [#7223](https://github.com/vatesfr/xen-orchestra/pull/7223))
|
||||
|
||||
## **0.1.6** (2023-11-30)
|
||||
|
||||
- Explicit error if users attempt to connect from a slave host (PR [#7110](https://github.com/vatesfr/xen-orchestra/pull/7110))
|
||||
- More compact UI (PR [#7159](https://github.com/vatesfr/xen-orchestra/pull/7159))
|
||||
- Fix dashboard host patches list (PR [#7169](https://github.com/vatesfr/xen-orchestra/pull/7169))
|
||||
- Ability to export selected VMs (PR [#7174](https://github.com/vatesfr/xen-orchestra/pull/7174))
|
||||
- [VM/Action] Ability to export a VM from its view (PR [#7190](https://github.com/vatesfr/xen-orchestra/pull/7190))
|
||||
|
||||
## **0.1.5** (2023-11-07)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/lite",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"scripts": {
|
||||
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
|
||||
"build": "run-p type-check build-only",
|
||||
@@ -10,57 +10,55 @@
|
||||
"test": "yarn run type-check",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"devDependencies": {
|
||||
"@fontsource/poppins": "^5.0.8",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.2.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.0",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.1",
|
||||
"@novnc/novnc": "^1.3.0",
|
||||
"@types/d3-time-format": "^4.0.0",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/marked": "^4.0.8",
|
||||
"@vueuse/core": "^10.1.2",
|
||||
"@vueuse/math": "^10.1.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.5",
|
||||
"@intlify/unplugin-vue-i18n": "^1.5.0",
|
||||
"@limegrass/eslint-plugin-import-alias": "^1.1.0",
|
||||
"@novnc/novnc": "^1.4.0",
|
||||
"@rushstack/eslint-patch": "^1.5.1",
|
||||
"@tsconfig/node18": "^18.2.2",
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/lodash-es": "^4.17.11",
|
||||
"@types/node": "^18.18.9",
|
||||
"@vitejs/plugin-vue": "^4.4.0",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"@vueuse/core": "^10.5.0",
|
||||
"@vueuse/math": "^10.5.0",
|
||||
"complex-matcher": "^0.7.1",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"echarts": "^5.3.3",
|
||||
"echarts": "^5.4.3",
|
||||
"eslint-plugin-vue": "^9.18.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlight.js": "^11.6.0",
|
||||
"human-format": "^1.1.0",
|
||||
"highlight.js": "^11.9.0",
|
||||
"human-format": "^1.2.0",
|
||||
"iterable-backoff": "^0.1.0",
|
||||
"json-rpc-2.0": "^1.3.0",
|
||||
"json5": "^2.2.1",
|
||||
"json-rpc-2.0": "^1.7.0",
|
||||
"json5": "^2.2.3",
|
||||
"limit-concurrency-decorator": "^0.5.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"make-error": "^1.3.6",
|
||||
"marked": "^4.2.12",
|
||||
"pinia": "^2.1.2",
|
||||
"placement.js": "^1.0.0-beta.5",
|
||||
"vue": "^3.3.4",
|
||||
"vue-echarts": "^6.2.3",
|
||||
"vue-i18n": "^9.2.2",
|
||||
"vue-router": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/unplugin-vue-i18n": "^0.10.0",
|
||||
"@limegrass/eslint-plugin-import-alias": "^1.0.5",
|
||||
"@rushstack/eslint-patch": "^1.1.0",
|
||||
"@types/node": "^16.11.41",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.0",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
"marked": "^9.1.5",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.19",
|
||||
"postcss-custom-media": "^9.0.1",
|
||||
"postcss-nested": "^6.0.0",
|
||||
"typescript": "^4.9.3",
|
||||
"vite": "^4.3.8",
|
||||
"vue-tsc": "^1.6.5"
|
||||
"pinia": "^2.1.7",
|
||||
"placement.js": "^1.0.0-beta.5",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-custom-media": "^10.0.2",
|
||||
"postcss-nested": "^6.0.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.5.0",
|
||||
"vue": "^3.3.8",
|
||||
"vue-echarts": "^6.6.1",
|
||||
"vue-i18n": "^9.6.5",
|
||||
"vue-router": "^4.2.5",
|
||||
"vue-tsc": "^1.8.22"
|
||||
},
|
||||
"private": true,
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/lite",
|
||||
@@ -76,6 +74,6 @@
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</RouterLink>
|
||||
<slot />
|
||||
<div class="right">
|
||||
<PoolOverrideWarning as-tooltip />
|
||||
<AccountButton />
|
||||
</div>
|
||||
</header>
|
||||
@@ -19,6 +20,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import AccountButton from "@/components/AccountButton.vue";
|
||||
import PoolOverrideWarning from "@/components/PoolOverrideWarning.vue";
|
||||
import TextLogo from "@/components/TextLogo.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import { useNavigationStore } from "@/stores/navigation.store";
|
||||
@@ -51,6 +53,10 @@ const { trigger: navigationTrigger } = storeToRefs(navigationStore);
|
||||
margin-left: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.warning-not-current-pool {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="app-login form-container">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<img alt="XO Lite" src="../assets/logo-title.svg" />
|
||||
<PoolOverrideWarning />
|
||||
<p v-if="isHostIsSlaveErr(error)" class="error">
|
||||
<UiIcon :icon="faExclamationCircle" />
|
||||
{{ $t("login-only-on-master") }}
|
||||
@@ -45,6 +46,7 @@ import FormCheckbox from "@/components/form/FormCheckbox.vue";
|
||||
import FormInput from "@/components/form/FormInput.vue";
|
||||
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
|
||||
import LoginError from "@/components/LoginError.vue";
|
||||
import PoolOverrideWarning from "@/components/PoolOverrideWarning.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import type { XenApiError } from "@/libs/xen-api/xen-api.types";
|
||||
|
||||
@@ -44,9 +44,15 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const sortedPatches = computed(() =>
|
||||
[...props.patches].sort(
|
||||
(patch1, patch2) => patch1.changelog.date - patch2.changelog.date
|
||||
)
|
||||
[...props.patches].sort((patch1, patch2) => {
|
||||
if (patch1.changelog == null) {
|
||||
return 1;
|
||||
} else if (patch2.changelog == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return patch1.changelog.date - patch2.changelog.date;
|
||||
})
|
||||
);
|
||||
|
||||
const { isDesktop } = useUiStore();
|
||||
|
||||
59
@xen-orchestra/lite/src/components/PoolOverrideWarning.vue
Normal file
59
@xen-orchestra/lite/src/components/PoolOverrideWarning.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="xenApi.isPoolOverridden"
|
||||
class="warning-not-current-pool"
|
||||
@click="xenApi.resetPoolMasterIp"
|
||||
v-tooltip="
|
||||
asTooltip && {
|
||||
placement: 'right',
|
||||
content: `
|
||||
${$t('you-are-currently-on', [masterSessionStorage])}.
|
||||
${$t('click-to-return-default-pool')}
|
||||
`,
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="wrapper">
|
||||
<UiIcon :icon="faWarning" />
|
||||
<p v-if="!asTooltip">
|
||||
<i18n-t keypath="you-are-currently-on">
|
||||
<strong>{{ masterSessionStorage }}</strong>
|
||||
</i18n-t>
|
||||
<br />
|
||||
{{ $t("click-to-return-default-pool") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useSessionStorage } from "@vueuse/core";
|
||||
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
|
||||
defineProps<{
|
||||
asTooltip?: boolean;
|
||||
}>();
|
||||
|
||||
const xenApi = useXenApiStore();
|
||||
const masterSessionStorage = useSessionStorage("master", null);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.warning-not-current-pool {
|
||||
color: var(--color-orange-world-base);
|
||||
cursor: pointer;
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
margin: auto 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -125,7 +125,9 @@ const emit = defineEmits<{
|
||||
const model = useVModel(props, "modelValue", emit);
|
||||
|
||||
const openRawValueModal = (code: string) =>
|
||||
useModal(() => import("@/components/CodeHighlight.vue"), { code });
|
||||
useModal(() => import("@/components/modals/CodeHighlightModal.vue"), {
|
||||
code,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
80
@xen-orchestra/lite/src/components/form/FormByteSize.vue
Normal file
80
@xen-orchestra/lite/src/components/form/FormByteSize.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<FormInputGroup>
|
||||
<FormNumber v-model="sizeInput" :max-decimals="3" />
|
||||
<FormSelect v-model="prefixInput">
|
||||
<option
|
||||
v-for="currentPrefix in availablePrefixes"
|
||||
:key="currentPrefix"
|
||||
:value="currentPrefix"
|
||||
>
|
||||
{{ currentPrefix }}B
|
||||
</option>
|
||||
</FormSelect>
|
||||
</FormInputGroup>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FormInputGroup from "@/components/form/FormInputGroup.vue";
|
||||
import FormNumber from "@/components/form/FormNumber.vue";
|
||||
import FormSelect from "@/components/form/FormSelect.vue";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import humanFormat, { type Prefix } from "human-format";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number | undefined;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: number): number;
|
||||
}>();
|
||||
|
||||
const availablePrefixes: Prefix<"binary">[] = ["Ki", "Mi", "Gi"];
|
||||
|
||||
const model = useVModel(props, "modelValue", emit, {
|
||||
shouldEmit: (value) => value !== props.modelValue,
|
||||
});
|
||||
|
||||
const sizeInput = ref();
|
||||
const prefixInput = ref();
|
||||
|
||||
const scale = humanFormat.Scale.create(availablePrefixes, 1024, 1);
|
||||
|
||||
watch([sizeInput, prefixInput], ([newSize, newPrefix]) => {
|
||||
if (newSize === "" || newSize === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = humanFormat.parse(`${newSize || 0} ${newPrefix || "Ki"}`, {
|
||||
scale,
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue === undefined) {
|
||||
sizeInput.value = undefined;
|
||||
|
||||
if (prefixInput.value === undefined) {
|
||||
prefixInput.value = availablePrefixes[0];
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { value, prefix } = humanFormat.raw(newValue, {
|
||||
scale,
|
||||
prefix: prefixInput.value,
|
||||
});
|
||||
console.log(value);
|
||||
|
||||
sizeInput.value = value;
|
||||
|
||||
if (value !== 0) {
|
||||
prefixInput.value = prefix;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
77
@xen-orchestra/lite/src/components/form/FormNumber.vue
Normal file
77
@xen-orchestra/lite/src/components/form/FormNumber.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<FormInput v-model="localValue" inputmode="decimal" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FormInput from "@/components/form/FormInput.vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number | undefined;
|
||||
maxDecimals?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: number | undefined): void;
|
||||
}>();
|
||||
|
||||
const localValue = ref("");
|
||||
|
||||
const hasTrailingDot = ref(false);
|
||||
|
||||
const cleaningRegex = computed(() => {
|
||||
if (props.maxDecimals === undefined) {
|
||||
// Any number with optional decimal part
|
||||
return /(\d*\.?\d*)/;
|
||||
}
|
||||
|
||||
if (props.maxDecimals > 0) {
|
||||
// Numbers with up to `props.maxDecimals` decimal places
|
||||
return new RegExp(`(\\d*\\.?\\d{0,${props.maxDecimals}})`);
|
||||
}
|
||||
|
||||
// Integer numbers only
|
||||
return /(\d*)/;
|
||||
});
|
||||
|
||||
watch(
|
||||
localValue,
|
||||
(newLocalValue) => {
|
||||
const cleanValue =
|
||||
localValue.value
|
||||
.replace(",", ".")
|
||||
.replace(/[^0-9.]/g, "")
|
||||
.match(cleaningRegex.value)?.[0] ?? "";
|
||||
|
||||
hasTrailingDot.value = cleanValue.endsWith(".");
|
||||
|
||||
if (cleanValue !== newLocalValue) {
|
||||
localValue.value = cleanValue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (newLocalValue === "") {
|
||||
emit("update:modelValue", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedValue = parseFloat(cleanValue);
|
||||
|
||||
emit(
|
||||
"update:modelValue",
|
||||
Number.isNaN(parsedValue) ? undefined : parsedValue
|
||||
);
|
||||
},
|
||||
{ flush: "post" }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newModelValue) => {
|
||||
localValue.value = `${newModelValue?.toString() ?? ""}${
|
||||
hasTrailingDot.value ? "." : ""
|
||||
}`;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -12,6 +12,6 @@ import BasicModalLayout from "@/components/ui/modals/layouts/BasicModalLayout.vu
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
|
||||
defineProps<{
|
||||
code: string;
|
||||
code: any;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<UiModal>
|
||||
<FormModalLayout :icon="faDisplay">
|
||||
<template #title>
|
||||
{{ $t("export-n-vms-manually", { n: labelWithUrl.length }) }}
|
||||
</template>
|
||||
|
||||
<p>
|
||||
{{ $t("export-vms-manually-information") }}
|
||||
</p>
|
||||
<ul class="list">
|
||||
<li v-for="({ url, label }, index) in labelWithUrl" :key="index">
|
||||
<a :href="url.href" target="_blank">
|
||||
{{ label }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template #buttons>
|
||||
<ModalDeclineButton />
|
||||
</template>
|
||||
</FormModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
|
||||
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
blockedUrls: URL[];
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef } = useVmCollection();
|
||||
|
||||
const labelWithUrl = computed(() =>
|
||||
props.blockedUrls.map((url) => {
|
||||
const ref = url.searchParams.get("ref") as XenApiVm["$ref"];
|
||||
return {
|
||||
url: url,
|
||||
label: getByOpaqueRef(ref)?.name_label ?? ref,
|
||||
};
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.list {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
65
@xen-orchestra/lite/src/components/modals/VmExportModal.vue
Normal file
65
@xen-orchestra/lite/src/components/modals/VmExportModal.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<UiModal @submit.prevent="handleSubmit">
|
||||
<FormModalLayout :icon="faDisplay">
|
||||
<template #title>
|
||||
{{ $t("export-n-vms", { n: vmRefs.length }) }}
|
||||
</template>
|
||||
|
||||
<FormInputWrapper
|
||||
light
|
||||
learn-more-url="https://xcp-ng.org/blog/2018/12/19/zstd-compression-for-xcp-ng/"
|
||||
:label="$t('select-compression')"
|
||||
>
|
||||
<FormSelect v-model="compressionType">
|
||||
<option
|
||||
v-for="key in Object.keys(VM_COMPRESSION_TYPE)"
|
||||
:key="key"
|
||||
:value="
|
||||
VM_COMPRESSION_TYPE[key as keyof typeof VM_COMPRESSION_TYPE]
|
||||
"
|
||||
>
|
||||
{{ $t(key.toLowerCase()) }}
|
||||
</option>
|
||||
</FormSelect>
|
||||
</FormInputWrapper>
|
||||
|
||||
<template #buttons>
|
||||
<ModalDeclineButton />
|
||||
<ModalApproveButton>
|
||||
{{ $t("export-n-vms", { n: vmRefs.length }) }}
|
||||
</ModalApproveButton>
|
||||
</template>
|
||||
</FormModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
|
||||
import { inject, ref } from "vue";
|
||||
|
||||
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
|
||||
import FormSelect from "@/components/form/FormSelect.vue";
|
||||
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
|
||||
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
|
||||
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import { VM_COMPRESSION_TYPE } from "@/libs/xen-api/xen-api.enums";
|
||||
|
||||
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
|
||||
const props = defineProps<{
|
||||
vmRefs: XenApiVm["$ref"][];
|
||||
}>();
|
||||
|
||||
const modal = inject(IK_MODAL)!;
|
||||
|
||||
const compressionType = ref(VM_COMPRESSION_TYPE.DISABLED);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const xenApi = useXenApiStore().getXapi();
|
||||
xenApi.vm.export(props.vmRefs, compressionType.value);
|
||||
modal.approve();
|
||||
};
|
||||
</script>
|
||||
@@ -3,8 +3,14 @@
|
||||
<UiCardTitle>
|
||||
{{ $t("cpu-provisioning") }}
|
||||
<template v-if="!hasError" #right>
|
||||
<!-- TODO: add a tooltip for the warning icon -->
|
||||
<UiStatusIcon v-if="state !== 'success'" :state="state" />
|
||||
<UiStatusIcon
|
||||
v-if="state !== 'success'"
|
||||
v-tooltip="{
|
||||
content: $t('cpu-provisioning-warning'),
|
||||
placement: 'left',
|
||||
}"
|
||||
:state="state"
|
||||
/>
|
||||
</template>
|
||||
</UiCardTitle>
|
||||
<NoDataError v-if="hasError" />
|
||||
@@ -37,11 +43,12 @@ import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardFooter from "@/components/ui/UiCardFooter.vue";
|
||||
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { useVmMetricsCollection } from "@/stores/xen-api/vm-metrics.store";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
import { percent } from "@/libs/utils";
|
||||
import { VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { useVmMetricsCollection } from "@/stores/xen-api/vm-metrics.store";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { logicAnd } from "@vueuse/math";
|
||||
import { computed } from "vue";
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useContext } from "@/composables/context.composable";
|
||||
import { ColorContext, DisabledContext } from "@/context";
|
||||
import type { Color } from "@/types";
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
import { useMagicKeys, whenever } from "@vueuse/core/index";
|
||||
import { useMagicKeys, whenever } from "@vueuse/core";
|
||||
import { inject } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,51 +1,53 @@
|
||||
<template>
|
||||
<MenuItem :icon="faFileExport">
|
||||
{{ $t("export") }}
|
||||
<template #submenu>
|
||||
<MenuItem
|
||||
v-tooltip="{ content: $t('coming-soon'), placement: 'left' }"
|
||||
:icon="faDisplay"
|
||||
>
|
||||
{{ $t("export-vms") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:icon="faCode"
|
||||
@click="
|
||||
exportVmsAsJsonFile(vms, `vms_${new Date().toISOString()}.json`)
|
||||
"
|
||||
>
|
||||
{{ $t("export-table-to", { type: ".json" }) }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:icon="faFileCsv"
|
||||
@click="exportVmsAsCsvFile(vms, `vms_${new Date().toISOString()}.csv`)"
|
||||
>
|
||||
{{ $t("export-table-to", { type: ".csv" }) }}
|
||||
</MenuItem>
|
||||
</template>
|
||||
<MenuItem
|
||||
v-tooltip="
|
||||
vmRefs.length > 0 &&
|
||||
!isSomeExportable &&
|
||||
$t(isSingleAction ? 'vm-is-running' : 'no-selected-vm-can-be-exported')
|
||||
"
|
||||
:icon="faDisplay"
|
||||
:disabled="isDisabled"
|
||||
@click="openModal"
|
||||
>
|
||||
{{ $t(isSingleAction ? "export-vm" : "export-vms") }}
|
||||
</MenuItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { computed } from "vue";
|
||||
import { exportVmsAsCsvFile, exportVmsAsJsonFile } from "@/libs/vm";
|
||||
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import {
|
||||
faCode,
|
||||
faDisplay,
|
||||
faFileCsv,
|
||||
faFileExport,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { DisabledContext } from "@/context";
|
||||
import { useContext } from "@/composables/context.composable";
|
||||
import { useModal } from "@/composables/modal.composable";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
|
||||
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
|
||||
const props = defineProps<{
|
||||
vmRefs: XenApiVm["$ref"][];
|
||||
isSingleAction?: boolean;
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef: getVm } = useVmCollection();
|
||||
const vms = computed(() =>
|
||||
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
|
||||
const { getByOpaqueRefs, areSomeOperationAllowed } = useVmCollection();
|
||||
|
||||
const isParentDisabled = useContext(DisabledContext);
|
||||
|
||||
const isSomeExportable = computed(() =>
|
||||
getByOpaqueRefs(props.vmRefs).some((vm) =>
|
||||
areSomeOperationAllowed(vm, VM_OPERATION.EXPORT)
|
||||
)
|
||||
);
|
||||
|
||||
const isDisabled = computed(
|
||||
() => isParentDisabled.value || !isSomeExportable.value
|
||||
);
|
||||
|
||||
const openModal = () => {
|
||||
useModal(() => import("@/components/modals/VmExportModal.vue"), {
|
||||
vmRefs: props.vmRefs,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<MenuItem :icon="faFileExport">
|
||||
{{ $t("export") }}
|
||||
<template #submenu>
|
||||
<VmActionExportItem :vmRefs="vmRefs" />
|
||||
<MenuItem
|
||||
:icon="faCode"
|
||||
@click="
|
||||
exportVmsAsJsonFile(vms, `vms_${new Date().toISOString()}.json`)
|
||||
"
|
||||
>
|
||||
{{ $t("export-table-to", { type: ".json" }) }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:icon="faFileCsv"
|
||||
@click="exportVmsAsCsvFile(vms, `vms_${new Date().toISOString()}.csv`)"
|
||||
>
|
||||
{{ $t("export-table-to", { type: ".csv" }) }}
|
||||
</MenuItem>
|
||||
</template>
|
||||
</MenuItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { computed } from "vue";
|
||||
import { exportVmsAsCsvFile, exportVmsAsJsonFile } from "@/libs/vm";
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import VmActionExportItem from "@/components/vm/VmActionItems/VmActionExportItem.vue";
|
||||
import {
|
||||
faCode,
|
||||
faFileCsv,
|
||||
faFileExport,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
|
||||
const props = defineProps<{
|
||||
vmRefs: XenApiVm["$ref"][];
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef: getVm } = useVmCollection();
|
||||
const vms = computed(() =>
|
||||
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
|
||||
);
|
||||
</script>
|
||||
@@ -3,7 +3,11 @@
|
||||
v-tooltip="
|
||||
selectedRefs.length > 0 &&
|
||||
!isMigratable &&
|
||||
$t('no-selected-vm-can-be-migrated')
|
||||
$t(
|
||||
isSingleAction
|
||||
? 'this-vm-cant-be-migrated'
|
||||
: 'no-selected-vm-can-be-migrated'
|
||||
)
|
||||
"
|
||||
:busy="isMigrating"
|
||||
:disabled="isParentDisabled || !isMigratable"
|
||||
@@ -28,6 +32,7 @@ import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
selectedRefs: XenApiVm["$ref"][];
|
||||
isSingleAction?: boolean;
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRefs, isOperationPending, areSomeOperationAllowed } =
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
/>
|
||||
</template>
|
||||
<VmActionCopyItem :selected-refs="[vm.$ref]" is-single-action />
|
||||
<VmActionExportItem :vm-refs="[vm.$ref]" is-single-action />
|
||||
<VmActionSnapshotItem :vm-refs="[vm.$ref]" />
|
||||
<VmActionMigrateItem :selected-refs="[vm.$ref]" is-single-action />
|
||||
</AppMenu>
|
||||
</template>
|
||||
</TitleBar>
|
||||
@@ -37,9 +39,11 @@ import AppMenu from "@/components/menu/AppMenu.vue";
|
||||
import TitleBar from "@/components/TitleBar.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import VmActionMigrateItem from "@/components/vm/VmActionItems/VmActionMigrateItem.vue";
|
||||
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
|
||||
import VmActionSnapshotItem from "@/components/vm/VmActionItems/VmActionSnapshotItem.vue";
|
||||
import VmActionCopyItem from "@/components/vm/VmActionItems/VmActionCopyItem.vue";
|
||||
import VmActionExportItem from "@/components/vm/VmActionItems/VmActionExportItem.vue";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{{ $t("edit-config") }}
|
||||
</MenuItem>
|
||||
<VmActionSnapshotItem :vm-refs="selectedRefs" />
|
||||
<VmActionExportItem :vm-refs="selectedRefs" />
|
||||
<VmActionExportItems :vm-refs="selectedRefs" />
|
||||
<VmActionDeleteItem :vm-refs="selectedRefs" />
|
||||
</AppMenu>
|
||||
</template>
|
||||
@@ -32,7 +32,7 @@ import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import VmActionCopyItem from "@/components/vm/VmActionItems/VmActionCopyItem.vue";
|
||||
import VmActionDeleteItem from "@/components/vm/VmActionItems/VmActionDeleteItem.vue";
|
||||
import VmActionExportItem from "@/components/vm/VmActionItems/VmActionExportItem.vue";
|
||||
import VmActionExportItems from "@/components/vm/VmActionItems/VmActionExportItems.vue";
|
||||
import VmActionMigrateItem from "@/components/vm/VmActionItems/VmActionMigrateItem.vue";
|
||||
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
|
||||
import VmActionSnapshotItem from "@/components/vm/VmActionItems/VmActionSnapshotItem.vue";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HighlightResult, Language } from "highlight.js";
|
||||
import type { HighlightResult } from "highlight.js";
|
||||
import HLJS from "highlight.js/lib/core";
|
||||
import cssLang from "highlight.js/lib/languages/css";
|
||||
import jsonLang from "highlight.js/lib/languages/json";
|
||||
@@ -19,10 +19,6 @@ export const highlight: (
|
||||
ignoreIllegals?: boolean
|
||||
) => HighlightResult = HLJS.highlight;
|
||||
|
||||
export const getLanguage: (
|
||||
languageName: AcceptedLanguage
|
||||
) => Language | undefined = HLJS.getLanguage;
|
||||
|
||||
export type AcceptedLanguage =
|
||||
| "xml"
|
||||
| "css"
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
type AcceptedLanguage,
|
||||
getLanguage,
|
||||
highlight,
|
||||
} from "@/libs/highlight";
|
||||
import { type AcceptedLanguage, highlight } from "@/libs/highlight";
|
||||
import HLJS from "highlight.js/lib/core";
|
||||
import { marked } from "marked";
|
||||
|
||||
enum VUE_TAG {
|
||||
@@ -11,15 +8,26 @@ enum VUE_TAG {
|
||||
STYLE = "vue-style",
|
||||
}
|
||||
|
||||
function extractLang(lang: string | undefined): AcceptedLanguage | VUE_TAG {
|
||||
if (lang === undefined) {
|
||||
return "plaintext";
|
||||
}
|
||||
|
||||
if (Object.values(VUE_TAG).includes(lang as VUE_TAG)) {
|
||||
return lang as VUE_TAG;
|
||||
}
|
||||
|
||||
if (HLJS.getLanguage(lang) !== undefined) {
|
||||
return lang as AcceptedLanguage;
|
||||
}
|
||||
|
||||
return "plaintext";
|
||||
}
|
||||
|
||||
marked.use({
|
||||
renderer: {
|
||||
code(str: string, lang: AcceptedLanguage) {
|
||||
const code = customHighlight(
|
||||
str,
|
||||
Object.values(VUE_TAG).includes(lang as VUE_TAG) || getLanguage(lang)
|
||||
? lang
|
||||
: "plaintext"
|
||||
);
|
||||
code(str, lang) {
|
||||
const code = customHighlight(str, extractLang(lang));
|
||||
return `<pre class="hljs"><button class="copy-button" type="button">Copy</button><code class="hljs-code">${code}</code></pre>`;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -82,8 +82,8 @@ const testMetric = (
|
||||
typeof test === "string"
|
||||
? test === type
|
||||
: typeof test === "function"
|
||||
? test(type)
|
||||
: test.exec(type);
|
||||
? test(type)
|
||||
: test.exec(type);
|
||||
|
||||
const findMetric = (metrics: any, metricType: string) => {
|
||||
let testResult;
|
||||
|
||||
@@ -491,3 +491,9 @@ export enum CERTIFICATE_TYPE {
|
||||
HOST = "host",
|
||||
HOST_INTERNAL = "host_internal",
|
||||
}
|
||||
|
||||
export enum VM_COMPRESSION_TYPE {
|
||||
DISABLED = "false",
|
||||
GZIP = "true",
|
||||
ZSTD = "zstd",
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
import { buildXoObject, typeToRawType } from "@/libs/xen-api/xen-api.utils";
|
||||
import { JSONRPCClient } from "json-rpc-2.0";
|
||||
import { castArray } from "lodash-es";
|
||||
import type { VM_COMPRESSION_TYPE } from "@/libs/xen-api/xen-api.enums";
|
||||
import { useModal } from "@/composables/modal.composable";
|
||||
|
||||
export default class XenApi {
|
||||
private client: JSONRPCClient;
|
||||
@@ -27,10 +29,12 @@ export default class XenApi {
|
||||
Set<(...args: any[]) => void>
|
||||
>();
|
||||
private fromToken: string | undefined;
|
||||
private hostUrl: string;
|
||||
|
||||
constructor(hostUrl: string) {
|
||||
this.hostUrl = hostUrl;
|
||||
this.client = new JSONRPCClient(async (request) => {
|
||||
const response = await fetch(`${hostUrl}/jsonrpc`, {
|
||||
const response = await fetch(`${this.hostUrl}/jsonrpc`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
@@ -380,6 +384,36 @@ export default class XenApi {
|
||||
)
|
||||
);
|
||||
},
|
||||
export: (vmRefs: VmRefs, compression: VM_COMPRESSION_TYPE) => {
|
||||
const blockedUrls: URL[] = [];
|
||||
|
||||
castArray(vmRefs).forEach((vmRef) => {
|
||||
const url = new URL(this.hostUrl);
|
||||
url.pathname = "/export/";
|
||||
url.search = new URLSearchParams({
|
||||
session_id: this.sessionId!,
|
||||
ref: vmRef,
|
||||
use_compression: compression,
|
||||
}).toString();
|
||||
|
||||
const _window = window.open(url.href, "_blank");
|
||||
if (_window === null) {
|
||||
blockedUrls.push(url);
|
||||
} else {
|
||||
URL.revokeObjectURL(url.toString());
|
||||
}
|
||||
});
|
||||
|
||||
if (blockedUrls.length > 0) {
|
||||
const { onClose } = useModal(
|
||||
() => import("@/components/modals/VmExportBlockedUrlsModal.vue"),
|
||||
{ blockedUrls }
|
||||
);
|
||||
onClose(() =>
|
||||
blockedUrls.forEach((url) => URL.revokeObjectURL(url.toString()))
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"cancel": "Cancel",
|
||||
"change-state": "Change state",
|
||||
"click-to-display-alarms": "Click to display alarms:",
|
||||
"click-to-return-default-pool": "Click here to return to the default pool",
|
||||
"close": "Close",
|
||||
"coming-soon": "Coming soon!",
|
||||
"community": "Community",
|
||||
@@ -37,12 +38,14 @@
|
||||
"console-unavailable": "Console unavailable",
|
||||
"copy": "Copy",
|
||||
"cpu-provisioning": "CPU provisioning",
|
||||
"cpu-provisioning-warning": "The number of vCPUs allocated exceeds the number of physical CPUs available. System performance could be affected",
|
||||
"cpu-usage": "CPU usage",
|
||||
"dashboard": "Dashboard",
|
||||
"delete": "Delete",
|
||||
"delete-vms": "Delete 1 VM | Delete {n} VMs",
|
||||
"descending": "descending",
|
||||
"description": "Description",
|
||||
"disabled": "Disabled",
|
||||
"display": "Display",
|
||||
"do-you-have-needs": "You have needs and/or expectations? Let us know",
|
||||
"documentation": "Documentation",
|
||||
@@ -51,8 +54,12 @@
|
||||
"error-no-data": "Error, can't collect data.",
|
||||
"error-occurred": "An error has occurred",
|
||||
"export": "Export",
|
||||
"export-n-vms": "Export 1 VM | Export {n} VMs",
|
||||
"export-n-vms-manually": "Export 1 VM manually | Export {n} VMs manually",
|
||||
"export-table-to": "Export table to {type}",
|
||||
"export-vm": "Export VM",
|
||||
"export-vms": "Export VMs",
|
||||
"export-vms-manually-information": "Some VM exports were not able to start automatically, probably due to your browser settings. To export them, you should click on each one. (Alternatively, copy the link as well.)",
|
||||
"fetching-fresh-data": "Fetching fresh data",
|
||||
"filter": {
|
||||
"comparison": {
|
||||
@@ -78,6 +85,7 @@
|
||||
"fullscreen": "Fullscreen",
|
||||
"fullscreen-leave": "Leave fullscreen",
|
||||
"go-back": "Go back",
|
||||
"gzip": "gzip",
|
||||
"here": "Here",
|
||||
"hosts": "Hosts",
|
||||
"keep-me-logged": "Keep me logged in",
|
||||
@@ -104,6 +112,7 @@
|
||||
"news": "News",
|
||||
"news-name": "{name} news",
|
||||
"no-alarm-triggered": "No alarm triggered",
|
||||
"no-selected-vm-can-be-exported": "No selected VM can be exported",
|
||||
"no-selected-vm-can-be-migrated": "No selected VM can be migrated",
|
||||
"no-tasks": "No tasks",
|
||||
"not-found": "Not found",
|
||||
@@ -139,6 +148,7 @@
|
||||
},
|
||||
"resume": "Resume",
|
||||
"save": "Save",
|
||||
"select-compression": "Select a compression",
|
||||
"select-destination-host": "Select a destination host",
|
||||
"selected-vms-in-execution": "Some selected VMs are running",
|
||||
"send-ctrl-alt-del": "Send Ctrl+Alt+Del",
|
||||
@@ -169,6 +179,7 @@
|
||||
"theme-auto": "Auto",
|
||||
"theme-dark": "Dark",
|
||||
"theme-light": "Light",
|
||||
"this-vm-cant-be-migrated": "This VM can't be migrated",
|
||||
"top-#": "Top {n}",
|
||||
"total-cpus": "Total CPUs",
|
||||
"total-free": "Total free",
|
||||
@@ -180,5 +191,7 @@
|
||||
"version": "Version",
|
||||
"vm-is-running": "The VM is running",
|
||||
"vms": "VMs",
|
||||
"xo-lite-under-construction": "XOLite is under construction"
|
||||
"xo-lite-under-construction": "XOLite is under construction",
|
||||
"you-are-currently-on": "You are currently on: {0}",
|
||||
"zstd": "zstd"
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"cancel": "Annuler",
|
||||
"change-state": "Changer l'état",
|
||||
"click-to-display-alarms": "Cliquer pour afficher les alarmes :",
|
||||
"click-to-return-default-pool": "Cliquer ici pour revenir au pool par défaut",
|
||||
"close": "Fermer",
|
||||
"coming-soon": "Bientôt disponible !",
|
||||
"community": "Communauté",
|
||||
@@ -37,12 +38,14 @@
|
||||
"console-unavailable": "Console indisponible",
|
||||
"copy": "Copier",
|
||||
"cpu-provisioning": "Provisionnement CPU",
|
||||
"cpu-provisioning-warning": "Le nombre de vCPU alloués dépasse le nombre de CPU physique disponible. Les performances du système pourraient être affectées",
|
||||
"cpu-usage": "Utilisation CPU",
|
||||
"dashboard": "Tableau de bord",
|
||||
"delete": "Supprimer",
|
||||
"delete-vms": "Supprimer 1 VM | Supprimer {n} VMs",
|
||||
"descending": "descendant",
|
||||
"description": "Description",
|
||||
"disabled": "Désactivé",
|
||||
"display": "Affichage",
|
||||
"do-you-have-needs": "Vous avez des besoins et/ou des attentes ? Faites le nous savoir",
|
||||
"documentation": "Documentation",
|
||||
@@ -51,8 +54,12 @@
|
||||
"error-no-data": "Erreur, impossible de collecter les données.",
|
||||
"error-occurred": "Une erreur est survenue",
|
||||
"export": "Exporter",
|
||||
"export-n-vms": "Exporter 1 VM | Exporter {n} VMs",
|
||||
"export-n-vms-manually": "Exporter 1 VM manuellement | Exporter {n} VMs manuellement",
|
||||
"export-table-to": "Exporter le tableau en {type}",
|
||||
"export-vm": "Exporter la VM",
|
||||
"export-vms": "Exporter les VMs",
|
||||
"export-vms-manually-information": "Certaines exportations de VMs n'ont pas pu démarrer automatiquement, peut-être en raison des paramètres du navigateur. Pour les exporter, vous devrez cliquer sur chacune d'entre elles. (Ou copier le lien.)",
|
||||
"fetching-fresh-data": "Récupération de données à jour",
|
||||
"filter": {
|
||||
"comparison": {
|
||||
@@ -78,6 +85,7 @@
|
||||
"fullscreen": "Plein écran",
|
||||
"fullscreen-leave": "Quitter plein écran",
|
||||
"go-back": "Revenir en arrière",
|
||||
"gzip": "gzip",
|
||||
"here": "Ici",
|
||||
"hosts": "Hôtes",
|
||||
"keep-me-logged": "Rester connecté",
|
||||
@@ -104,6 +112,7 @@
|
||||
"news": "Actualités",
|
||||
"news-name": "Actualités {name}",
|
||||
"no-alarm-triggered": "Aucune alarme déclenchée",
|
||||
"no-selected-vm-can-be-exported": "Aucune VM sélectionnée ne peut être exportée",
|
||||
"no-selected-vm-can-be-migrated": "Aucune VM sélectionnée ne peut être migrée",
|
||||
"no-tasks": "Aucune tâche",
|
||||
"not-found": "Non trouvé",
|
||||
@@ -139,6 +148,7 @@
|
||||
},
|
||||
"resume": "Reprendre",
|
||||
"save": "Enregistrer",
|
||||
"select-compression": "Sélectionnez une compression",
|
||||
"select-destination-host": "Sélectionnez un hôte de destination",
|
||||
"selected-vms-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
|
||||
"send-ctrl-alt-del": "Envoyer Ctrl+Alt+Suppr",
|
||||
@@ -169,6 +179,7 @@
|
||||
"theme-auto": "Auto",
|
||||
"theme-dark": "Sombre",
|
||||
"theme-light": "Clair",
|
||||
"this-vm-cant-be-migrated": "Cette VM ne peut pas être migrée",
|
||||
"top-#": "Top {n}",
|
||||
"total-cpus": "Total CPUs",
|
||||
"total-free": "Total libre",
|
||||
@@ -180,5 +191,7 @@
|
||||
"version": "Version",
|
||||
"vm-is-running": "La VM est en cours d'exécution",
|
||||
"vms": "VMs",
|
||||
"xo-lite-under-construction": "XOLite est en construction"
|
||||
"xo-lite-under-construction": "XOLite est en construction",
|
||||
"you-are-currently-on": "Vous êtes actuellement sur : {0}",
|
||||
"zstd": "zstd"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import XapiStats from "@/libs/xapi-stats";
|
||||
import XenApi from "@/libs/xen-api/xen-api";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { useLocalStorage, useSessionStorage, whenever } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref, watchEffect } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const HOST_URL = import.meta.env.PROD
|
||||
? window.origin
|
||||
@@ -15,7 +17,27 @@ enum STATUS {
|
||||
}
|
||||
|
||||
export const useXenApiStore = defineStore("xen-api", () => {
|
||||
const xenApi = new XenApi(HOST_URL);
|
||||
// undefined not correctly handled. See https://github.com/vueuse/vueuse/issues/3595
|
||||
const masterSessionStorage = useSessionStorage<null | string>("master", null);
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
whenever(
|
||||
() => route.query.master,
|
||||
async (newMaster) => {
|
||||
masterSessionStorage.value = newMaster as string;
|
||||
await router.replace({ query: { ...route.query, master: undefined } });
|
||||
window.location.reload();
|
||||
}
|
||||
);
|
||||
|
||||
const hostUrl = new URL(HOST_URL);
|
||||
if (masterSessionStorage.value !== null) {
|
||||
hostUrl.hostname = masterSessionStorage.value;
|
||||
}
|
||||
|
||||
const isPoolOverridden = hostUrl.origin !== new URL(HOST_URL).origin;
|
||||
const xenApi = new XenApi(hostUrl.origin);
|
||||
const xapiStats = new XapiStats(xenApi);
|
||||
const storedSessionId = useLocalStorage<string | undefined>(
|
||||
"sessionId",
|
||||
@@ -75,14 +97,21 @@ export const useXenApiStore = defineStore("xen-api", () => {
|
||||
status.value = STATUS.DISCONNECTED;
|
||||
}
|
||||
|
||||
function resetPoolMasterIp() {
|
||||
masterSessionStorage.value = null;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isPoolOverridden,
|
||||
connect,
|
||||
reconnect,
|
||||
disconnect,
|
||||
getXapi,
|
||||
getXapiStats,
|
||||
currentSessionId,
|
||||
resetPoolMasterIp,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
```vue-template
|
||||
<FormByteSize v-model="size" />
|
||||
```
|
||||
|
||||
```vue-script
|
||||
const size = ref(0);
|
||||
```
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
v-slot="{ properties }"
|
||||
:params="[
|
||||
model().type('number').required().preset(4096).help('The size in bytes'),
|
||||
]"
|
||||
>
|
||||
<FormByteSize v-bind="properties" />
|
||||
</ComponentStory>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ComponentStory from "@/components/component-story/ComponentStory.vue";
|
||||
import FormByteSize from "@/components/form/FormByteSize.vue";
|
||||
import { model } from "@/libs/story/story-param";
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
:params="[slot().help('Can contains multiple FormInput and FormSelect')]"
|
||||
:params="[slot().help('Can contain multiple FormInput and FormSelect')]"
|
||||
>
|
||||
<FormInputGroup>
|
||||
<FormInput />
|
||||
|
||||
@@ -31,9 +31,12 @@ export type XenApiPatch = {
|
||||
size: number;
|
||||
url: string;
|
||||
version: string;
|
||||
changelog: {
|
||||
date: number;
|
||||
description: string;
|
||||
author: string;
|
||||
};
|
||||
changelog:
|
||||
| null
|
||||
| undefined
|
||||
| {
|
||||
date: number;
|
||||
description: string;
|
||||
author: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -135,23 +135,15 @@
|
||||
</UiCard>
|
||||
<UiCard class="group">
|
||||
<UiCardTitle>{{ $t("language") }}</UiCardTitle>
|
||||
<UiKeyValueList>
|
||||
<UiKeyValueRow>
|
||||
<template #value>
|
||||
<FormWidget class="full-length" :before="faEarthAmericas">
|
||||
<select v-model="$i18n.locale">
|
||||
<option
|
||||
:value="locale"
|
||||
v-for="locale in $i18n.availableLocales"
|
||||
:key="locale"
|
||||
>
|
||||
{{ locales[locale].name ?? locale }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
</template>
|
||||
</UiKeyValueRow>
|
||||
</UiKeyValueList>
|
||||
<FormSelect :before="faEarthAmericas" v-model="$i18n.locale">
|
||||
<option
|
||||
:value="locale"
|
||||
v-for="locale in $i18n.availableLocales"
|
||||
:key="locale"
|
||||
>
|
||||
{{ locales[locale].name ?? locale }}
|
||||
</option>
|
||||
</FormSelect>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -174,7 +166,7 @@ import {
|
||||
faGear,
|
||||
faCheck,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import FormWidget from "@/components/FormWidget.vue";
|
||||
import FormSelect from "@/components/form/FormSelect.vue";
|
||||
import TitleBar from "@/components/TitleBar.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiKeyValueList from "@/components/ui/UiKeyValueList.vue";
|
||||
@@ -249,8 +241,4 @@ h5 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.full-length {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.node.json",
|
||||
"extends": ["@tsconfig/node18/tsconfig.json", "@vue/tsconfig/tsconfig.json"],
|
||||
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/stories/**/*"],
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"lib": ["ES2019", "ES2020.Intl", "dom"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@xen-orchestra/proxy",
|
||||
"version": "0.26.38",
|
||||
"version": "0.26.41",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "XO Proxy used to remotely execute backup jobs",
|
||||
"keywords": [
|
||||
@@ -30,15 +30,15 @@
|
||||
"@vates/cached-dns.lookup": "^1.0.0",
|
||||
"@vates/compose": "^2.1.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.4",
|
||||
"@vates/disposable": "^0.1.5",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.43.2",
|
||||
"@xen-orchestra/fs": "^4.1.2",
|
||||
"@xen-orchestra/backups": "^0.44.2",
|
||||
"@xen-orchestra/fs": "^4.1.3",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/mixin": "^0.1.0",
|
||||
"@xen-orchestra/mixins": "^0.14.0",
|
||||
"@xen-orchestra/self-signed": "^0.1.3",
|
||||
"@xen-orchestra/xapi": "^3.3.0",
|
||||
"@xen-orchestra/xapi": "^4.0.0",
|
||||
"ajv": "^8.0.3",
|
||||
"app-conf": "^2.3.0",
|
||||
"async-iterator-to-stream": "^1.1.0",
|
||||
@@ -60,7 +60,7 @@
|
||||
"source-map-support": "^0.5.16",
|
||||
"stoppable": "^1.0.6",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"xen-api": "^1.3.6",
|
||||
"xen-api": "^2.0.0",
|
||||
"xo-common": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"pw": "^0.0.4",
|
||||
"xdg-basedir": "^4.0.0",
|
||||
"xo-lib": "^0.11.1",
|
||||
"xo-vmdk-to-vhd": "^2.5.6"
|
||||
"xo-vmdk-to-vhd": "^2.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.0.0",
|
||||
|
||||
@@ -235,6 +235,9 @@ export default class Esxi extends EventEmitter {
|
||||
|
||||
return Object.keys(datas).map(id => {
|
||||
const { config, storage, runtime } = datas[id]
|
||||
if (storage === undefined) {
|
||||
throw new Error(`source VM ${id} don't have any storage`)
|
||||
}
|
||||
const perDatastoreUsage = Array.isArray(storage.perDatastoreUsage)
|
||||
? storage.perDatastoreUsage
|
||||
: [storage.perDatastoreUsage]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"license": "ISC",
|
||||
"private": false,
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"name": "@xen-orchestra/vmware-explorer",
|
||||
"dependencies": {
|
||||
"@vates/node-vsphere-soap": "^2.0.0",
|
||||
@@ -10,7 +10,7 @@
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^3.3.0",
|
||||
"vhd-lib": "^4.6.1"
|
||||
"vhd-lib": "^4.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Xapi } from './index.mjs'
|
||||
import CLI from 'xen-api/dist/cli.js'
|
||||
import { main } from 'xen-api/cli-lib.mjs'
|
||||
|
||||
CLI.default(opts => new Xapi(opts)).catch(console.error.bind(console, 'FATAL'))
|
||||
import { Xapi } from './index.mjs'
|
||||
|
||||
main(opts => new Xapi(opts)).catch(console.error.bind(console, 'FATAL'))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/xapi",
|
||||
"version": "3.3.0",
|
||||
"version": "4.0.0",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/xapi",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"main": "./index.mjs",
|
||||
"peerDependencies": {
|
||||
"xen-api": "^1.3.6"
|
||||
"xen-api": "^2.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/nbd-client": "^2.0.0",
|
||||
"@vates/nbd-client": "^2.0.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
@@ -34,7 +34,7 @@
|
||||
"json-rpc-protocol": "^0.13.2",
|
||||
"lodash": "^4.17.15",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.6.1",
|
||||
"vhd-lib": "^4.7.0",
|
||||
"xo-common": "^0.8.0"
|
||||
},
|
||||
"private": false,
|
||||
|
||||
@@ -137,14 +137,16 @@ class Vdi {
|
||||
|
||||
const vdi = await this.getRecord('VDI', ref)
|
||||
const sr = await this.getRecord('SR', vdi.SR)
|
||||
|
||||
try {
|
||||
const taskRef = await this.task_create(`Importing content into VDI ${vdi.name_label} on SR ${sr.name_label}`)
|
||||
const uuid = await this.getField('task', taskRef, 'uuid')
|
||||
await vdi.update_other_config({ 'xo:import:task': uuid, 'xo:import:length': stream.length.toString() })
|
||||
await this.putResource(cancelToken, stream, '/import_raw_vdi/', {
|
||||
query: {
|
||||
format,
|
||||
vdi: ref,
|
||||
},
|
||||
task: await this.task_create(`Importing content into VDI ${vdi.name_label} on SR ${sr.name_label}`),
|
||||
task: taskRef,
|
||||
})
|
||||
} catch (error) {
|
||||
// augment the error with as much relevant info as possible
|
||||
@@ -153,6 +155,8 @@ class Vdi {
|
||||
error.SR = sr
|
||||
error.VDI = vdi
|
||||
throw error
|
||||
} finally {
|
||||
vdi.update_other_config({ 'xo:import:task': null, 'xo:import:length': null }).catch(warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +491,7 @@ class Vm {
|
||||
exportedVmRef = await this.VM_snapshot(vmRef, { cancelToken, name_label: `[XO Export] ${vm.name_label}` })
|
||||
destroySnapshot = () =>
|
||||
this.VM_destroy(exportedVmRef).catch(error => {
|
||||
warn('VM_export: failed to destroy snapshots', {
|
||||
warn('VM_export: failed to destroy snapshot', {
|
||||
error,
|
||||
snapshotRef: exportedVmRef,
|
||||
vmRef,
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -1,7 +1,61 @@
|
||||
# ChangeLog
|
||||
|
||||
## **5.89.0** (2023-11-30)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
|
||||
### Highlights
|
||||
|
||||
- [Restore] Show source remote and restoration time on a restored VM (PR [#7186](https://github.com/vatesfr/xen-orchestra/pull/7186))
|
||||
- [Backup/Import] Show disk import status during Incremental Replication or restoration of Incremental Backup (PR [#7171](https://github.com/vatesfr/xen-orchestra/pull/7171))
|
||||
- [VM/Console] Add a message to indicate that the console view has been [disabled](https://support.citrix.com/article/CTX217766/how-to-disable-the-console-for-the-vm-in-xencenter) for this VM [#6319](https://github.com/vatesfr/xen-orchestra/issues/6319) (PR [#7161](https://github.com/vatesfr/xen-orchestra/pull/7161))
|
||||
- [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))
|
||||
- [REST API] Support VM import using the XVA format
|
||||
- [File Restore] API method `backupNg.mountPartition` to manually mount a backup disk on the XOA
|
||||
- [Backup] Implement differential restore (PR [#7202](https://github.com/vatesfr/xen-orchestra/pull/7202))
|
||||
- [VM/Disks] Display task information when importing VDIs (PR [#7197](https://github.com/vatesfr/xen-orchestra/pull/7197))
|
||||
- [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))
|
||||
- [Task] Show the related SR on the Garbage Collector Task ( vdi coalescing) (PR [#7189](https://github.com/vatesfr/xen-orchestra/pull/7189))
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [Netbox] Ability to synchronize XO users as Netbox tenants (PR [#7158](https://github.com/vatesfr/xen-orchestra/pull/7158))
|
||||
- [Backup] Don't backup VM with tag xo:no-bak (PR [#7173](https://github.com/vatesfr/xen-orchestra/pull/7173))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Backup/Restore] In case of snapshot with memory, create the suspend VDI on the correct SR instead of the default one
|
||||
- [Import/ESXi] Handle `Cannot read properties of undefined (reading 'perDatastoreUsage')` error when importing VM without storage (PR [#7168](https://github.com/vatesfr/xen-orchestra/pull/7168))
|
||||
- [Export/OVA] Handle export with resulting disk larger than 8.2GB (PR [#7183](https://github.com/vatesfr/xen-orchestra/pull/7183))
|
||||
- [Self Service] Fix error displayed after adding a VM to a resource set (PR [#7144](https://github.com/vatesfr/xen-orchestra/pull/7144))
|
||||
- [Backup/HealthCheck] Don't backup VM created by health check when using smart mode (PR [#7173](https://github.com/vatesfr/xen-orchestra/pull/7173))
|
||||
|
||||
### Released packages
|
||||
|
||||
- vhd-lib 4.7.0
|
||||
- @vates/multi-key-map 0.2.0
|
||||
- @vates/disposable 0.1.5
|
||||
- @xen-orchestra/fs 4.1.3
|
||||
- xen-api 2.0.0
|
||||
- @vates/nbd-client 2.0.1
|
||||
- @xen-orchestra/xapi 4.0.0
|
||||
- @xen-orchestra/backups 0.44.2
|
||||
- @xen-orchestra/backups-cli 1.0.14
|
||||
- @xen-orchestra/cr-seed-cli 1.0.0
|
||||
- @xen-orchestra/proxy 0.26.41
|
||||
- xo-vmdk-to-vhd 2.5.7
|
||||
- @xen-orchestra/vmware-explorer 0.3.1
|
||||
- xapi-explore-sr 0.4.2
|
||||
- xo-cli 0.22.0
|
||||
- xo-server 5.129.0
|
||||
- xo-server-netbox 1.4.0
|
||||
- xo-web 5.130.0
|
||||
|
||||
## **5.88.2** (2023-11-13)
|
||||
|
||||
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
|
||||
|
||||
### Enhancement
|
||||
|
||||
- [REST API] Add `users` collection
|
||||
@@ -13,8 +67,6 @@
|
||||
|
||||
## **5.88.1** (2023-11-07)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Netbox] Fix VMs' `site` property being unnecessarily updated on some versions of Netbox (PR [#7145](https://github.com/vatesfr/xen-orchestra/pull/7145))
|
||||
@@ -83,8 +135,6 @@
|
||||
|
||||
## **5.87.0** (2023-09-29)
|
||||
|
||||
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
|
||||
|
||||
### Highlights
|
||||
|
||||
- [Patches] Support new XenServer Updates system. See [our documentation](https://xen-orchestra.com/docs/updater.html#xenserver-updates). (PR [#7044](https://github.com/vatesfr/xen-orchestra/pull/7044))
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
|
||||
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
||||
|
||||
- [Forget SR] Changed the modal message and added a confirmation text to be sure the action is understood by the user [#7148](https://github.com/vatesfr/xen-orchestra/issues/7148) (PR [#7155](https://github.com/vatesfr/xen-orchestra/pull/7155))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
> Users must be able to say: “I had this issue, happy to know it's fixed”
|
||||
|
||||
- [Backup/Restore] In case of snapshot with memory, create the suspend VDI on the correct SR instead of the default one
|
||||
- [Remotes] Prevents the "connection failed" alert from continuing to appear after successfull connection
|
||||
|
||||
### Packages to release
|
||||
|
||||
@@ -29,7 +31,6 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- @xen-orchestra/backups patch
|
||||
- xo-web patch
|
||||
- xo-remote-parser patch
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
@@ -84,7 +84,6 @@ module.exports = {
|
||||
['/xoa', 'XOA Support'],
|
||||
['/purchase', 'Purchase XOA'],
|
||||
['/license_management', 'License Management'],
|
||||
['/reseller', 'Partner Program'],
|
||||
['/community', 'Community Support'],
|
||||
],
|
||||
},
|
||||
|
||||
@@ -323,7 +323,7 @@ From there, you can even manage your existing resources with Terraform!
|
||||
|
||||
## Netbox
|
||||
|
||||
Synchronize your pools, VMs, network interfaces and IP addresses with your [Netbox](https://netbox.readthedocs.io/en/stable/) instance.
|
||||
Synchronize your pools, VMs, network interfaces and IP addresses with your [Netbox](https://docs.netbox.dev/en/stable/) instance.
|
||||
|
||||

|
||||
|
||||
@@ -338,38 +338,48 @@ Synchronize your pools, VMs, network interfaces and IP addresses with your [Netb
|
||||
XO will try to find the right prefix for each IP address. If it can't find a prefix that fits, the IP address won't be synchronized.
|
||||
:::
|
||||
|
||||
- Create a Netbox user:
|
||||
- Go to Admin > Authentication and Authorization > Users > Add
|
||||
- Enter a name and a password and click on "Save and continue editing"
|
||||
- Scroll down to Permissions and add the following permissions:
|
||||
- View permissions on:
|
||||
- Create permissions:
|
||||
- Go to Admin > Permissions > Add and create 2 permissions:
|
||||
- "XO read" with action "Can view" enabled and object types:
|
||||
- Extras > custom field
|
||||
- IPAM > prefix
|
||||
- All permissions on:
|
||||
- "XO read-write" with all 4 actions enabled and object types:
|
||||
- DCIM > platform
|
||||
- Extras > tag
|
||||
- IPAM > IP address
|
||||
- Tenancy > tenant (if you want to synchronize XO users with Netbox tenants)
|
||||
- Virtualization > cluster
|
||||
- Virtualization > cluster type
|
||||
- Virtualization > virtual machine
|
||||
- Virtualization > interface
|
||||
- From that user's account, generate an API token:
|
||||
- Go to Profile > API Tokens > Add a token
|
||||
- Create a token with "Write enabled"
|
||||
- Add a UUID custom field:
|
||||
- Go to Other > Customization > Custom fields > Add
|
||||
- Create a custom field called "uuid" (lower case!)
|
||||
- Assign it to object types:
|
||||
|
||||

|
||||
|
||||
- Create a Netbox user:
|
||||
- Go to Admin > Users > Add
|
||||
- Choose a username and a password
|
||||
- Scroll down to Permissions and select the 2 permissions "XO read" and "XO read-write"
|
||||
- Create an API token:
|
||||
- Got to Admin > API Tokens > Add
|
||||
- Select the user you just created
|
||||
- Copy the token for the next step
|
||||
- Make sure "Write enabled" is checked and create it
|
||||
|
||||
:::warning
|
||||
For testing purposes, you can create an API token bound to a Netbox superuser account, but once in production, it is highly recommended to create a dedicated user with only the required permissions.
|
||||
:::
|
||||
|
||||
- Create a UUID custom field:
|
||||
- Go to Customization > Custom Fields > Add
|
||||
- Select object types:
|
||||
- Tenancy > tenant (if you want to synchronize XO users with Netbox tenants)
|
||||
- Virtualization > cluster
|
||||
- Virtualization > virtual machine
|
||||
- Virtualization > interface
|
||||
- Name it "uuid" (lower case!)
|
||||
|
||||

|
||||
|
||||
:::warning
|
||||
You can generate an API token from a Netbox superuser account for testing purposes, but once in production, it is highly recommended to create a dedicated user with only the required permissions.
|
||||
:::
|
||||
|
||||
:::tip
|
||||
In Netbox 2.x, custom fields can be created from the Admin panel > Custom fields > Add custom field.
|
||||
:::
|
||||
@@ -381,6 +391,7 @@ In Netbox 2.x, custom fields can be created from the Admin panel > Custom fields
|
||||
- Unauthorized certificate: only for HTTPS, enable this option if your Netbox instance uses a self-signed SSL certificate
|
||||
- Token: the token you generated earlier
|
||||
- Pools: the pools you wish to automatically synchronize with Netbox
|
||||
- Synchronize users: enable this if you wish to synchronize XO users with Netbox tenants. Tenants will be assigned to the VMs the XO user _created_ within XO. Important: if you want to enable this feature, you also need to assign the custom field "uuid" that you created in the previous step to the type "Tenancy > tenant".
|
||||
- Interval: the time interval (in hours) between 2 auto-synchronizations. Leave empty if you don't want to synchronize automatically.
|
||||
- Load the plugin (button next to the plugin's name)
|
||||
- Manual synchronization: if you correctly configured and loaded the plugin, a "Synchronize with Netbox" button will appear in every pool's Advanced tab, which allows you to manually synchronize it with Netbox
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
BIN
docs/assets/netbox-permissions.png
Normal file
BIN
docs/assets/netbox-permissions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -106,13 +106,13 @@ XO needs the following packages to be installed. Redis is used as a database by
|
||||
For example, on Debian/Ubuntu:
|
||||
|
||||
```sh
|
||||
apt-get install build-essential redis-server libpng-dev git python3-minimal libvhdi-utils lvm2 cifs-utils nfs-common
|
||||
apt-get install build-essential redis-server libpng-dev git python3-minimal libvhdi-utils lvm2 cifs-utils nfs-common ntfs-3g
|
||||
```
|
||||
|
||||
On Fedora/CentOS like:
|
||||
|
||||
```sh
|
||||
dnf install redis libpng-devel git libvhdi-utils lvm2 cifs-utils make automake gcc gcc-c++
|
||||
dnf install redis libpng-devel git lvm2 cifs-utils make automake gcc gcc-c++ nfs-utils ntfs-3g
|
||||
```
|
||||
|
||||
### Make sure Redis is running
|
||||
|
||||
@@ -40,9 +40,10 @@ The second step is to select your purchase option:
|
||||
- Paid period: **check or wire transfer only**. This purchase allows you to subscribe for a one, two or three year period
|
||||
|
||||
:::tip
|
||||
|
||||
- A 2 year subscription period grants you 1 month discounted
|
||||
- A 3 year subscription period grants you 2 months discounted
|
||||
:::
|
||||
:::
|
||||
|
||||
Then you need to fill in your information and select **"Buy for my own use"** (direct purchase)
|
||||
|
||||
@@ -105,46 +106,6 @@ That's it, you have now completed the purchase.
|
||||
Once you have bound the plan to your end user account, you cannot change it. Double check the spelling of the e-mail before binding the account.
|
||||
:::
|
||||
|
||||
## As a reseller
|
||||
|
||||
The Xen Orchestra partner program is designed to offer you the opportunity to become a reseller of Xen Orchestra and deliver a full stack solution to your customers.
|
||||
|
||||
:::tip
|
||||
Becoming a reseller will grant you a standard discount. However, **the reseller status is designed for companies that want to actively prospect for new Xen Orchestra users**. That's why we are asking our partners to **resell Xen Orchestra at least two times a year**. If you are acting as a third party purchaser answering to a specific request from one of your customers, you don't need to apply to the reseller program - you can follow [this process](purchase.md#purchase-xoas) instead.
|
||||
:::
|
||||
|
||||
### Apply to the program
|
||||
|
||||
To apply to our partner program, you can access the [partner page](https://xen-orchestra.com/#!/partner) and click on the "Register to become a partner" button:
|
||||
|
||||

|
||||
|
||||
You will have to complete a form in order to provide information regarding your expectations and location. Once you've finished, you should receive an email in order to **start the discussion with someone from our team**.
|
||||
|
||||
:::tip
|
||||
It's important to answer the email - this will start the discussion with someone from our team in order to determine together if the partner status is what you really need.
|
||||
:::
|
||||
|
||||
Once we have activated your partner space, you will have the ability to access the purchasing page [at the same place](https://xen-orchestra.com/#!/partner).
|
||||
|
||||
### Purchase XOAs
|
||||
|
||||
Now that you can see the reseller interface:
|
||||
|
||||

|
||||
|
||||
You can follow these steps to purchase an XOA edition for your customer.
|
||||
|
||||
1. choose the edition you want to purchase for your customer
|
||||
2. Buy it on your reseller page (the discount is automatically applied - once it's done, a new line appears on your reseller page)
|
||||
3. assign/bind the plan to your final client email
|
||||
|
||||
You'll have all the invoices in your account.
|
||||
|
||||
### From your client's perspective
|
||||
|
||||
Your client can use the email assigned to register their appliance, and unlock the Edition you purchased. They will not get any invoices from us but can still access our support system.
|
||||
|
||||
## Invoices
|
||||
|
||||
Invoices are available in PDF format. You can find them [in your account](https://xen-orchestra.com/#!/member).
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# Partner Program
|
||||
|
||||
The Xen Orchestra partner program is designed to offer you the opportunity to become a reseller of Xen Orchestra and deliver a full stack solution to your customers.
|
||||
|
||||
:::tip
|
||||
Becoming a reseller will grant you a standard discount. However, **the reseller status is designed for companies that want to actively prospect for new Xen Orchestra users**. That's why we are asking our partners to **resell Xen Orchestra at least two times a year**. If you are acting as a third party purchaser answering to a specific request from one of your customers, you don't need to apply to the reseller program - you can follow [this process](./purchase.md#via-your-purchase-departement) instead.
|
||||
:::
|
||||
|
||||
## Apply to the program
|
||||
|
||||
To apply to our partner program, you can access the [partner page](https://xen-orchestra.com/#!/partner) and click on the "Register to become a partner" button:
|
||||
|
||||

|
||||
|
||||
You will have to complete a form in order to provide information regarding your expectations and location. Once you've finished, you should receive an email in order to **start the discussion with someone from our team**.
|
||||
|
||||
:::tip
|
||||
It's important to answer the email - this will start the discussion with someone from our team in order to determine together if the partner status is what you really need.
|
||||
:::
|
||||
|
||||
Once we have activated your partner space, you will have the ability to access the purchasing page [at the same place](https://xen-orchestra.com/#!/partner).
|
||||
|
||||
## Purchase XOAs
|
||||
|
||||
Now that you can see the reseller interface:
|
||||
|
||||

|
||||
|
||||
You can follow these steps to purchase an XOA edition for your customer.
|
||||
|
||||
1. choose the edition you want to purchase for your customer
|
||||
2. Buy it on your reseller page (the discount is automatically applied - once it's done, a new line appears on your reseller page)
|
||||
3. assign/bind the plan to your final client email
|
||||
|
||||
You'll have all the invoices in your account.
|
||||
|
||||
## From your client's perspective
|
||||
|
||||
Your client can use the email assigned to register their appliance, and unlock the Edition you purchased. They will not get any invoices from us but can still access our support system.
|
||||
@@ -123,7 +123,7 @@ Content-Type: application/x-ndjson
|
||||
|
||||
## Properties update
|
||||
|
||||
> This feature is restricted to `name_label` and `name_description` at the moment.
|
||||
> This feature is restricted to `name_label`, `name_description` and `tags` at the moment.
|
||||
|
||||
```sh
|
||||
curl \
|
||||
@@ -135,6 +135,30 @@ curl \
|
||||
'https://xo.example.org/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac'
|
||||
```
|
||||
|
||||
### Collections
|
||||
|
||||
For collection properties, like `tags`, it can be more practical to touch a single item without impacting the others.
|
||||
|
||||
An item can be created with `PUT <collection>/<item id>` and can be destroyed with `DELETE <collection>/<item id>`.
|
||||
|
||||
Adding a tag:
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-X PUT \
|
||||
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
|
||||
'https://xo.example.org/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/tags/My%20tag'
|
||||
```
|
||||
|
||||
Removing a tag:
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-X DELETE \
|
||||
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
|
||||
'https://xo.example.org/rest/v0/vms/770aa52a-fd42-8faf-f167-8c5c4a237cac/tags/My%20tag'
|
||||
```
|
||||
|
||||
## VM and VDI destruction
|
||||
|
||||
For a VM:
|
||||
@@ -175,9 +199,45 @@ curl \
|
||||
> myDisk.vhd
|
||||
```
|
||||
|
||||
## VM Import
|
||||
|
||||
A VM can be imported by posting to `/rest/v0/pools/:id/vms`.
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-X POST \
|
||||
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
|
||||
-T myDisk.raw \
|
||||
'https://xo.example.org/rest/v0/pools/355ee47d-ff4c-4924-3db2-fd86ae629676/vms?sr=357bd56c-71f9-4b2a-83b8-3451dec04b8f' \
|
||||
| cat
|
||||
```
|
||||
|
||||
The `sr` query parameter can be used to specify on which SR the VM should be imported, if not specified, the default SR will be used.
|
||||
|
||||
> Note: the final `| cat` ensures cURL's standard output is not a TTY, which is necessary for upload stats to be dislayed.
|
||||
|
||||
## VDI Import
|
||||
|
||||
A VHD or a raw export can be imported on an SR to create a new VDI at `/rest/v0/srs/<sr uuid>/vdis`.
|
||||
### Existing VDI
|
||||
|
||||
A VHD or a raw export can be imported in an existing VDI respectively at `/rest/v0/vdis/<uuid>.vhd` and `/rest/v0/vdis/<uuid>.raw`.
|
||||
|
||||
> Note: the size of the VDI must match exactly the size of VDI that was previously exported.
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-X PUT \
|
||||
-b authenticationToken=KQxQdm2vMiv7jBIK0hgkmgxKzemd8wSJ7ugFGKFkTbs \
|
||||
-T myDisk.vhd \
|
||||
'https://xo.example.org/rest/v0/vdis/1a269782-ea93-4c4c-897a-475365f7b674.vhd' \
|
||||
| cat
|
||||
```
|
||||
|
||||
> Note: the final `| cat` ensures cURL's standard output is not a TTY, which is necessary for upload stats to be dislayed.
|
||||
|
||||
### New VDI
|
||||
|
||||
An export can also be imported on an SR to create a new VDI at `/rest/v0/srs/<sr uuid>/vdis`.
|
||||
|
||||
```sh
|
||||
curl \
|
||||
|
||||
@@ -23,8 +23,8 @@ function r(p) {
|
||||
return v && v.__esModule
|
||||
? v
|
||||
: typeof v === 'object' || typeof v === 'function'
|
||||
? Object.create(v, { default: { enumerable: true, value: v } })
|
||||
: { default: v }
|
||||
? Object.create(v, { default: { enumerable: true, value: v } })
|
||||
: { default: v }
|
||||
}
|
||||
function e(p, i) {
|
||||
dl(defaults, i, function () {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"node": ">=10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/fs": "^4.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.3",
|
||||
"cli-progress": "^3.1.0",
|
||||
"exec-promise": "^0.7.0",
|
||||
"getopts": "^2.2.3",
|
||||
@@ -31,7 +31,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"uuid": "^9.0.0",
|
||||
"vhd-lib": "^4.6.1"
|
||||
"vhd-lib": "^4.7.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish",
|
||||
|
||||
184
packages/vhd-lib/Vhd/VhdNegative.integ.js
Normal file
184
packages/vhd-lib/Vhd/VhdNegative.integ.js
Normal file
@@ -0,0 +1,184 @@
|
||||
'use strict'
|
||||
|
||||
const { VhdAbstract, VhdNegative } = require('..')
|
||||
|
||||
const { describe, it } = require('test')
|
||||
const assert = require('assert/strict')
|
||||
const { unpackHeader, unpackFooter } = require('./_utils')
|
||||
const { createHeader, createFooter } = require('../_createFooterHeader')
|
||||
const _computeGeometryForSize = require('../_computeGeometryForSize')
|
||||
const { FOOTER_SIZE, DISK_TYPES } = require('../_constants')
|
||||
|
||||
const VHD_BLOCK_LENGTH = 2 * 1024 * 1024
|
||||
class VhdMock extends VhdAbstract {
|
||||
#blockUsed
|
||||
#header
|
||||
#footer
|
||||
get header() {
|
||||
return this.#header
|
||||
}
|
||||
get footer() {
|
||||
return this.#footer
|
||||
}
|
||||
|
||||
constructor(header, footer, blockUsed = new Set()) {
|
||||
super()
|
||||
this.#header = header
|
||||
this.#footer = footer
|
||||
this.#blockUsed = blockUsed
|
||||
}
|
||||
containsBlock(blockId) {
|
||||
return this.#blockUsed.has(blockId)
|
||||
}
|
||||
readBlock(blockId, onlyBitmap = false) {
|
||||
const bitmap = Buffer.alloc(512, 255) // bitmap are full of bit 1
|
||||
|
||||
const data = Buffer.alloc(2 * 1024 * 1024, 0) // empty are full of bit 0
|
||||
data.writeUint8(blockId)
|
||||
return {
|
||||
id: blockId,
|
||||
bitmap,
|
||||
data,
|
||||
buffer: Buffer.concat([bitmap, data]),
|
||||
}
|
||||
}
|
||||
|
||||
readBlockAllocationTable() {}
|
||||
readHeaderAndFooter() {}
|
||||
_readParentLocatorData(id) {}
|
||||
}
|
||||
|
||||
describe('vhd negative', async () => {
|
||||
it(`throws when uid aren't chained `, () => {
|
||||
const length = 10e8
|
||||
|
||||
let header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
|
||||
const geometry = _computeGeometryForSize(length)
|
||||
let footer = unpackFooter(
|
||||
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
|
||||
)
|
||||
const parent = new VhdMock(header, footer)
|
||||
|
||||
header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
|
||||
footer = unpackFooter(
|
||||
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
|
||||
)
|
||||
|
||||
const child = new VhdMock(header, footer)
|
||||
assert.throws(() => new VhdNegative(parent, child), { message: 'NOT_CHAINED' })
|
||||
})
|
||||
|
||||
it('throws when size changed', () => {
|
||||
const childLength = 10e8
|
||||
const parentLength = 10e8 + 1
|
||||
|
||||
let header = unpackHeader(createHeader(parentLength / VHD_BLOCK_LENGTH))
|
||||
let geometry = _computeGeometryForSize(parentLength)
|
||||
let footer = unpackFooter(
|
||||
createFooter(parentLength, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
|
||||
)
|
||||
const parent = new VhdMock(header, footer)
|
||||
|
||||
header = unpackHeader(createHeader(childLength / VHD_BLOCK_LENGTH))
|
||||
geometry = _computeGeometryForSize(childLength)
|
||||
header.parentUuid = footer.uuid
|
||||
footer = unpackFooter(
|
||||
createFooter(childLength, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
|
||||
)
|
||||
const child = new VhdMock(header, footer)
|
||||
assert.throws(() => new VhdNegative(parent, child), { message: 'GEOMETRY_CHANGED' })
|
||||
})
|
||||
it('throws when child is not differencing', () => {
|
||||
const length = 10e8
|
||||
|
||||
let header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
|
||||
const geometry = _computeGeometryForSize(length)
|
||||
let footer = unpackFooter(
|
||||
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
|
||||
)
|
||||
const parent = new VhdMock(header, footer)
|
||||
|
||||
header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
|
||||
header.parentUuid = footer.uuid
|
||||
footer = unpackFooter(
|
||||
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
|
||||
)
|
||||
|
||||
const child = new VhdMock(header, footer)
|
||||
assert.throws(() => new VhdNegative(parent, child), { message: 'CHILD_NOT_DIFFERENCING' })
|
||||
})
|
||||
|
||||
it(`throws when writing into vhd negative `, async () => {
|
||||
const length = 10e8
|
||||
|
||||
let header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
|
||||
const geometry = _computeGeometryForSize(length)
|
||||
let footer = unpackFooter(
|
||||
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
|
||||
)
|
||||
const parent = new VhdMock(header, footer)
|
||||
const parentUuid = footer.uuid
|
||||
header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
|
||||
header.parentUuid = parentUuid
|
||||
footer = unpackFooter(
|
||||
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
|
||||
)
|
||||
|
||||
const child = new VhdMock(header, footer)
|
||||
|
||||
const vhd = new VhdNegative(parent, child)
|
||||
|
||||
// await assert.rejects( ()=> vhd.writeFooter())
|
||||
assert.throws(() => vhd.writeHeader())
|
||||
assert.throws(() => vhd.writeBlockAllocationTable())
|
||||
assert.throws(() => vhd.writeEntireBlock())
|
||||
assert.throws(() => vhd.mergeBlock(), { message: `can't coalesce block into a vhd negative` })
|
||||
})
|
||||
|
||||
it('normal case', async () => {
|
||||
const length = 10e8
|
||||
|
||||
let header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
|
||||
let geometry = _computeGeometryForSize(length)
|
||||
let footer = unpackFooter(
|
||||
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
|
||||
)
|
||||
const parent = new VhdMock(header, footer, new Set([1, 3]))
|
||||
const parentUuid = footer.uuid
|
||||
header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
|
||||
header.parentUuid = parentUuid
|
||||
geometry = _computeGeometryForSize(length)
|
||||
footer = unpackFooter(
|
||||
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DIFFERENCING)
|
||||
)
|
||||
|
||||
const childUuid = footer.uuid
|
||||
const child = new VhdMock(header, footer, new Set([2, 3]))
|
||||
|
||||
const vhd = new VhdNegative(parent, child)
|
||||
assert.equal(vhd.header.parentUuid.equals(childUuid), true)
|
||||
assert.equal(vhd.footer.diskType, DISK_TYPES.DIFFERENCING)
|
||||
await vhd.readBlockAllocationTable()
|
||||
await vhd.readHeaderAndFooter()
|
||||
await vhd.readParentLocator(0)
|
||||
assert.equal(vhd.header.parentUuid, childUuid)
|
||||
assert.equal(vhd.footer.diskType, DISK_TYPES.DIFFERENCING)
|
||||
assert.equal(vhd.containsBlock(1), false)
|
||||
assert.equal(vhd.containsBlock(2), true)
|
||||
assert.equal(vhd.containsBlock(3), true)
|
||||
assert.equal(vhd.containsBlock(4), false)
|
||||
|
||||
const expected = [0, 1, 0, 3, 0]
|
||||
const expectedBitmap = Buffer.alloc(512, 255) // bitmap must always be full of bit 1
|
||||
for (let index = 0; index < 5; index++) {
|
||||
if (vhd.containsBlock(index)) {
|
||||
const { id, data, bitmap } = await vhd.readBlock(index)
|
||||
assert.equal(index, id)
|
||||
assert.equal(expectedBitmap.equals(bitmap), true)
|
||||
assert.equal(data.readUInt8(0), expected[index])
|
||||
} else {
|
||||
assert.equal([2, 3].includes(index), false)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
84
packages/vhd-lib/Vhd/VhdNegative.js
Normal file
84
packages/vhd-lib/Vhd/VhdNegative.js
Normal file
@@ -0,0 +1,84 @@
|
||||
'use strict'
|
||||
|
||||
const UUID = require('uuid')
|
||||
const { DISK_TYPES } = require('../_constants')
|
||||
const { VhdAbstract } = require('./VhdAbstract')
|
||||
const { computeBlockBitmapSize } = require('./_utils')
|
||||
const assert = require('node:assert')
|
||||
/**
|
||||
* Build an incremental VHD which can be applied to a child to revert to the state of its parent.
|
||||
* @param {*} parent
|
||||
* @param {*} descendant
|
||||
*/
|
||||
|
||||
class VhdNegative extends VhdAbstract {
|
||||
#parent
|
||||
#child
|
||||
|
||||
get header() {
|
||||
// we want to have parent => child => negative
|
||||
// where => means " is the parent of "
|
||||
return {
|
||||
...this.#parent.header,
|
||||
parentUuid: this.#child.footer.uuid,
|
||||
}
|
||||
}
|
||||
|
||||
get footer() {
|
||||
// by construct a negative vhd is differencing disk
|
||||
return {
|
||||
...this.#parent.footer,
|
||||
diskType: DISK_TYPES.DIFFERENCING,
|
||||
}
|
||||
}
|
||||
|
||||
constructor(parent, child) {
|
||||
super()
|
||||
this.#parent = parent
|
||||
this.#child = child
|
||||
|
||||
assert.strictEqual(UUID.stringify(child.header.parentUuid), UUID.stringify(parent.footer.uuid), 'NOT_CHAINED')
|
||||
assert.strictEqual(child.footer.diskType, DISK_TYPES.DIFFERENCING, 'CHILD_NOT_DIFFERENCING')
|
||||
// we don't want to handle alignment and missing block for now
|
||||
// last block may contains partly empty data when changing size
|
||||
assert.strictEqual(child.footer.currentSize, parent.footer.currentSize, 'GEOMETRY_CHANGED')
|
||||
}
|
||||
|
||||
async readBlockAllocationTable() {
|
||||
return Promise.all([this.#parent.readBlockAllocationTable(), this.#child.readBlockAllocationTable()])
|
||||
}
|
||||
|
||||
containsBlock(blockId) {
|
||||
return this.#child.containsBlock(blockId)
|
||||
}
|
||||
|
||||
async readHeaderAndFooter() {
|
||||
return Promise.all([this.#parent.readHeaderAndFooter(), this.#child.readHeaderAndFooter()])
|
||||
}
|
||||
|
||||
async readBlock(blockId, onlyBitmap = false) {
|
||||
// only read the content of the first vhd containing this block
|
||||
if (this.#parent.containsBlock(blockId)) {
|
||||
return this.#parent.readBlock(blockId, onlyBitmap)
|
||||
}
|
||||
|
||||
const bitmap = Buffer.alloc(computeBlockBitmapSize(this.header.blockSize), 255) // bitmap are full of bit 1
|
||||
const data = Buffer.alloc(this.header.blockSize, 0) // empty are full of bit 0
|
||||
return {
|
||||
id: blockId,
|
||||
bitmap,
|
||||
data,
|
||||
buffer: Buffer.concat([bitmap, data]),
|
||||
}
|
||||
}
|
||||
|
||||
mergeBlock(child, blockId) {
|
||||
throw new Error(`can't coalesce block into a vhd negative`)
|
||||
}
|
||||
|
||||
_readParentLocatorData(id) {
|
||||
return this.#parent._readParentLocatorData(id)
|
||||
}
|
||||
}
|
||||
|
||||
exports.VhdNegative = VhdNegative
|
||||
@@ -120,7 +120,8 @@ const VhdSynthetic = class VhdSynthetic extends VhdAbstract {
|
||||
}
|
||||
|
||||
// add decorated static method
|
||||
VhdSynthetic.fromVhdChain = Disposable.factory(async function* fromVhdChain(handler, childPath) {
|
||||
// until is not included in the result , the chain will stop at its child
|
||||
VhdSynthetic.fromVhdChain = Disposable.factory(async function* fromVhdChain(handler, childPath, { until } = {}) {
|
||||
let vhdPath = childPath
|
||||
let vhd
|
||||
const vhds = []
|
||||
@@ -128,8 +129,11 @@ VhdSynthetic.fromVhdChain = Disposable.factory(async function* fromVhdChain(hand
|
||||
vhd = yield openVhd(handler, vhdPath)
|
||||
vhds.unshift(vhd) // from oldest to most recent
|
||||
vhdPath = resolveRelativeFromFile(vhdPath, vhd.header.parentUnicodeName)
|
||||
} while (vhd.footer.diskType !== DISK_TYPES.DYNAMIC)
|
||||
} while (vhd.footer.diskType !== DISK_TYPES.DYNAMIC && vhdPath !== until)
|
||||
|
||||
if (until !== undefined && vhdPath !== until) {
|
||||
throw new Error(`Didn't find ${until} as a parent of ${childPath}`)
|
||||
}
|
||||
const synthetic = new VhdSynthetic(vhds)
|
||||
await synthetic.readHeaderAndFooter()
|
||||
yield synthetic
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
const { dirname, resolve } = require('path')
|
||||
|
||||
const resolveRelativeFromFile = (file, path) => resolve('/', dirname(file), path).slice(1)
|
||||
|
||||
const resolveRelativeFromFile = (file, path) => {
|
||||
if (file.startsWith('/')) {
|
||||
return resolve(dirname(file), path)
|
||||
}
|
||||
return resolve('/', dirname(file), path).slice(1)
|
||||
}
|
||||
module.exports = resolveRelativeFromFile
|
||||
|
||||
@@ -13,4 +13,5 @@ exports.VhdAbstract = require('./Vhd/VhdAbstract').VhdAbstract
|
||||
exports.VhdDirectory = require('./Vhd/VhdDirectory').VhdDirectory
|
||||
exports.VhdFile = require('./Vhd/VhdFile').VhdFile
|
||||
exports.VhdSynthetic = require('./Vhd/VhdSynthetic').VhdSynthetic
|
||||
exports.VhdNegative = require('./Vhd/VhdNegative').VhdNegative
|
||||
exports.Constants = require('./_constants')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "vhd-lib",
|
||||
"version": "4.6.1",
|
||||
"version": "4.7.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Primitives for VHD file handling",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/vhd-lib",
|
||||
@@ -20,7 +20,7 @@
|
||||
"@vates/read-chunk": "^1.2.0",
|
||||
"@vates/stream-reader": "^0.1.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.3",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"async-iterator-to-stream": "^1.0.2",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@xen-orchestra/fs": "^4.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.3",
|
||||
"execa": "^5.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"rimraf": "^5.0.1",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
module.exports = require('../../@xen-orchestra/babel-config')(require('./package.json'))
|
||||
@@ -1 +0,0 @@
|
||||
../../scripts/babel-eslintrc.js
|
||||
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createClient } from 'xen-api'
|
||||
import archy from 'archy'
|
||||
import chalk from 'chalk'
|
||||
import execPromise from 'exec-promise'
|
||||
import firstDefined from '@xen-orchestra/defined'
|
||||
import forEach from 'lodash/forEach.js'
|
||||
import humanFormat from 'human-format'
|
||||
import map from 'lodash/map.js'
|
||||
import orderBy from 'lodash/orderBy.js'
|
||||
import pw from 'pw'
|
||||
import { createClient } from 'xen-api'
|
||||
import { forEach, map, orderBy } from 'lodash'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "xapi-explore-sr",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"license": "ISC",
|
||||
"description": "Display the list of VDIs (unmanaged and snapshots included) of a SR",
|
||||
"keywords": [
|
||||
@@ -25,9 +25,9 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"preferGlobal": true,
|
||||
"main": "dist/",
|
||||
"main": "./index.mjs",
|
||||
"bin": {
|
||||
"xapi-explore-sr": "dist/index.js"
|
||||
"xapi-explore-sr": "./index.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -40,21 +40,9 @@
|
||||
"human-format": "^1.0.0",
|
||||
"lodash": "^4.17.4",
|
||||
"pw": "^0.0.4",
|
||||
"xen-api": "^1.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.1.5",
|
||||
"@babel/core": "^7.1.5",
|
||||
"@babel/preset-env": "^7.1.5",
|
||||
"cross-env": "^7.0.2",
|
||||
"rimraf": "^5.0.1"
|
||||
"xen-api": "^2.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",
|
||||
"dev": "cross-env NODE_ENV=development babel --watch --source-maps --out-dir=dist/ src/",
|
||||
"prebuild": "rimraf dist/",
|
||||
"predev": "yarn run prebuild",
|
||||
"prepublishOnly": "yarn run build",
|
||||
"postversion": "npm publish"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
module.exports = require('../../@xen-orchestra/babel-config')(require('./package.json'))
|
||||
@@ -1 +0,0 @@
|
||||
../../scripts/babel-eslintrc.js
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Cancel } from 'promise-toolbox'
|
||||
|
||||
import XapiError from './_XapiError'
|
||||
import XapiError from './_XapiError.mjs'
|
||||
|
||||
export default task => {
|
||||
const { status } = task
|
||||
8
packages/xen-api/_makeCallSetting.mjs
Normal file
8
packages/xen-api/_makeCallSetting.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
export default (setting, defaultValue) =>
|
||||
setting === undefined
|
||||
? () => defaultValue
|
||||
: typeof setting === 'function'
|
||||
? setting
|
||||
: typeof setting === 'object'
|
||||
? method => setting[method] ?? setting['*'] ?? defaultValue
|
||||
: () => setting
|
||||
@@ -1,8 +1,6 @@
|
||||
'use strict'
|
||||
import t from 'tap'
|
||||
|
||||
const t = require('tap')
|
||||
|
||||
const parseUrl = require('./dist/_parseUrl.js').default
|
||||
import parseUrl from './_parseUrl.mjs'
|
||||
|
||||
const data = {
|
||||
'xcp.company.lan': {
|
||||
@@ -1,4 +1,4 @@
|
||||
import mapValues from 'lodash/mapValues'
|
||||
import mapValues from 'lodash/mapValues.js'
|
||||
|
||||
export default function replaceSensitiveValues(value, replacement) {
|
||||
function helper(value, name) {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user