Compare commits
32 Commits
lite/multi
...
feat_sespa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f134fd33bf | ||
|
|
28794fa820 | ||
|
|
66847f04b4 | ||
|
|
1dbfc6d0a2 | ||
|
|
12c2083651 | ||
|
|
b1ed98a8fd | ||
|
|
f495e7110d | ||
|
|
e9b92780b9 | ||
|
|
d6ac1c2598 | ||
|
|
7e1dd7c26f | ||
|
|
14a0caa4c6 | ||
|
|
1c23bd5ff7 | ||
|
|
49c161b17a | ||
|
|
18dce3fce6 | ||
|
|
d6fc86b6bc | ||
|
|
61d960d4b1 | ||
|
|
02d3465832 | ||
|
|
4bbadc9515 | ||
|
|
78586291ca | ||
|
|
945dec94bf | ||
|
|
003140d96b | ||
|
|
363d7cf0d0 | ||
|
|
f0c94496bf | ||
|
|
de217eabd9 | ||
|
|
7c80d0c1e1 | ||
|
|
9fb749b1db | ||
|
|
ad9c59669a | ||
|
|
76a038e403 | ||
|
|
0e12072922 | ||
|
|
158a8e14a2 | ||
|
|
0c97910349 | ||
|
|
8347ac6ed8 |
@@ -1,9 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const LRU = require('lru-cache')
|
||||
const Fuse = require('fuse-native')
|
||||
const { VhdSynthetic } = require('vhd-lib')
|
||||
const { Disposable, fromCallback } = require('promise-toolbox')
|
||||
import LRU from 'lru-cache'
|
||||
import Fuse from 'fuse-native'
|
||||
import { VhdSynthetic } from 'vhd-lib'
|
||||
import { Disposable, fromCallback } from 'promise-toolbox'
|
||||
|
||||
// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
|
||||
const stat = st => ({
|
||||
@@ -16,7 +14,7 @@ const stat = st => ({
|
||||
gid: st.gid !== undefined ? st.gid : process.getgid(),
|
||||
})
|
||||
|
||||
exports.mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) {
|
||||
export const mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) {
|
||||
const vhd = yield VhdSynthetic.fromVhdChain(handler, diskPath)
|
||||
|
||||
const cache = new LRU({
|
||||
@@ -15,8 +15,9 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0"
|
||||
"node": ">=14"
|
||||
},
|
||||
"main": "./index.mjs",
|
||||
"dependencies": {
|
||||
"fuse-native": "^2.2.6",
|
||||
"lru-cache": "^7.14.0",
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
'use strict'
|
||||
exports.INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
|
||||
exports.OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
|
||||
exports.NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
|
||||
exports.NBD_OPT_EXPORT_NAME = 1
|
||||
exports.NBD_OPT_ABORT = 2
|
||||
exports.NBD_OPT_LIST = 3
|
||||
exports.NBD_OPT_STARTTLS = 5
|
||||
exports.NBD_OPT_INFO = 6
|
||||
exports.NBD_OPT_GO = 7
|
||||
|
||||
exports.NBD_FLAG_HAS_FLAGS = 1 << 0
|
||||
exports.NBD_FLAG_READ_ONLY = 1 << 1
|
||||
exports.NBD_FLAG_SEND_FLUSH = 1 << 2
|
||||
exports.NBD_FLAG_SEND_FUA = 1 << 3
|
||||
exports.NBD_FLAG_ROTATIONAL = 1 << 4
|
||||
exports.NBD_FLAG_SEND_TRIM = 1 << 5
|
||||
|
||||
exports.NBD_FLAG_FIXED_NEWSTYLE = 1 << 0
|
||||
|
||||
exports.NBD_CMD_FLAG_FUA = 1 << 0
|
||||
exports.NBD_CMD_FLAG_NO_HOLE = 1 << 1
|
||||
exports.NBD_CMD_FLAG_DF = 1 << 2
|
||||
exports.NBD_CMD_FLAG_REQ_ONE = 1 << 3
|
||||
exports.NBD_CMD_FLAG_FAST_ZERO = 1 << 4
|
||||
|
||||
exports.NBD_CMD_READ = 0
|
||||
exports.NBD_CMD_WRITE = 1
|
||||
exports.NBD_CMD_DISC = 2
|
||||
exports.NBD_CMD_FLUSH = 3
|
||||
exports.NBD_CMD_TRIM = 4
|
||||
exports.NBD_CMD_CACHE = 5
|
||||
exports.NBD_CMD_WRITE_ZEROES = 6
|
||||
exports.NBD_CMD_BLOCK_STATUS = 7
|
||||
exports.NBD_CMD_RESIZE = 8
|
||||
|
||||
exports.NBD_REQUEST_MAGIC = 0x25609513 // magic number to create a new NBD request to send to the server
|
||||
exports.NBD_REPLY_MAGIC = 0x67446698 // magic number received from the server when reading response to a nbd request
|
||||
exports.NBD_REPLY_ACK = 1
|
||||
|
||||
exports.NBD_DEFAULT_PORT = 10809
|
||||
exports.NBD_DEFAULT_BLOCK_SIZE = 64 * 1024
|
||||
41
@vates/nbd-client/constants.mjs
Normal file
41
@vates/nbd-client/constants.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
export const INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
|
||||
export const OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
|
||||
export const NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
|
||||
export const NBD_OPT_EXPORT_NAME = 1
|
||||
export const NBD_OPT_ABORT = 2
|
||||
export const NBD_OPT_LIST = 3
|
||||
export const NBD_OPT_STARTTLS = 5
|
||||
export const NBD_OPT_INFO = 6
|
||||
export const NBD_OPT_GO = 7
|
||||
|
||||
export const NBD_FLAG_HAS_FLAGS = 1 << 0
|
||||
export const NBD_FLAG_READ_ONLY = 1 << 1
|
||||
export const NBD_FLAG_SEND_FLUSH = 1 << 2
|
||||
export const NBD_FLAG_SEND_FUA = 1 << 3
|
||||
export const NBD_FLAG_ROTATIONAL = 1 << 4
|
||||
export const NBD_FLAG_SEND_TRIM = 1 << 5
|
||||
|
||||
export const NBD_FLAG_FIXED_NEWSTYLE = 1 << 0
|
||||
|
||||
export const NBD_CMD_FLAG_FUA = 1 << 0
|
||||
export const NBD_CMD_FLAG_NO_HOLE = 1 << 1
|
||||
export const NBD_CMD_FLAG_DF = 1 << 2
|
||||
export const NBD_CMD_FLAG_REQ_ONE = 1 << 3
|
||||
export const NBD_CMD_FLAG_FAST_ZERO = 1 << 4
|
||||
|
||||
export const NBD_CMD_READ = 0
|
||||
export const NBD_CMD_WRITE = 1
|
||||
export const NBD_CMD_DISC = 2
|
||||
export const NBD_CMD_FLUSH = 3
|
||||
export const NBD_CMD_TRIM = 4
|
||||
export const NBD_CMD_CACHE = 5
|
||||
export const NBD_CMD_WRITE_ZEROES = 6
|
||||
export const NBD_CMD_BLOCK_STATUS = 7
|
||||
export const NBD_CMD_RESIZE = 8
|
||||
|
||||
export const NBD_REQUEST_MAGIC = 0x25609513 // magic number to create a new NBD request to send to the server
|
||||
export const NBD_REPLY_MAGIC = 0x67446698 // magic number received from the server when reading response to a nbd request
|
||||
export const NBD_REPLY_ACK = 1
|
||||
|
||||
export const NBD_DEFAULT_PORT = 10809
|
||||
export const NBD_DEFAULT_BLOCK_SIZE = 64 * 1024
|
||||
@@ -1,8 +1,11 @@
|
||||
'use strict'
|
||||
const assert = require('node:assert')
|
||||
const { Socket } = require('node:net')
|
||||
const { connect } = require('node:tls')
|
||||
const {
|
||||
import assert from 'node:assert'
|
||||
import { Socket } from 'node:net'
|
||||
import { connect } from 'node:tls'
|
||||
import { fromCallback, pRetry, pDelay, pTimeout } from 'promise-toolbox'
|
||||
import { readChunkStrict } from '@vates/read-chunk'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
import {
|
||||
INIT_PASSWD,
|
||||
NBD_CMD_READ,
|
||||
NBD_DEFAULT_BLOCK_SIZE,
|
||||
@@ -17,16 +20,14 @@ const {
|
||||
NBD_REQUEST_MAGIC,
|
||||
OPTS_MAGIC,
|
||||
NBD_CMD_DISC,
|
||||
} = require('./constants.js')
|
||||
const { fromCallback, pRetry, pDelay, pTimeout } = require('promise-toolbox')
|
||||
const { readChunkStrict } = require('@vates/read-chunk')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
} from './constants.mjs'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
const { warn } = createLogger('vates:nbd-client')
|
||||
|
||||
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
|
||||
|
||||
module.exports = class NbdClient {
|
||||
export default class NbdClient {
|
||||
#serverAddress
|
||||
#serverCert
|
||||
#serverPort
|
||||
@@ -232,19 +233,20 @@ module.exports = class NbdClient {
|
||||
}
|
||||
try {
|
||||
this.#waitingForResponse = true
|
||||
const magic = await this.#readInt32()
|
||||
const buffer = await this.#read(8)
|
||||
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(1)
|
||||
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(4)
|
||||
const query = this.#commandQueryBacklog.get(blockQueryId)
|
||||
if (!query) {
|
||||
throw new Error(` no query associated with id ${blockQueryId}`)
|
||||
@@ -281,7 +283,13 @@ module.exports = 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 +315,15 @@ module.exports = 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 +357,15 @@ module.exports = class NbdClient {
|
||||
yield readAhead.shift()
|
||||
}
|
||||
}
|
||||
|
||||
stream(chunk_size) {
|
||||
async function* iterator() {
|
||||
for await (const chunk of this.readBlocks(chunk_size)) {
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
// create a readable stream instead of returning the iterator
|
||||
// since iterators don't like unshift and partial reading
|
||||
return Readable.from(iterator())
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
},
|
||||
"main": "./index.mjs",
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/read-chunk": "^1.1.1",
|
||||
@@ -31,6 +32,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test-integration": "tap --lines 97 --functions 95 --branches 74 --statements 97 tests/*.integ.js"
|
||||
"test-integration": "tap --lines 97 --functions 95 --branches 74 --statements 97 tests/*.integ.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use strict'
|
||||
const NbdClient = require('../index.js')
|
||||
const { spawn, exec } = require('node:child_process')
|
||||
const fs = require('node:fs/promises')
|
||||
const { test } = require('tap')
|
||||
const tmp = require('tmp')
|
||||
const { pFromCallback } = require('promise-toolbox')
|
||||
const { Socket } = require('node:net')
|
||||
const { NBD_DEFAULT_PORT } = require('../constants.js')
|
||||
const assert = require('node:assert')
|
||||
import NbdClient from '../index.mjs'
|
||||
import { spawn, exec } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import { test } from 'tap'
|
||||
import tmp from 'tmp'
|
||||
import { pFromCallback } from 'promise-toolbox'
|
||||
import { Socket } from 'node:net'
|
||||
import { NBD_DEFAULT_PORT } from '../constants.mjs'
|
||||
import assert from 'node:assert'
|
||||
|
||||
const FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
'use strict'
|
||||
/*
|
||||
|
||||
node-vsphere-soap
|
||||
@@ -12,17 +11,18 @@
|
||||
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events').EventEmitter
|
||||
const axios = require('axios')
|
||||
const https = require('node:https')
|
||||
const util = require('util')
|
||||
const soap = require('soap')
|
||||
const Cookie = require('soap-cookie') // required for session persistence
|
||||
import { EventEmitter } from 'events'
|
||||
import axios from 'axios'
|
||||
import https from 'node:https'
|
||||
import util from 'util'
|
||||
import soap from 'soap'
|
||||
import Cookie from 'soap-cookie' // required for session persistence
|
||||
|
||||
// Client class
|
||||
// inherits from EventEmitter
|
||||
// possible events: connect, error, ready
|
||||
|
||||
function Client(vCenterHostname, username, password, sslVerify) {
|
||||
export function Client(vCenterHostname, username, password, sslVerify) {
|
||||
this.status = 'disconnected'
|
||||
this.reconnectCount = 0
|
||||
|
||||
@@ -228,4 +228,3 @@ function _soapErrorHandler(self, emitter, command, args, err) {
|
||||
}
|
||||
|
||||
// end
|
||||
exports.Client = Client
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@vates/node-vsphere-soap",
|
||||
"version": "1.0.0",
|
||||
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
|
||||
"main": "lib/client.js",
|
||||
"main": "lib/client.mjs",
|
||||
"author": "reedog117",
|
||||
"repository": {
|
||||
"directory": "@vates/node-vsphere-soap",
|
||||
@@ -30,7 +30,7 @@
|
||||
"private": false,
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/node-vsphere-soap",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
"node": ">=14"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
'use strict'
|
||||
|
||||
// place your own credentials here for a vCenter or ESXi server
|
||||
// this information will be used for connecting to a vCenter instance
|
||||
// for module testing
|
||||
// name the file config-test.js
|
||||
|
||||
const vCenterTestCreds = {
|
||||
export const vCenterTestCreds = {
|
||||
vCenterIP: 'vcsa',
|
||||
vCenterUser: 'vcuser',
|
||||
vCenterPassword: 'vcpw',
|
||||
vCenter: true,
|
||||
}
|
||||
|
||||
exports.vCenterTestCreds = vCenterTestCreds
|
||||
@@ -1,18 +1,16 @@
|
||||
'use strict'
|
||||
|
||||
/*
|
||||
vsphere-soap.test.js
|
||||
|
||||
tests for the vCenterConnectionInstance class
|
||||
*/
|
||||
|
||||
const assert = require('assert')
|
||||
const { describe, it } = require('test')
|
||||
import assert from 'assert'
|
||||
import { describe, it } from 'test'
|
||||
|
||||
const vc = require('../lib/client')
|
||||
import * as vc from '../lib/client.mjs'
|
||||
|
||||
// eslint-disable-next-line n/no-missing-require
|
||||
const TestCreds = require('../config-test.js').vCenterTestCreds
|
||||
// eslint-disable-next-line n/no-missing-import
|
||||
import { vCenterTestCreds as TestCreds } from '../config-test.mjs'
|
||||
|
||||
const VItest = new vc.Client(TestCreds.vCenterIP, TestCreds.vCenterUser, TestCreds.vCenterPassword, false)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const isUtf8 = require('isutf8')
|
||||
|
||||
/**
|
||||
* Read a chunk of data from a stream.
|
||||
@@ -81,6 +82,13 @@ exports.readChunkStrict = async function readChunkStrict(stream, size) {
|
||||
|
||||
if (size !== undefined && chunk.length !== size) {
|
||||
const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`)
|
||||
|
||||
// Buffer.isUtf8 is too recent for now
|
||||
// @todo : replace external package by Buffer.isUtf8 when the supported version of node reach 18
|
||||
|
||||
if (chunk.length < 1024 && isUtf8(chunk)) {
|
||||
error.text = chunk.toString('utf8')
|
||||
}
|
||||
Object.defineProperties(error, {
|
||||
chunk: {
|
||||
value: chunk,
|
||||
|
||||
@@ -102,12 +102,37 @@ describe('readChunkStrict', function () {
|
||||
assert.strictEqual(error.chunk, undefined)
|
||||
})
|
||||
|
||||
it('throws if stream ends with not enough data', async () => {
|
||||
it('throws if stream ends with not enough data, utf8', async () => {
|
||||
const error = await rejectionOf(readChunkStrict(makeStream(['foo', 'bar']), 10))
|
||||
assert(error instanceof Error)
|
||||
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 6, expected: 10)')
|
||||
assert.strictEqual(error.text, 'foobar')
|
||||
assert.deepEqual(error.chunk, Buffer.from('foobar'))
|
||||
})
|
||||
|
||||
it('throws if stream ends with not enough data, non utf8 ', async () => {
|
||||
const source = [Buffer.alloc(10, 128), Buffer.alloc(10, 128)]
|
||||
const error = await rejectionOf(readChunkStrict(makeStream(source), 30))
|
||||
assert(error instanceof Error)
|
||||
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 20, expected: 30)')
|
||||
assert.strictEqual(error.text, undefined)
|
||||
assert.deepEqual(error.chunk, Buffer.concat(source))
|
||||
})
|
||||
|
||||
it('throws if stream ends with not enough data, utf8 , long data', async () => {
|
||||
const source = Buffer.from('a'.repeat(1500))
|
||||
const error = await rejectionOf(readChunkStrict(makeStream([source]), 2000))
|
||||
assert(error instanceof Error)
|
||||
assert.strictEqual(error.message, `stream has ended with not enough data (actual: 1500, expected: 2000)`)
|
||||
assert.strictEqual(error.text, undefined)
|
||||
assert.deepEqual(error.chunk, source)
|
||||
})
|
||||
|
||||
it('succeed', async () => {
|
||||
const source = Buffer.from('a'.repeat(20))
|
||||
const chunk = await readChunkStrict(makeStream([source]), 10)
|
||||
assert.deepEqual(source.subarray(10), chunk)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skip', function () {
|
||||
@@ -134,6 +159,16 @@ describe('skip', function () {
|
||||
it('returns less size if stream ends', async () => {
|
||||
assert.deepEqual(await skip(makeStream('foo bar'), 10), 7)
|
||||
})
|
||||
|
||||
it('put back if it read too much', async () => {
|
||||
let source = makeStream(['foo', 'bar'])
|
||||
await skip(source, 1) // read part of data chunk
|
||||
const chunk = (await readChunkStrict(source, 2)).toString('utf-8')
|
||||
assert.strictEqual(chunk, 'oo')
|
||||
|
||||
source = makeStream(['foo', 'bar'])
|
||||
assert.strictEqual(await skip(source, 3), 3) // read aligned with data chunk
|
||||
})
|
||||
})
|
||||
|
||||
describe('skipStrict', function () {
|
||||
@@ -144,4 +179,9 @@ describe('skipStrict', function () {
|
||||
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)')
|
||||
assert.deepEqual(error.bytesSkipped, 7)
|
||||
})
|
||||
it('succeed', async () => {
|
||||
const source = makeStream(['foo', 'bar', 'baz'])
|
||||
const res = await skipStrict(source, 4)
|
||||
assert.strictEqual(res, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,5 +33,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"isutf8": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('encryption', () => {
|
||||
// encrypt with a non default algorithm
|
||||
const encryptor = _getEncryptor('aes-256-cbc', '73c1838d7d8a6088ca2317fb5f29cd91')
|
||||
|
||||
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "aes-256-gmc"}`)
|
||||
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "aes-256-gcm"}`)
|
||||
await fs.writeFile(`${dir}/metadata.json`, encryptor.encryptData(`{"random": "NOTSORANDOM"}`))
|
||||
|
||||
// remote is now non empty : can't modify key anymore
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/marked": "^4.0.8",
|
||||
"@vueform/multiselect": "^2.6.2",
|
||||
"@vueuse/core": "^10.1.2",
|
||||
"@vueuse/math": "^10.1.2",
|
||||
"complex-matcher": "^0.7.0",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
@import "reset.css";
|
||||
@import "theme.css";
|
||||
@import "multi-select.css";
|
||||
/* TODO Serve fonts locally */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;0,900;1,400;1,500;1,600;1,700;1,900&display=swap");
|
||||
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
@import "@vueform/multiselect/themes/default.css";
|
||||
|
||||
:root {
|
||||
--ms-font-size: 0.8em;
|
||||
--ms-line-height: 1.375;
|
||||
--ms-bg: var(--background-color-primary);
|
||||
--ms-bg-disabled: var(--background-color-secondary);
|
||||
--ms-border-color: var(--color-blue-scale-400);
|
||||
--ms-border-width: 0.1rem;
|
||||
--ms-border-color-active: var(--color-extra-blue-base);
|
||||
--ms-border-width-active: 0.1rem;
|
||||
--ms-radius: 0.4em;
|
||||
--ms-py: 1.08em;
|
||||
--ms-px: 0.625em;
|
||||
--ms-ring-width: 0;
|
||||
--ms-ring-color: transparent;
|
||||
--ms-placeholder-color: var(--color-blue-scale-100);
|
||||
--ms-max-height: 35rem;
|
||||
|
||||
--ms-spinner-color: var(--color-green-infra-base);
|
||||
--ms-caret-color: var(--color-blue-scale-300);
|
||||
--ms-clear-color: var(--color-blue-scale-300);
|
||||
--ms-clear-color-hover: var(--color-blue-scale-100);
|
||||
|
||||
--ms-tag-font-size: 1em;
|
||||
--ms-tag-line-height: 150%;
|
||||
--ms-tag-font-weight: 400;
|
||||
--ms-tag-bg: var(--background-color-secondary);
|
||||
--ms-tag-bg-disabled: var(--color-grayscale-200);
|
||||
--ms-tag-color: var(--color-blue-scale-200);
|
||||
--ms-tag-color-disabled: var(--color-blue-scale-500);
|
||||
--ms-tag-radius: 0.4em;
|
||||
--ms-tag-py: 0.4rem;
|
||||
--ms-tag-px: 1.2rem;
|
||||
--ms-tag-my: 0.25rem;
|
||||
--ms-tag-mx: 0.5rem;
|
||||
|
||||
--ms-tag-remove-radius: 4rem;
|
||||
--ms-tag-remove-py: 0.5rem;
|
||||
--ms-tag-remove-px: 0.5rem;
|
||||
--ms-tag-remove-my: 0rem;
|
||||
--ms-tag-remove-mx: 0.5rem;
|
||||
|
||||
--ms-dropdown-bg: var(--background-color-primary);
|
||||
--ms-dropdown-border-color: var(--color-extra-blue-base);
|
||||
--ms-dropdown-border-width: 0.1rem;
|
||||
--ms-dropdown-radius: 0.8rem;
|
||||
|
||||
--ms-group-label-py: 0.5rem;
|
||||
--ms-group-label-px: 2rem;
|
||||
--ms-group-label-line-height: 1.375;
|
||||
--ms-group-label-bg: var(--background-color-secondary);
|
||||
--ms-group-label-color: var(--color-blue-scale-100);
|
||||
--ms-group-label-bg-pointed: var(--color-blue-scale-400);
|
||||
--ms-group-label-color-pointed: var(--color-blue-scale-200);
|
||||
--ms-group-label-bg-disabled: var(--color-blue-scale-200);
|
||||
--ms-group-label-color-disabled: var(--color-blue-scale-500);
|
||||
--ms-group-label-bg-selected: var(--color-green-infra-base);
|
||||
--ms-group-label-color-selected: var(--color-blue-scale-500);
|
||||
--ms-group-label-bg-selected-pointed: var(--color-green-infra-d20);
|
||||
--ms-group-label-color-selected-pointed: var(--color-blue-scale-500);
|
||||
--ms-group-label-bg-selected-disabled: var(--color-blue-scale-200);
|
||||
--ms-group-label-color-selected-disabled: var(--color-green-infra-base);
|
||||
|
||||
--ms-option-font-size: 1em;
|
||||
--ms-option-line-height: 1.375;
|
||||
--ms-option-bg-pointed: var(--background-color-secondary);
|
||||
--ms-option-color-pointed: var(--color-blue-scale-200);
|
||||
--ms-option-bg-selected: var(--background-color-primary);
|
||||
--ms-option-color-selected: var(--color-green-infra-base);
|
||||
--ms-option-bg-disabled: var(--background-color-primary);
|
||||
--ms-option-color-disabled: var(--color-blue-scale-400);
|
||||
--ms-option-bg-selected-pointed: var(--background-color-secondary);
|
||||
--ms-option-color-selected-pointed: var(--color-green-infra-base);
|
||||
--ms-option-bg-selected-disabled: var(--background-color-primary);
|
||||
--ms-option-color-selected-disabled: var(--color-blue-scale-300);
|
||||
--ms-option-py: 1rem;
|
||||
--ms-option-px: 2rem;
|
||||
|
||||
--ms-empty-color: var(--color-grayscale-200);
|
||||
}
|
||||
|
||||
.multiselect {
|
||||
min-width: 15rem;
|
||||
box-shadow: var(--shadow-100);
|
||||
|
||||
&:not(.is-disabled) {
|
||||
&.color-info {
|
||||
--ms-border-color: var(--color-blue-scale-400);
|
||||
--ms-border-color-active: var(--color-extra-blue-l40);
|
||||
|
||||
&:hover {
|
||||
--ms-border-color: var(--color-extra-blue-l60);
|
||||
}
|
||||
}
|
||||
|
||||
&.color-success {
|
||||
--ms-border-color: var(--color-green-infra-base);
|
||||
--ms-border-color-active: var(--color-green-infra-l40);
|
||||
|
||||
&:hover {
|
||||
--ms-border-color: var(--color-green-infra-l60);
|
||||
}
|
||||
}
|
||||
|
||||
&.color-warning {
|
||||
--ms-border-color: var(--color-orange-world-base);
|
||||
--ms-border-color-active: var(--color-orange-world-l40);
|
||||
|
||||
&:hover {
|
||||
--ms-border-color: var(--color-orange-world-l60);
|
||||
}
|
||||
}
|
||||
|
||||
&.color-error {
|
||||
--ms-border-color: var(--color-red-vates-base);
|
||||
--ms-border-color-active: var(--color-red-vates-l40);
|
||||
|
||||
&:hover {
|
||||
--ms-border-color: var(--color-red-vates-l60);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .multiselect-group-label {
|
||||
font-size: 1.2rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
& .caret-icon {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
margin: 0 var(--ms-px, 0.875rem) 0 0;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
transform: rotateX(0deg);
|
||||
z-index: 10;
|
||||
color: var(--ms-caret-color);
|
||||
}
|
||||
|
||||
&.is-open .caret-icon {
|
||||
transform: rotateX(180deg);
|
||||
}
|
||||
|
||||
& .multiselect-tag-remove {
|
||||
color: var(--background-color-secondary);
|
||||
background-color: var(--color-blue-scale-200);
|
||||
}
|
||||
|
||||
& .multiselect-tag-remove:hover {
|
||||
background-color: var(--color-red-vates-l40);
|
||||
}
|
||||
|
||||
& .multiselect-tag-remove-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
& .multiselect-search,
|
||||
& .multiselect-tags-search {
|
||||
color: var(--color-blue-scale-100);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
@@ -121,15 +121,7 @@ import {
|
||||
import { faSliders } from "@fortawesome/free-solid-svg-icons";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import { uniqueId, upperFirst } from "lodash-es";
|
||||
import {
|
||||
computed,
|
||||
effectScope,
|
||||
onBeforeUnmount,
|
||||
reactive,
|
||||
ref,
|
||||
watch,
|
||||
watchEffect,
|
||||
} from "vue";
|
||||
import { computed, reactive, ref, watch, watchEffect } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const tab = (tab: TAB, params: Param[]) =>
|
||||
@@ -186,24 +178,6 @@ if (propParams.value.length !== 0) {
|
||||
}
|
||||
|
||||
const propValues = ref<Record<string, any>>({});
|
||||
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
for (const param of props.params) {
|
||||
if (!isPropParam(param) || !param.hasChangeHandler()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => propValues.value[param.name],
|
||||
(value) => param.getOnChangeHandler()?.(value, propValues.value)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => scope.stop());
|
||||
|
||||
const settingValues = ref<Record<string, any>>({});
|
||||
const eventsLog = ref<
|
||||
{ id: string; name: string; args: { name: string; value: any }[] }[]
|
||||
@@ -262,12 +236,8 @@ const eventLogRows = computed(() => {
|
||||
const slotProperties = computed(() => {
|
||||
const properties: Record<string, any> = {};
|
||||
|
||||
propParams.value.forEach((param) => {
|
||||
const value = propValues.value[param.name];
|
||||
|
||||
if (param.isRequired() || value !== undefined) {
|
||||
properties[param.name] = value;
|
||||
}
|
||||
propParams.value.forEach(({ name }) => {
|
||||
properties[name] = propValues.value[name];
|
||||
});
|
||||
|
||||
eventParams.value.forEach((eventParam) => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<th><!-- Reset Default --></th>
|
||||
<th><!-- Widget --></th>
|
||||
<th>Default</th>
|
||||
<th><!-- Help --></th>
|
||||
<th>Help</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tfoot>
|
||||
@@ -78,11 +78,7 @@
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="help">
|
||||
<UiIcon
|
||||
v-if="param.getHelp()"
|
||||
v-tooltip="param.getHelp()"
|
||||
:icon="faInfoCircle"
|
||||
/>
|
||||
{{ param.getHelp() }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -99,11 +95,7 @@ import useModal from "@/composables/modal.composable";
|
||||
import useSortedCollection from "@/composables/sorted-collection.composable";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
import type { PropParam } from "@/libs/story/story-param";
|
||||
import {
|
||||
faClose,
|
||||
faInfoCircle,
|
||||
faRepeat,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { faClose, faRepeat } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import { toRef } from "vue";
|
||||
|
||||
@@ -176,7 +168,6 @@ const {
|
||||
.help {
|
||||
font-style: italic;
|
||||
color: var(--color-blue-scale-200);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.default-value {
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
<template>
|
||||
<div class="story-widget">
|
||||
<div v-if="isSelectWidget(widget)">
|
||||
<FormSelect :options="widget.choices" v-model="model" />
|
||||
</div>
|
||||
<div v-else-if="isRadioWidget(widget)" class="radio">
|
||||
<FormInputWrapper v-for="choice in widget.choices" :key="choice.label">
|
||||
<FormRadio v-model="model" :value="choice.value" />
|
||||
{{ choice.label }}
|
||||
</FormInputWrapper>
|
||||
</div>
|
||||
<div v-else-if="isBooleanWidget(widget)">
|
||||
<FormCheckbox v-model="model" />
|
||||
</div>
|
||||
<FormInput
|
||||
v-else-if="isNumberWidget(widget)"
|
||||
v-model.number="model"
|
||||
type="number"
|
||||
/>
|
||||
<FormInput v-else-if="isTextWidget(widget)" v-model="model" />
|
||||
<FormJson v-else-if="isObjectWidget(widget)" v-model="model" />
|
||||
<FormSelect
|
||||
v-if="isSelectWidget(widget)"
|
||||
v-model="model"
|
||||
:wrapper-attrs="{ class: 'full-width' }"
|
||||
>
|
||||
<option v-if="!required && model === undefined" :value="undefined" />
|
||||
<option
|
||||
v-for="choice in widget.choices"
|
||||
:key="choice.label"
|
||||
:value="choice.value"
|
||||
>
|
||||
{{ choice.label }}
|
||||
</option>
|
||||
</FormSelect>
|
||||
<div v-else-if="isRadioWidget(widget)" class="radio">
|
||||
<FormInputWrapper v-for="choice in widget.choices" :key="choice.label">
|
||||
<FormRadio v-model="model" :value="choice.value" />
|
||||
{{ choice.label }}
|
||||
</FormInputWrapper>
|
||||
</div>
|
||||
<div v-else-if="isBooleanWidget(widget)">
|
||||
<FormCheckbox v-model="model" />
|
||||
</div>
|
||||
<FormInput
|
||||
v-else-if="isNumberWidget(widget)"
|
||||
v-model.number="model"
|
||||
type="number"
|
||||
/>
|
||||
<FormInput v-else-if="isTextWidget(widget)" v-model="model" />
|
||||
<FormJson v-else-if="isObjectWidget(widget)" v-model="model" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -73,11 +82,9 @@ const model = useVModel(props, "modelValue", emit);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.story-widget {
|
||||
&:deep(.form-select),
|
||||
&:deep(.form-input),
|
||||
&:deep(.form-json) {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.form-select,
|
||||
.form-input,
|
||||
.form-json {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -157,7 +157,7 @@ defineExpose({
|
||||
max-width: 30em;
|
||||
|
||||
--before-width: v-bind('beforeWidth || "1.75em"');
|
||||
--after-width: v-bind('afterWidth || "1.75em"');
|
||||
--after-width: v-bind('afterWidth || "1.625em"');
|
||||
--caret-width: 1.5em;
|
||||
|
||||
--text-color: var(--color-blue-scale-100);
|
||||
@@ -187,9 +187,9 @@ defineExpose({
|
||||
.input,
|
||||
.textarea,
|
||||
.select {
|
||||
font-size: 0.8em;
|
||||
font-size: 1em;
|
||||
width: 100%;
|
||||
height: 3.5em;
|
||||
height: 3em;
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
border: 0.05em solid var(--border-color);
|
||||
@@ -292,11 +292,11 @@ defineExpose({
|
||||
padding-left: 0.625em;
|
||||
|
||||
&.has-before {
|
||||
padding-left: calc(var(--before-width) + 0.6em);
|
||||
padding-left: calc(var(--before-width) + 0.25em);
|
||||
}
|
||||
|
||||
&.has-after {
|
||||
padding-right: calc(var(--after-width) + 0.6em);
|
||||
padding-right: calc(var(--after-width) + 0.25em);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&:deep(.form-input),
|
||||
&:deep(.form-select) {
|
||||
:slotted(.form-input),
|
||||
:slotted(.form-select) {
|
||||
&:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -23,8 +23,7 @@
|
||||
margin-left: -1px;
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.multiselect {
|
||||
.select {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
@@ -32,8 +31,7 @@
|
||||
|
||||
&:not(:last-child) {
|
||||
.input,
|
||||
.select,
|
||||
.multiselect {
|
||||
.select {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import type { Color } from "@/types";
|
||||
import {
|
||||
IK_FORM_HAS_LABEL,
|
||||
IK_FORM_INPUT_COLOR,
|
||||
IK_FORM_LABEL_DISABLED,
|
||||
IK_INPUT_ID,
|
||||
} from "@/types/injection-keys";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
@@ -58,6 +59,7 @@ const props = defineProps<{
|
||||
warning?: string;
|
||||
error?: string;
|
||||
help?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const id = computed(() => props.id ?? uniqueId("form-input-"));
|
||||
@@ -81,6 +83,11 @@ provide(
|
||||
IK_FORM_HAS_LABEL,
|
||||
computed(() => slots.label !== undefined)
|
||||
);
|
||||
|
||||
provide(
|
||||
IK_FORM_LABEL_DISABLED,
|
||||
computed(() => props.disabled ?? false)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -1,93 +1,15 @@
|
||||
<template>
|
||||
<span class="form-select">
|
||||
<MultiSelect
|
||||
v-model="modelValue"
|
||||
:can-clear="clearable"
|
||||
:class="colorClass"
|
||||
:close-on-deselect="!multiple"
|
||||
:close-on-select="!multiple"
|
||||
:groups="isGrouped"
|
||||
:hide-selected="false"
|
||||
:label="labelKey"
|
||||
:mode="multiple ? 'multiple' : 'single'"
|
||||
:multiple-label="getMultipleLabel"
|
||||
:no-options-text="$t('no-options-available')"
|
||||
:no-results-text="$t('no-results-found')"
|
||||
:options="options"
|
||||
:track-by="labelKey"
|
||||
:value-prop="valueKey"
|
||||
:object="object"
|
||||
:disabled="busy || disabled"
|
||||
:searchable="options.length > SEARCHABLE_THRESHOLD"
|
||||
:loading="busy"
|
||||
>
|
||||
<template #caret>
|
||||
<UiIcon :icon="faAngleDown" class="caret-icon" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</span>
|
||||
<FormInput>
|
||||
<slot />
|
||||
</FormInput>
|
||||
</template>
|
||||
|
||||
<script generic="T extends XenApiRecord<string>" lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import type { XenApiRecord } from "@/libs/xen-api";
|
||||
import type { Color } from "@/types";
|
||||
import { IK_FORM_INPUT_COLOR } from "@/types/injection-keys";
|
||||
import { faAngleDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import MultiSelect from "@vueform/multiselect";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import { computed, inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
<script lang="ts" setup>
|
||||
import FormInput from "@/components/form/FormInput.vue";
|
||||
import { IK_INPUT_TYPE } from "@/types/injection-keys";
|
||||
import { provide } from "vue";
|
||||
|
||||
const SEARCHABLE_THRESHOLD = 10;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: any;
|
||||
multiple?: boolean;
|
||||
options: { label: string; options: T[] }[] | T[];
|
||||
labelKey?: string;
|
||||
valueKey?: string;
|
||||
clearable?: boolean;
|
||||
color?: Color;
|
||||
object?: boolean;
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
}>(),
|
||||
{
|
||||
labelKey: "label",
|
||||
valueKey: "value",
|
||||
color: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: any): void;
|
||||
}>();
|
||||
|
||||
const isGrouped = computed(() => {
|
||||
const option = props.options[0];
|
||||
return "object" === typeof option && "options" in option && "label" in option;
|
||||
});
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emit);
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const getMultipleLabel = (values: any[]) =>
|
||||
i18n.t("n-options-selected", { n: values.length });
|
||||
|
||||
const parentColor = inject(IK_FORM_INPUT_COLOR, undefined);
|
||||
|
||||
const colorClass = computed(() => {
|
||||
const color = props.color ?? parentColor?.value ?? "info";
|
||||
|
||||
return `color-${color}`;
|
||||
});
|
||||
provide(IK_INPUT_TYPE, "select");
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.form-select {
|
||||
font-size: 2rem;
|
||||
}
|
||||
</style>
|
||||
<style lang="postcss" scoped></style>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<template>
|
||||
<span class="form-tag">
|
||||
<MultiSelect
|
||||
v-model="modelValue"
|
||||
:class="colorClass"
|
||||
:create-option="allowNew"
|
||||
:no-options-text="$t('no-options-available')"
|
||||
:no-results-text="$t('no-results-found')"
|
||||
:on-create="handleCreate"
|
||||
:options="options"
|
||||
:searchable="allowNew || options.length > SEARCHABLE_THRESHOLD"
|
||||
mode="tags"
|
||||
@deselect="($event) => handleDeselect($event as string)"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template #caret>
|
||||
<UiIcon :icon="faAngleDown" class="caret-icon" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import type { Color } from "@/types";
|
||||
import { IK_FORM_INPUT_COLOR } from "@/types/injection-keys";
|
||||
import { faAngleDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import MultiSelect from "@vueform/multiselect";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import { computed, inject } from "vue";
|
||||
|
||||
const SEARCHABLE_THRESHOLD = 10;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: any;
|
||||
createdTags?: string[];
|
||||
options: string[];
|
||||
allowNew?: boolean;
|
||||
color?: Color;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{ createdTags: () => [] }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: string[]): void;
|
||||
(event: "update:createdTags", value: string[]): void;
|
||||
}>();
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emit);
|
||||
|
||||
const handleCreate = (tag: { label: string; value: string }) => {
|
||||
emit("update:createdTags", [...props.createdTags, tag.value]);
|
||||
return tag;
|
||||
};
|
||||
|
||||
const handleDeselect = (value: string) => {
|
||||
if (!props.allowNew) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit(
|
||||
"update:createdTags",
|
||||
props.createdTags.filter((t) => t !== value)
|
||||
);
|
||||
};
|
||||
|
||||
const parentColor = inject(IK_FORM_INPUT_COLOR, undefined);
|
||||
|
||||
const colorClass = computed(() => {
|
||||
const color = props.color ?? parentColor?.value ?? "info";
|
||||
|
||||
return `color-${color}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.form-tag {
|
||||
font-size: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,31 +0,0 @@
|
||||
<template>
|
||||
<FormSelect
|
||||
object
|
||||
v-model="modelValue"
|
||||
:color="color"
|
||||
:multiple="multiple"
|
||||
:options="options"
|
||||
label-key="name_label"
|
||||
value-key="$ref"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script generic="T extends XenApiRecord<string>" lang="ts" setup>
|
||||
import FormSelect from "@/components/form/FormSelect.vue";
|
||||
import type { XenApiRecord } from "@/libs/xen-api";
|
||||
import type { Color } from "@/types";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
multiple?: boolean;
|
||||
options: { label: string; options: T[] }[] | T[];
|
||||
color?: Color;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: any): void;
|
||||
}>();
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emit);
|
||||
</script>
|
||||
@@ -102,26 +102,12 @@ export class PropParam extends mixin(BaseParam, WithWidget, WithType) {
|
||||
#isRequired = false;
|
||||
#defaultValue: any;
|
||||
#isVModel: boolean;
|
||||
#onChangeHandler: ((value: any, context: object) => void) | undefined;
|
||||
|
||||
constructor(name: string, isVModel = false) {
|
||||
super(name);
|
||||
this.#isVModel = isVModel;
|
||||
}
|
||||
|
||||
onChange(handler: (value: any, context: object) => void) {
|
||||
this.#onChangeHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
hasChangeHandler() {
|
||||
return this.#onChangeHandler !== undefined;
|
||||
}
|
||||
|
||||
getOnChangeHandler() {
|
||||
return this.#onChangeHandler;
|
||||
}
|
||||
|
||||
isRequired() {
|
||||
return this.#isRequired;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"log-out": "Log out",
|
||||
"login": "Login",
|
||||
"migrate": "Migrate",
|
||||
"n-options-selected": "{n} option selected | {n} options selected",
|
||||
"n-vms": "1 VM | {n} VMs",
|
||||
"name": "Name",
|
||||
"network": "Network",
|
||||
@@ -78,8 +77,6 @@
|
||||
"news": "News",
|
||||
"news-name": "{name} news",
|
||||
"new-features-are-coming": "New features are coming soon!",
|
||||
"no-options-available": "No options available",
|
||||
"no-results-found": "No results found",
|
||||
"object": "Object",
|
||||
"object-not-found": "Object {id} can't be found…",
|
||||
"or": "Or",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"log-out": "Se déconnecter",
|
||||
"login": "Connexion",
|
||||
"migrate": "Migrer",
|
||||
"n-options-selected": "{n} option sélectionnée | {n} options sélectionnées",
|
||||
"n-vms": "1 VM | {n} VMs",
|
||||
"name": "Nom",
|
||||
"network": "Réseau",
|
||||
@@ -78,8 +77,6 @@
|
||||
"news": "Actualités",
|
||||
"news-name": "Actualités {name}",
|
||||
"new-features-are-coming": "De nouvelles fonctionnalités arrivent bientôt !",
|
||||
"no-options-available": "Aucune option disponible",
|
||||
"no-results-found": "Aucun résultat trouvé",
|
||||
"object": "Objet",
|
||||
"object-not-found": "L'objet {id} est introuvable…",
|
||||
"or": "Ou",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
```vue-template
|
||||
<FormInputGroup>
|
||||
<FormInput ... />
|
||||
<FormInput ... />
|
||||
<FormSelect ... />
|
||||
<FormInput />
|
||||
<FormInput />
|
||||
<FormSelect>
|
||||
<option>Option 1</option>
|
||||
<option>Option 2</option>
|
||||
<option>Option 3</option>
|
||||
</FormSelect>
|
||||
</FormInputGroup>
|
||||
```
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
:params="[slot().help('Can contains multiple FormInput and FormSelect')]"
|
||||
>
|
||||
<FormInputGroup>
|
||||
<FormInput v-model="model" />
|
||||
<FormInput v-model="model" />
|
||||
<FormSelect
|
||||
v-model="model"
|
||||
:options="['Option 1', 'Option 2', 'Option 3']"
|
||||
/>
|
||||
<FormInput />
|
||||
<FormInput />
|
||||
<FormSelect>
|
||||
<option>Option 1</option>
|
||||
<option>Option 2</option>
|
||||
<option>Option 3</option>
|
||||
</FormSelect>
|
||||
</FormInputGroup>
|
||||
</ComponentStory>
|
||||
</template>
|
||||
@@ -19,7 +20,4 @@ import FormInput from "@/components/form/FormInput.vue";
|
||||
import FormInputGroup from "@/components/form/FormInputGroup.vue";
|
||||
import FormSelect from "@/components/form/FormSelect.vue";
|
||||
import { slot } from "@/libs/story/story-param";
|
||||
import { ref } from "vue";
|
||||
|
||||
const model = ref("");
|
||||
</script>
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# `options` prop
|
||||
|
||||
## Array of strings
|
||||
|
||||
```ts
|
||||
const options = ["Option 1", "Option 2", "Option 3"];
|
||||
```
|
||||
|
||||
## Array of objects
|
||||
|
||||
```ts
|
||||
const options = [
|
||||
{ label: "Option 1", value: "option1" },
|
||||
{ label: "Option 2", value: "option2" },
|
||||
{ label: "Option 3", value: "option3" },
|
||||
];
|
||||
```
|
||||
|
||||
### Custom properties
|
||||
|
||||
When not using `label` and `value` properties, you can change them with `label-key` and `value-key` props.
|
||||
|
||||
```ts
|
||||
const options = [
|
||||
{ name: "Option 1", id: "option1" },
|
||||
{ name: "Option 2", id: "option2" },
|
||||
{ name: "Option 3", id: "option3" },
|
||||
];
|
||||
```
|
||||
|
||||
```html
|
||||
<FormSelect :options="options" label-key="name" value-key="id" />
|
||||
```
|
||||
|
||||
## Array of groups
|
||||
|
||||
```ts
|
||||
const options = [
|
||||
{
|
||||
label: "Group 1",
|
||||
options: [
|
||||
{ label: "Option 1", value: "option1" },
|
||||
{ label: "Option 2", value: "option2" },
|
||||
{ label: "Option 3", value: "option3" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Group 2",
|
||||
options: [
|
||||
{ label: "Option 4", value: "option4" },
|
||||
{ label: "Option 5", value: "option5" },
|
||||
{ label: "Option 6", value: "option6" },
|
||||
],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
# `object` prop
|
||||
|
||||
```ts
|
||||
const options = [
|
||||
{ label: "Option 1", value: "option1" },
|
||||
{ label: "Option 2", value: "option2" },
|
||||
{ label: "Option 3", value: "option3" },
|
||||
];
|
||||
```
|
||||
|
||||
By default, when selection "Option 2", the value sent to `v-model` will be `option2`.
|
||||
|
||||
If you want to send the whole object, you can use `object` prop.
|
||||
|
||||
In this case, the value sent to `v-model` will be `{ label: 'Option 2', value: 'option2' }`.
|
||||
@@ -1,60 +0,0 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
v-slot="{ properties }"
|
||||
:params="[
|
||||
model().required().type('any'),
|
||||
prop('options').required().arr().preset(options),
|
||||
prop('multiple').bool().widget().onChange(handleMultipleChange),
|
||||
prop('labelKey')
|
||||
.default('label')
|
||||
.str()
|
||||
.help(
|
||||
'If `options` is an array of objects, item label will be extracted from this key'
|
||||
),
|
||||
prop('valueKey')
|
||||
.default('value')
|
||||
.str()
|
||||
.help(
|
||||
'If `options` is an array of objects, item value will be extracted from this key'
|
||||
),
|
||||
prop('clearable')
|
||||
.bool()
|
||||
.widget()
|
||||
.help('When true, adds a clear button on the right side of the select'),
|
||||
colorProp(),
|
||||
prop('disabled').bool().widget(),
|
||||
prop('object')
|
||||
.bool()
|
||||
.widget()
|
||||
.help(
|
||||
'If `options` is an array of objects, the whole object will be selected instead of only the value'
|
||||
),
|
||||
]"
|
||||
>
|
||||
<FormSelect v-if="isActive" v-bind="properties" />
|
||||
</ComponentStory>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ComponentStory from "@/components/component-story/ComponentStory.vue";
|
||||
import FormSelect from "@/components/form/FormSelect.vue";
|
||||
import { colorProp, model, prop } from "@/libs/story/story-param";
|
||||
import { nextTick, ref } from "vue";
|
||||
|
||||
const options = [
|
||||
{ label: "Option 1", value: "1" },
|
||||
{ label: "Option 2", value: "2" },
|
||||
{ label: "Option 3", value: "3" },
|
||||
];
|
||||
|
||||
// Workaround to prevent errors when `multiple` changes
|
||||
const isActive = ref(true);
|
||||
|
||||
const handleMultipleChange = (isMultiple, context) => {
|
||||
isActive.value = false;
|
||||
context.modelValue = isMultiple ? [] : null;
|
||||
nextTick(() => {
|
||||
isActive.value = true;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -1,31 +0,0 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
v-slot="{ properties }"
|
||||
:params="[
|
||||
model()
|
||||
.required()
|
||||
.type('string[]')
|
||||
.help('List of selected tags (including created ones)'),
|
||||
model('createdTags').type('string[]').help('List of created tags'),
|
||||
prop('options')
|
||||
.required()
|
||||
.arr('string')
|
||||
.preset(availableTags)
|
||||
.help('List of available tags')
|
||||
.widget(object()),
|
||||
prop('allowNew').bool().help('Allow to create new tags').widget(),
|
||||
colorProp(),
|
||||
]"
|
||||
>
|
||||
<FormTag v-bind="properties" />
|
||||
</ComponentStory>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ComponentStory from "@/components/component-story/ComponentStory.vue";
|
||||
import FormTag from "@/components/form/FormTag.vue";
|
||||
import { colorProp, model, prop } from "@/libs/story/story-param";
|
||||
import { object } from "@/libs/story/story-widget";
|
||||
|
||||
const availableTags = ["First tag", "Second tag", "Third tag"];
|
||||
</script>
|
||||
@@ -1,6 +0,0 @@
|
||||
```typescript
|
||||
type XenApiRecordGroup = {
|
||||
label: string;
|
||||
options: XenApiRecord[];
|
||||
}[];
|
||||
```
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
v-slot="{ properties }"
|
||||
:params="[
|
||||
model().type('XenApiRecord').required(),
|
||||
prop('multiple').bool().widget().onChange(handleMultipleChange),
|
||||
prop('options')
|
||||
.required()
|
||||
.arr()
|
||||
.type('XenApiRecord[] | XenApiRecordGroup[]')
|
||||
.widget()
|
||||
.preset(options),
|
||||
colorProp(),
|
||||
prop('disabled').bool().widget(),
|
||||
]"
|
||||
>
|
||||
<FormXapiRecord v-if="isActive" v-bind="properties" />
|
||||
</ComponentStory>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ComponentStory from "@/components/component-story/ComponentStory.vue";
|
||||
import FormXapiRecord from "@/components/form/FormXapiRecord.vue";
|
||||
import { colorProp, model, prop } from "@/libs/story/story-param";
|
||||
import { nextTick, ref } from "vue";
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: "ISOs - Storage Lab",
|
||||
options: [
|
||||
{
|
||||
$ref: "1",
|
||||
name_label: "AlmaLinux-8.3-x86_64-minimal.iso",
|
||||
},
|
||||
{
|
||||
$ref: "2",
|
||||
name_label: "AlmaLinux-8.5-x86_64-boot.iso",
|
||||
},
|
||||
{ $ref: "3", name_label: "CentOS-6.10-i386-minimal.iso" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "XCP-ng Tools - XO Lab",
|
||||
options: [{ $ref: "4", name_label: "guest-tools.iso" }],
|
||||
},
|
||||
];
|
||||
|
||||
// Workaround to prevent errors when `multiple` is changed
|
||||
const isActive = ref(true);
|
||||
|
||||
const handleMultipleChange = (isMultiple, context) => {
|
||||
isActive.value = false;
|
||||
context.modelValue = isMultiple ? [] : null;
|
||||
nextTick(() => {
|
||||
isActive.value = true;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -4,7 +4,12 @@
|
||||
|
||||
<div class="row">
|
||||
Choose a component
|
||||
<FormSelect v-model="componentPath" :options="componentPaths" />
|
||||
<FormSelect v-model="componentPath">
|
||||
<option value="" />
|
||||
<option v-for="path in componentPaths" :key="path">
|
||||
{{ path }}
|
||||
</option>
|
||||
</FormSelect>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
|
||||
@@ -48,7 +48,7 @@ export default class VhdEsxiCowd extends VhdAbstract {
|
||||
|
||||
// depending on the paramters we also look into the parent data
|
||||
return (
|
||||
this.#grainDirectory.readInt32LE(blockId * 4) !== 0 ||
|
||||
this.#grainDirectory.readUInt32LE(blockId * 4) !== 0 ||
|
||||
(this.#lookMissingBlockInParent && this.#parentVhd.containsBlock(blockId))
|
||||
)
|
||||
}
|
||||
@@ -61,14 +61,14 @@ export default class VhdEsxiCowd extends VhdAbstract {
|
||||
const buffer = await this.#read(0, 2048)
|
||||
|
||||
strictEqual(buffer.slice(0, 4).toString('ascii'), 'COWD')
|
||||
strictEqual(buffer.readInt32LE(4), 1) // version
|
||||
strictEqual(buffer.readInt32LE(8), 3) // flags
|
||||
const numSectors = buffer.readInt32LE(12)
|
||||
const grainSize = buffer.readInt32LE(16)
|
||||
strictEqual(buffer.readUInt32LE(4), 1) // version
|
||||
strictEqual(buffer.readUInt32LE(8), 3) // flags
|
||||
const numSectors = buffer.readUInt32LE(12)
|
||||
const grainSize = buffer.readUInt32LE(16)
|
||||
strictEqual(grainSize, 1) // 1 grain should be 1 sector long
|
||||
strictEqual(buffer.readInt32LE(20), 4) // grain directory position in sectors
|
||||
strictEqual(buffer.readUInt32LE(20), 4) // grain directory position in sectors
|
||||
|
||||
const nbGrainDirectoryEntries = buffer.readInt32LE(24)
|
||||
const nbGrainDirectoryEntries = buffer.readUInt32LE(24)
|
||||
strictEqual(nbGrainDirectoryEntries, Math.ceil(numSectors / 4096))
|
||||
const size = numSectors * 512
|
||||
// a grain directory entry contains the address of a grain table
|
||||
@@ -90,7 +90,7 @@ export default class VhdEsxiCowd extends VhdAbstract {
|
||||
// we're lucky : a grain address can address exacty a full block
|
||||
async readBlock(blockId) {
|
||||
notEqual(this.#grainDirectory, undefined, 'grainDirectory is not loaded')
|
||||
const sectorOffset = this.#grainDirectory.readInt32LE(blockId * 4)
|
||||
const sectorOffset = this.#grainDirectory.readUInt32LE(blockId * 4)
|
||||
|
||||
const buffer = (await this.#parentVhd.readBlock(blockId)).buffer
|
||||
|
||||
@@ -137,7 +137,7 @@ export default class VhdEsxiCowd extends VhdAbstract {
|
||||
}
|
||||
|
||||
for (let i = 0; i < graintable.length / 4; i++) {
|
||||
const grainOffset = graintable.readInt32LE(i * 4)
|
||||
const grainOffset = graintable.readUInt32LE(i * 4)
|
||||
if (grainOffset === 0) {
|
||||
// the content from parent : it is already in buffer
|
||||
await changeRange()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import _computeGeometryForSize from 'vhd-lib/_computeGeometryForSize.js'
|
||||
import { createFooter, createHeader } from 'vhd-lib/_createFooterHeader.js'
|
||||
import { DISK_TYPES, FOOTER_SIZE } from 'vhd-lib/_constants.js'
|
||||
import { readChunk } from '@vates/read-chunk'
|
||||
import { readChunkStrict, skipStrict } from '@vates/read-chunk'
|
||||
import { Task } from '@vates/task'
|
||||
import { unpackFooter, unpackHeader } from 'vhd-lib/Vhd/_utils.js'
|
||||
import { VhdAbstract } from 'vhd-lib'
|
||||
@@ -21,6 +21,10 @@ export default class VhdEsxiRaw extends VhdAbstract {
|
||||
#header
|
||||
#footer
|
||||
|
||||
#streamOffset = 0
|
||||
#stream
|
||||
#reading = false
|
||||
|
||||
static async open(esxi, datastore, path, opts) {
|
||||
const vhd = new VhdEsxiRaw(esxi, datastore, path, opts)
|
||||
await vhd.readHeaderAndFooter()
|
||||
@@ -49,10 +53,10 @@ export default class VhdEsxiRaw extends VhdAbstract {
|
||||
|
||||
this.#header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
|
||||
const geometry = _computeGeometryForSize(length)
|
||||
const actualSize = geometry.actualSize
|
||||
|
||||
this.#footer = unpackFooter(
|
||||
createFooter(actualSize, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
|
||||
// length can be smaller than disk capacity due to alignment to head/cylinder/sector
|
||||
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,12 +68,65 @@ export default class VhdEsxiRaw extends VhdAbstract {
|
||||
return this.#bat.has(blockId)
|
||||
}
|
||||
|
||||
async readBlock(blockId) {
|
||||
async #readChunk(start, length) {
|
||||
if (this.#reading) {
|
||||
throw new Error('reading must be done sequentially')
|
||||
}
|
||||
try {
|
||||
this.#reading = true
|
||||
if (this.#stream !== undefined) {
|
||||
// stream is too far ahead or to far behind
|
||||
if (this.#streamOffset > start || this.#streamOffset + VHD_BLOCK_LENGTH < start) {
|
||||
this.#stream.destroy()
|
||||
this.#stream = undefined
|
||||
this.#streamOffset = 0
|
||||
}
|
||||
}
|
||||
// no stream
|
||||
if (this.#stream === undefined) {
|
||||
const end = this.footer.currentSize - 1
|
||||
const res = await this.#esxi.download(this.#datastore, this.#path, `${start}-${end}`)
|
||||
this.#stream = res.body
|
||||
this.#streamOffset = start
|
||||
}
|
||||
|
||||
// stream a little behind
|
||||
if (this.#streamOffset < start) {
|
||||
await skipStrict(this.#stream, start - this.#streamOffset)
|
||||
this.#streamOffset = start
|
||||
}
|
||||
|
||||
// really read data
|
||||
this.#streamOffset += length
|
||||
const data = await readChunkStrict(this.#stream, length)
|
||||
return data
|
||||
} catch (error) {
|
||||
error.start = start
|
||||
error.length = length
|
||||
error.streamLength = this.footer.currentSize
|
||||
this.#stream?.destroy()
|
||||
this.#stream = undefined
|
||||
this.#streamOffset = 0
|
||||
throw error
|
||||
} finally {
|
||||
this.#reading = false
|
||||
}
|
||||
}
|
||||
|
||||
async #readBlock(blockId) {
|
||||
const start = blockId * VHD_BLOCK_LENGTH
|
||||
const end = (blockId + 1) * VHD_BLOCK_LENGTH - 1
|
||||
let length = VHD_BLOCK_LENGTH
|
||||
let partial = false
|
||||
if (start + length > this.footer.currentSize) {
|
||||
length = this.footer.currentSize - start
|
||||
partial = true
|
||||
}
|
||||
|
||||
const data = await (await this.#esxi.download(this.#datastore, this.#path, `${start}-${end}`)).buffer()
|
||||
let data = await this.#readChunk(start, length)
|
||||
|
||||
if (partial) {
|
||||
data = Buffer.concat([data, Buffer.alloc(VHD_BLOCK_LENGTH - data.length)])
|
||||
}
|
||||
const bitmap = Buffer.alloc(512, 255)
|
||||
return {
|
||||
id: blockId,
|
||||
@@ -79,28 +136,44 @@ export default class VhdEsxiRaw extends VhdAbstract {
|
||||
}
|
||||
}
|
||||
|
||||
async readBlock(blockId) {
|
||||
let tries = 5
|
||||
let lastError
|
||||
while (tries > 0) {
|
||||
try {
|
||||
const res = await this.#readBlock(blockId)
|
||||
return res
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
lastError.blockId = blockId
|
||||
console.warn('got error , will retry in 2seconds', lastError)
|
||||
}
|
||||
await new Promise(resolve => setTimeout(() => resolve(), 2000))
|
||||
tries--
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// this will read all the disk once to check which block contains data, it can take a long time to execute depending on the network speed
|
||||
async readBlockAllocationTable() {
|
||||
if (!this.#thin) {
|
||||
// fast path : is we do not use thin mode, the BAT is full
|
||||
return
|
||||
}
|
||||
const res = await this.#esxi.download(this.#datastore, this.#path)
|
||||
const length = res.headers.get('content-length')
|
||||
const stream = res.body
|
||||
const empty = Buffer.alloc(VHD_BLOCK_LENGTH, 0)
|
||||
let pos = 0
|
||||
this.#bat = new Set()
|
||||
let nextChunkLength = Math.min(VHD_BLOCK_LENGTH, length)
|
||||
Task.set('total', length / VHD_BLOCK_LENGTH)
|
||||
let nextChunkLength = Math.min(VHD_BLOCK_LENGTH, this.footer.currentSize)
|
||||
Task.set('total', this.footer.currentSize / VHD_BLOCK_LENGTH)
|
||||
const progress = setInterval(() => {
|
||||
Task.set('progress', Math.round((pos * 100) / length))
|
||||
console.log('reading blocks', pos / VHD_BLOCK_LENGTH, '/', length / VHD_BLOCK_LENGTH)
|
||||
Task.set('progress', Math.round((pos * 100) / this.footer.currentSize))
|
||||
console.log('reading blocks', pos / VHD_BLOCK_LENGTH, '/', this.footer.currentSize / VHD_BLOCK_LENGTH)
|
||||
}, 30 * 1000)
|
||||
|
||||
while (nextChunkLength > 0) {
|
||||
try {
|
||||
const chunk = await readChunk(stream, nextChunkLength)
|
||||
const chunk = await this.#readChunk(pos, nextChunkLength)
|
||||
let isEmpty
|
||||
if (nextChunkLength === VHD_BLOCK_LENGTH) {
|
||||
isEmpty = empty.equals(chunk)
|
||||
@@ -112,15 +185,28 @@ export default class VhdEsxiRaw extends VhdAbstract {
|
||||
this.#bat.add(pos / VHD_BLOCK_LENGTH)
|
||||
}
|
||||
pos += VHD_BLOCK_LENGTH
|
||||
nextChunkLength = Math.min(VHD_BLOCK_LENGTH, length - pos)
|
||||
nextChunkLength = Math.min(VHD_BLOCK_LENGTH, this.footer.currentSize - pos)
|
||||
} catch (error) {
|
||||
clearInterval(progress)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
console.log('BAT reading done, remaining ', this.#bat.size, '/', Math.ceil(length / VHD_BLOCK_LENGTH))
|
||||
console.log(
|
||||
'BAT reading done, remaining ',
|
||||
this.#bat.size,
|
||||
'/',
|
||||
Math.ceil(this.footer.currentSize / VHD_BLOCK_LENGTH)
|
||||
)
|
||||
clearInterval(progress)
|
||||
}
|
||||
|
||||
rawContent() {
|
||||
return this.#esxi.download(this.#datastore, this.#path).then(res => {
|
||||
const stream = res.body
|
||||
stream.length = this.footer.currentSize
|
||||
return stream
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-enable no-console */
|
||||
|
||||
@@ -1,18 +1,54 @@
|
||||
import _computeGeometryForSize from 'vhd-lib/_computeGeometryForSize.js'
|
||||
import { createFooter, createHeader } from 'vhd-lib/_createFooterHeader.js'
|
||||
import { FOOTER_SIZE } from 'vhd-lib/_constants.js'
|
||||
import { DISK_TYPES, FOOTER_SIZE } from 'vhd-lib/_constants.js'
|
||||
import { notEqual, strictEqual } from 'node:assert'
|
||||
import { unpackFooter, unpackHeader } from 'vhd-lib/Vhd/_utils.js'
|
||||
import { VhdAbstract } from 'vhd-lib'
|
||||
|
||||
// from https://github.com/qemu/qemu/commit/98eb9733f4cf2eeab6d12db7e758665d2fd5367b#
|
||||
// one big difference with the other versions of VMDK is that the grain tables are actually sparse, they are pre-allocated but not used in grain order,
|
||||
// so we have to read the grain directory to know where to find the grain tables
|
||||
|
||||
function readInt64(buffer, index) {
|
||||
const n = buffer.readBigInt64LE(index * 8 /* size of an int64 in bytes */)
|
||||
if (n > Number.MAX_SAFE_INTEGER) {
|
||||
const SE_SPARSE_DIR_NON_ALLOCATED = 0
|
||||
const SE_SPARSE_DIR_ALLOCATED = 1
|
||||
|
||||
const SE_SPARSE_GRAIN_NON_ALLOCATED = 0 // check in parent
|
||||
const SE_SPARSE_GRAIN_UNMAPPED = 1 // grain has been unmapped, but index of previous grain still readable for reclamation
|
||||
const SE_SPARSE_GRAIN_ZERO = 2
|
||||
const SE_SPARSE_GRAIN_ALLOCATED = 3
|
||||
|
||||
const VHD_BLOCK_SIZE_BYTES = 2 * 1024 * 1024
|
||||
const GRAIN_SIZE_BYTES = 4 * 1024
|
||||
const GRAIN_TABLE_COUNT = 4 * 1024
|
||||
|
||||
const ones = n => (1n << BigInt(n)) - 1n
|
||||
|
||||
function asNumber(n) {
|
||||
if (n > Number.MAX_SAFE_INTEGER)
|
||||
throw new Error(`can't handle ${n} ${Number.MAX_SAFE_INTEGER} ${n & 0x00000000ffffffffn}`)
|
||||
}
|
||||
return +n
|
||||
return Number(n)
|
||||
}
|
||||
|
||||
const readInt64 = (buffer, index) => asNumber(buffer.readBigInt64LE(index * 8))
|
||||
|
||||
/**
|
||||
* @returns {{topNibble: number, low60: bigint}} topNibble is the first 4 bits of the 64 bits entry, indexPart is the remaining 60 bits
|
||||
*/
|
||||
function readTaggedEntry(buffer, index) {
|
||||
const entry = buffer.readBigInt64LE(index * 8)
|
||||
return { topNibble: Number(entry >> 60n), low60: entry & ones(60) }
|
||||
}
|
||||
|
||||
function readSeSparseDir(buffer, index) {
|
||||
const { topNibble, low60 } = readTaggedEntry(buffer, index)
|
||||
return { type: topNibble, tableIndex: asNumber(low60) }
|
||||
}
|
||||
|
||||
function readSeSparseTable(buffer, index) {
|
||||
const { topNibble, low60 } = readTaggedEntry(buffer, index)
|
||||
// https://lists.gnu.org/archive/html/qemu-block/2019-06/msg00934.html
|
||||
const topIndexPart = low60 >> 48n // bring the top 12 bits down
|
||||
const bottomIndexPart = (low60 & ones(48)) << 12n // bring the bottom 48 bits up
|
||||
return { type: topNibble, grainIndex: asNumber(bottomIndexPart | topIndexPart) }
|
||||
}
|
||||
|
||||
export default class VhdEsxiSeSparse extends VhdAbstract {
|
||||
@@ -25,27 +61,22 @@ export default class VhdEsxiSeSparse extends VhdAbstract {
|
||||
#header
|
||||
#footer
|
||||
|
||||
#grainDirectory
|
||||
// as we will read all grain with data with load everything in memory
|
||||
// in theory , that can be 512MB of data for a 2TB fully allocated
|
||||
// but our use case is to transfer a relatively small diff
|
||||
// and random access is expensive in HTTP, and migration is a one time cors
|
||||
// so let's go with naive approach, and future me will have to handle a more
|
||||
// clever approach if necessary
|
||||
// grain at zero won't be stored
|
||||
#grainIndex // Map blockId => []
|
||||
|
||||
#grainMap = new Map()
|
||||
|
||||
#grainSize
|
||||
#grainTableSize
|
||||
#grainTableOffset
|
||||
#grainOffset
|
||||
#grainDirOffsetBytes
|
||||
#grainDirSizeBytes
|
||||
#grainTableOffsetBytes
|
||||
#grainOffsetBytes
|
||||
|
||||
static async open(esxi, datastore, path, parentVhd, opts) {
|
||||
const vhd = new VhdEsxiSeSparse(esxi, datastore, path, parentVhd, opts)
|
||||
await vhd.readHeaderAndFooter()
|
||||
return vhd
|
||||
}
|
||||
|
||||
get path() {
|
||||
return this.#path
|
||||
}
|
||||
constructor(esxi, datastore, path, parentVhd, { lookMissingBlockInParent = true } = {}) {
|
||||
super()
|
||||
this.#esxi = esxi
|
||||
@@ -63,156 +94,149 @@ export default class VhdEsxiSeSparse extends VhdAbstract {
|
||||
return this.#footer
|
||||
}
|
||||
|
||||
async #readGrain(start, length = 4 * 1024) {
|
||||
return (await this.#esxi.download(this.#datastore, this.#path, `${start}-${start + length - 1}`)).buffer()
|
||||
}
|
||||
|
||||
containsBlock(blockId) {
|
||||
notEqual(this.#grainDirectory, undefined, "bat must be loaded to use contain blocks'")
|
||||
|
||||
// a grain table is 4096 entries of 4KB
|
||||
// a grain table cover 8 vhd blocks
|
||||
// grain table always exists in sespars
|
||||
|
||||
// depending on the paramters we also look into the parent data
|
||||
notEqual(this.#grainIndex, undefined, "bat must be loaded to use contain blocks'")
|
||||
return (
|
||||
this.#grainDirectory.readInt32LE(blockId * 4) !== 0 ||
|
||||
this.#grainIndex.get(blockId) !== undefined ||
|
||||
(this.#lookMissingBlockInParent && this.#parentVhd.containsBlock(blockId))
|
||||
)
|
||||
}
|
||||
|
||||
async #read(start, end) {
|
||||
return (await this.#esxi.download(this.#datastore, this.#path, `${start}-${end}`)).buffer()
|
||||
async #read(start, length) {
|
||||
const buffer = await (
|
||||
await this.#esxi.download(this.#datastore, this.#path, `${start}-${start + length - 1}`)
|
||||
).buffer()
|
||||
strictEqual(buffer.length, length)
|
||||
return buffer
|
||||
}
|
||||
|
||||
async readHeaderAndFooter() {
|
||||
const buffer = await this.#read(0, 2048)
|
||||
strictEqual(buffer.readBigInt64LE(0), 0xcafebaben)
|
||||
const vmdkHeaderBuffer = await this.#read(0, 2048)
|
||||
|
||||
strictEqual(readInt64(buffer, 1), 0x200000001) // version 2.1
|
||||
strictEqual(vmdkHeaderBuffer.readBigInt64LE(0), 0xcafebaben)
|
||||
strictEqual(readInt64(vmdkHeaderBuffer, 1), 0x200000001) // version 2.1
|
||||
|
||||
const capacity = readInt64(buffer, 2)
|
||||
const grain_size = readInt64(buffer, 3)
|
||||
this.#grainDirOffsetBytes = readInt64(vmdkHeaderBuffer, 16) * 512
|
||||
// console.log('grainDirOffsetBytes', this.#grainDirOffsetBytes)
|
||||
this.#grainDirSizeBytes = readInt64(vmdkHeaderBuffer, 17) * 512
|
||||
// console.log('grainDirSizeBytes', this.#grainDirSizeBytes)
|
||||
|
||||
const grain_tables_offset = readInt64(buffer, 18)
|
||||
const grain_tables_size = readInt64(buffer, 19)
|
||||
this.#grainOffset = readInt64(buffer, 24)
|
||||
const grainSizeSectors = readInt64(vmdkHeaderBuffer, 3)
|
||||
const grainSizeBytes = grainSizeSectors * 512 // 8 sectors = 4KB default
|
||||
strictEqual(grainSizeBytes, GRAIN_SIZE_BYTES) // we only support default grain size
|
||||
|
||||
this.#grainSize = grain_size * 512 // 8 sectors / 4KB default
|
||||
this.#grainTableOffset = grain_tables_offset * 512
|
||||
this.#grainTableSize = grain_tables_size * 512
|
||||
this.#grainTableOffsetBytes = readInt64(vmdkHeaderBuffer, 18) * 512
|
||||
// console.log('grainTableOffsetBytes', this.#grainTableOffsetBytes)
|
||||
|
||||
const size = capacity * grain_size * 512
|
||||
this.#header = unpackHeader(createHeader(Math.ceil(size / (4096 * 512))))
|
||||
const geometry = _computeGeometryForSize(size)
|
||||
const actualSize = geometry.actualSize
|
||||
const grainTableCount = (readInt64(vmdkHeaderBuffer, 4) * 512) / 8 // count is the number of 64b entries in each tables
|
||||
// console.log('grainTableCount', grainTableCount)
|
||||
strictEqual(grainTableCount, GRAIN_TABLE_COUNT) // we only support tables of 4096 entries (default)
|
||||
|
||||
this.#grainOffsetBytes = readInt64(vmdkHeaderBuffer, 24) * 512
|
||||
// console.log('grainOffsetBytes', this.#grainOffsetBytes)
|
||||
|
||||
const sizeBytes = readInt64(vmdkHeaderBuffer, 2) * 512
|
||||
// console.log('sizeBytes', sizeBytes)
|
||||
|
||||
const nbBlocks = Math.ceil(sizeBytes / VHD_BLOCK_SIZE_BYTES)
|
||||
this.#header = unpackHeader(createHeader(nbBlocks))
|
||||
const geometry = _computeGeometryForSize(sizeBytes)
|
||||
this.#footer = unpackFooter(
|
||||
createFooter(actualSize, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, this.#parentVhd.footer.diskType)
|
||||
createFooter(sizeBytes, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
|
||||
)
|
||||
}
|
||||
|
||||
async readBlockAllocationTable() {
|
||||
const CHUNK_SIZE = 64 * 512
|
||||
this.#grainIndex = new Map()
|
||||
|
||||
strictEqual(this.#grainTableSize % CHUNK_SIZE, 0)
|
||||
|
||||
for (let chunkIndex = 0, grainIndex = 0; chunkIndex < this.#grainTableSize / CHUNK_SIZE; chunkIndex++) {
|
||||
process.stdin.write('.')
|
||||
const start = chunkIndex * CHUNK_SIZE + this.#grainTableOffset
|
||||
const end = start + 4096 * 8 - 1
|
||||
const buffer = await this.#read(start, end)
|
||||
for (let indexInChunk = 0; indexInChunk < 4096; indexInChunk++) {
|
||||
const entry = buffer.readBigInt64LE(indexInChunk * 8)
|
||||
switch (entry) {
|
||||
case 0n: // not allocated, go to parent
|
||||
break
|
||||
case 1n: // unmapped
|
||||
break
|
||||
}
|
||||
if (entry > 3n) {
|
||||
this.#grainMap.set(grainIndex)
|
||||
grainIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// read grain directory and the grain tables
|
||||
const nbBlocks = this.header.maxTableEntries
|
||||
this.#grainDirectory = await this.#read(2048 /* header length */, 2048 + nbBlocks * 4 - 1)
|
||||
}
|
||||
|
||||
// we're lucky : a grain address can address exacty a full block
|
||||
async readBlock(blockId) {
|
||||
notEqual(this.#grainDirectory, undefined, 'grainDirectory is not loaded')
|
||||
const sectorOffset = this.#grainDirectory.readInt32LE(blockId * 4)
|
||||
|
||||
const buffer = (await this.#parentVhd.readBlock(blockId)).buffer
|
||||
|
||||
if (sectorOffset === 0) {
|
||||
strictEqual(this.#lookMissingBlockInParent, true, "shouldn't have empty block in a delta alone")
|
||||
return {
|
||||
id: blockId,
|
||||
bitmap: buffer.slice(0, 512),
|
||||
data: buffer.slice(512),
|
||||
buffer,
|
||||
}
|
||||
}
|
||||
const offset = sectorOffset * 512
|
||||
|
||||
const graintable = await this.#read(offset, offset + 4096 * 4 /* grain table length */ - 1)
|
||||
|
||||
strictEqual(graintable.length, 4096 * 4)
|
||||
// we have no guaranty that data are order or contiguous
|
||||
// let's construct ranges to limit the number of queries
|
||||
let rangeStart, offsetStart, offsetEnd
|
||||
|
||||
const changeRange = async (index, offset) => {
|
||||
if (offsetStart !== undefined) {
|
||||
// if there was a
|
||||
if (offset === offsetEnd) {
|
||||
offsetEnd++
|
||||
return
|
||||
}
|
||||
const grains = await this.#read(offsetStart * 512, offsetEnd * 512 - 1)
|
||||
grains.copy(buffer, (rangeStart + 1) /* block bitmap */ * 512)
|
||||
}
|
||||
if (offset) {
|
||||
// we're at the beginning of a range present in the file
|
||||
rangeStart = index
|
||||
offsetStart = offset
|
||||
offsetEnd = offset + 1
|
||||
} else {
|
||||
// we're at the beginning of a range from the parent or empty
|
||||
rangeStart = undefined
|
||||
offsetStart = undefined
|
||||
offsetEnd = undefined
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < graintable.length / 4; i++) {
|
||||
const grainOffset = graintable.readInt32LE(i * 4)
|
||||
if (grainOffset === 0) {
|
||||
await changeRange()
|
||||
// from parent
|
||||
const tableSizeBytes = GRAIN_TABLE_COUNT * 8
|
||||
const grainDirBuffer = await this.#read(this.#grainDirOffsetBytes, this.#grainDirSizeBytes)
|
||||
// read the grain dir ( first level )
|
||||
for (let grainDirIndex = 0; grainDirIndex < grainDirBuffer.length / 8; grainDirIndex++) {
|
||||
const { type: grainDirType, tableIndex } = readSeSparseDir(grainDirBuffer, grainDirIndex)
|
||||
if (grainDirType === SE_SPARSE_DIR_NON_ALLOCATED) {
|
||||
// no grain table allocated at all in this grain dir
|
||||
continue
|
||||
}
|
||||
if (grainOffset === 1) {
|
||||
await changeRange()
|
||||
// this is a emptied grain, no data, don't look into parent
|
||||
buffer.fill(0, (i + 1) /* block bitmap */ * 512)
|
||||
strictEqual(grainDirType, SE_SPARSE_DIR_ALLOCATED)
|
||||
// read the corresponding grain table ( second level )
|
||||
const grainTableBuffer = await this.#read(
|
||||
this.#grainTableOffsetBytes + tableIndex * tableSizeBytes,
|
||||
tableSizeBytes
|
||||
)
|
||||
// offset in bytes if >0, grainType if <=0
|
||||
let grainOffsets = []
|
||||
let blockId = grainDirIndex * 8
|
||||
|
||||
const addGrain = val => {
|
||||
grainOffsets.push(val)
|
||||
// 4096 block of 4Kb per dir entry =>16MB/grain dir
|
||||
// 1 block = 2MB
|
||||
// 512 grain => 1 block
|
||||
// 8 block per dir entry
|
||||
if (grainOffsets.length === 512) {
|
||||
this.#grainIndex.set(blockId, grainOffsets)
|
||||
grainOffsets = []
|
||||
blockId++
|
||||
}
|
||||
}
|
||||
|
||||
if (grainOffset > 1) {
|
||||
// non empty grain
|
||||
await changeRange(i, grainOffset)
|
||||
for (let grainTableIndex = 0; grainTableIndex < grainTableBuffer.length / 8; grainTableIndex++) {
|
||||
const { type: grainType, grainIndex } = readSeSparseTable(grainTableBuffer, grainTableIndex)
|
||||
if (grainType === SE_SPARSE_GRAIN_ALLOCATED) {
|
||||
// this is ok in 32 bits int with VMDK smaller than 2TB
|
||||
const offsetByte = grainIndex * GRAIN_SIZE_BYTES + this.#grainOffsetBytes
|
||||
addGrain(offsetByte)
|
||||
} else {
|
||||
// multiply by -1 to differenciate type and offset
|
||||
// no offset can be zero
|
||||
addGrain(-grainType)
|
||||
}
|
||||
}
|
||||
}
|
||||
await changeRange()
|
||||
return {
|
||||
id: blockId,
|
||||
bitmap: buffer.slice(0, 512),
|
||||
data: buffer.slice(512),
|
||||
buffer,
|
||||
strictEqual(grainOffsets.length, 0)
|
||||
}
|
||||
}
|
||||
|
||||
async readBlock(blockId) {
|
||||
let changed = false
|
||||
const parentBlock = await this.#parentVhd.readBlock(blockId)
|
||||
const parentBuffer = parentBlock.buffer
|
||||
const grainOffsets = this.#grainIndex.get(blockId) // may be undefined if the child contains block and lookMissingBlockInParent=true
|
||||
const EMPTY_GRAIN = Buffer.alloc(GRAIN_SIZE_BYTES, 0)
|
||||
for (const index in grainOffsets) {
|
||||
const value = grainOffsets[index]
|
||||
let data
|
||||
if (value > 0) {
|
||||
// it's the offset in byte of a grain type SE_SPARSE_GRAIN_ALLOCATED
|
||||
data = await this.#read(value, GRAIN_SIZE_BYTES)
|
||||
} else {
|
||||
// back to the real grain type
|
||||
const type = value * -1
|
||||
switch (type) {
|
||||
case SE_SPARSE_GRAIN_ZERO:
|
||||
case SE_SPARSE_GRAIN_UNMAPPED:
|
||||
data = EMPTY_GRAIN
|
||||
break
|
||||
case SE_SPARSE_GRAIN_NON_ALLOCATED:
|
||||
/* from parent */
|
||||
break
|
||||
default:
|
||||
throw new Error(`can't handle grain type ${type}`)
|
||||
}
|
||||
}
|
||||
if (data) {
|
||||
changed = true
|
||||
data.copy(parentBuffer, index * GRAIN_SIZE_BYTES + 512 /* block bitmap */)
|
||||
}
|
||||
}
|
||||
// no need to copy if data all come from parent
|
||||
return changed
|
||||
? {
|
||||
id: blockId,
|
||||
bitmap: parentBuffer.slice(0, 512),
|
||||
data: parentBuffer.slice(512),
|
||||
buffer: parentBuffer,
|
||||
}
|
||||
: parentBlock
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Client } from '@vates/node-vsphere-soap'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { dirname } from 'node:path'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { strictEqual, notStrictEqual } from 'node:assert'
|
||||
@@ -9,6 +10,8 @@ import parseVmdk from './parsers/vmdk.mjs'
|
||||
import parseVmsd from './parsers/vmsd.mjs'
|
||||
import parseVmx from './parsers/vmx.mjs'
|
||||
|
||||
const { warn } = createLogger('xo:vmware-explorer:esxi')
|
||||
|
||||
export default class Esxi extends EventEmitter {
|
||||
#client
|
||||
#cookies
|
||||
@@ -64,7 +67,7 @@ export default class Esxi extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
async download(dataStore, path, range) {
|
||||
async #download(dataStore, path, range) {
|
||||
strictEqual(this.#ready, true)
|
||||
notStrictEqual(this.#dcPath, undefined)
|
||||
const url = new URL('https://localhost')
|
||||
@@ -102,6 +105,24 @@ export default class Esxi extends EventEmitter {
|
||||
return res
|
||||
}
|
||||
|
||||
async download(dataStore, path, range) {
|
||||
let tries = 5
|
||||
let lastError
|
||||
while (tries > 0) {
|
||||
try {
|
||||
const res = await this.#download(dataStore, path, range)
|
||||
return res
|
||||
} catch (error) {
|
||||
warn('got error , will retry in 2 seconds', { error })
|
||||
lastError = error
|
||||
}
|
||||
await new Promise(resolve => setTimeout(() => resolve(), 2000))
|
||||
tries--
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// inspired from https://github.com/reedog117/node-vsphere-soap/blob/master/test/vsphere-soap.test.js#L95
|
||||
async search(type, properties) {
|
||||
// get property collector
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import VHDEsxiSeSparse from './VhdEsxiSeSparse.mjs'
|
||||
import VhdEsxiCowd from './VhdEsxiCowd.mjs'
|
||||
// import VhdEsxiSeSparse from "./VhdEsxiSeSparse.mjs";
|
||||
|
||||
export default async function openDeltaVmdkasVhd(esxi, datastore, path, parentVhd, opts) {
|
||||
let vhd
|
||||
if (path.endsWith('-sesparse.vmdk')) {
|
||||
throw new Error(
|
||||
`sesparse VMDK reading is not functional yet ${path}. For now, this VM can only be migrated if it doesn't have any snapshots and if it is halted.`
|
||||
)
|
||||
// vhd = new VhdEsxiSeSparse(esxi, datastore, path, parentVhd, opts)
|
||||
vhd = new VHDEsxiSeSparse(esxi, datastore, path, parentVhd, opts)
|
||||
} else {
|
||||
if (path.endsWith('-delta.vmdk')) {
|
||||
vhd = new VhdEsxiCowd(esxi, datastore, path, parentVhd, opts)
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
"version": "0.2.3",
|
||||
"name": "@xen-orchestra/vmware-explorer",
|
||||
"dependencies": {
|
||||
"@vates/task": "^0.2.0",
|
||||
"@vates/node-vsphere-soap": "^1.0.0",
|
||||
"@vates/read-chunk": "^1.1.1",
|
||||
"@vates/task": "^0.2.0",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^3.3.0",
|
||||
"@vates/node-vsphere-soap": "^1.0.0",
|
||||
"vhd-lib": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
||||
|
||||
- [Backup/Restore] Button to open the raw log in the REST API (PR [#6936](https://github.com/vatesfr/xen-orchestra/pull/6936))
|
||||
- [Vmware/Import] Support esxi 6.5+ with snapshot (PR [#6909](https://github.com/vatesfr/xen-orchestra/pull/6909))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
@@ -16,6 +17,9 @@
|
||||
- [Incremental Backup & Replication] Attempt to work around HVM multiplier issues when creating VMs on older XAPIs (PR [#6866](https://github.com/vatesfr/xen-orchestra/pull/6866))
|
||||
- [REST API] Fix VDI export when NBD is enabled
|
||||
- [XO Config Cloud Backup] Improve wording about passphrase (PR [#6938](https://github.com/vatesfr/xen-orchestra/pull/6938))
|
||||
- [Pool] Fix IPv6 handling when adding hosts
|
||||
- [New SR] Send provided NFS version to XAPI when probing a share
|
||||
- [Backup/exports] Show more information on error ` stream has ended with not enough data (actual: xxx, expected: 512)` (PR [#6940](https://github.com/vatesfr/xen-orchestra/pull/6940))
|
||||
|
||||
### Packages to release
|
||||
|
||||
@@ -33,10 +37,17 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- @vates/fuse-vhd major
|
||||
- @vates/nbd-client major
|
||||
- @vates/node-vsphere-soap major
|
||||
- @xen-orchestra/backups minor
|
||||
- @xen-orchestra/vmware-explorer minor
|
||||
- @xen-orchestra/xapi major
|
||||
- @vates/read-chunk minor
|
||||
- complex-matcher patch
|
||||
- xo-server patch
|
||||
- xen-api patch
|
||||
- xo-server minor
|
||||
- xo-server-transport-xmpp patch
|
||||
- xo-server-audit patch
|
||||
- xo-web minor
|
||||
|
||||
|
||||
@@ -954,6 +954,8 @@ export class Xapi extends EventEmitter {
|
||||
url,
|
||||
agent: this.httpAgent,
|
||||
})
|
||||
const { hostname } = url
|
||||
url.hostnameRaw = hostname[0] === '[' ? hostname.slice(1, -1) : hostname
|
||||
this._url = url
|
||||
}
|
||||
|
||||
|
||||
@@ -30,14 +30,12 @@ const parseResult = result => {
|
||||
return result.Value
|
||||
}
|
||||
|
||||
const removeBrackets = hostname => (hostname[0] === '[' ? hostname.slice(1, -1) : hostname)
|
||||
|
||||
export default ({ secureOptions, url: { hostname, pathname, port, protocol }, agent }) => {
|
||||
export default ({ secureOptions, url: { hostnameRaw, pathname, port, protocol }, agent }) => {
|
||||
const secure = protocol === 'https:'
|
||||
const client = (secure ? createSecureClient : createClient)({
|
||||
...(secure ? secureOptions : undefined),
|
||||
agent,
|
||||
host: removeBrackets(hostname),
|
||||
host: hostnameRaw,
|
||||
pathname,
|
||||
port,
|
||||
})
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
"preferGlobal": false,
|
||||
"main": "dist/",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
"node": ">=10"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-xmpp-client": "^3.0.0",
|
||||
"@xmpp/client": "^0.13.1",
|
||||
"promise-toolbox": "^0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fromEvent from 'promise-toolbox/fromEvent'
|
||||
import XmppClient from 'node-xmpp-client'
|
||||
import { client, xml } from '@xmpp/client'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -46,13 +46,16 @@ class TransportXmppPlugin {
|
||||
this._client = null
|
||||
}
|
||||
|
||||
configure(conf) {
|
||||
this._conf = conf
|
||||
this._conf.reconnect = true
|
||||
configure({ host, jid, port, password }) {
|
||||
this._conf = {
|
||||
password,
|
||||
service: Object.assign(new URL('xmpp://localhost'), { hostname: host, port }).href,
|
||||
username: jid,
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._client = new XmppClient(this._conf)
|
||||
this._client = client(this._conf)
|
||||
this._client.on('error', () => {})
|
||||
|
||||
await fromEvent(this._client.connection.socket, 'data')
|
||||
@@ -71,12 +74,14 @@ class TransportXmppPlugin {
|
||||
_sendToXmppClient({ to, message }) {
|
||||
for (const receiver of to) {
|
||||
this._client.send(
|
||||
new XmppClient.Stanza('message', {
|
||||
to: receiver,
|
||||
type: 'chat',
|
||||
})
|
||||
.c('body')
|
||||
.t(message)
|
||||
xml(
|
||||
'message',
|
||||
{
|
||||
to: receiver,
|
||||
type: 'chat',
|
||||
},
|
||||
xml('body', {}, message)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,10 +467,11 @@ createZfs.resolve = {
|
||||
// This function helps to detect all NFS shares (exports) on a NFS server
|
||||
// Return a table of exports with their paths and ACLs
|
||||
|
||||
export async function probeNfs({ host, server }) {
|
||||
export async function probeNfs({ host, nfsVersion, server }) {
|
||||
const xapi = this.getXapi(host)
|
||||
|
||||
const deviceConfig = {
|
||||
nfsversion: nfsVersion,
|
||||
server,
|
||||
}
|
||||
|
||||
@@ -501,6 +502,7 @@ export async function probeNfs({ host, server }) {
|
||||
|
||||
probeNfs.params = {
|
||||
host: { type: 'string' },
|
||||
nfsVersion: { type: 'string', optional: true },
|
||||
server: { type: 'string' },
|
||||
}
|
||||
|
||||
@@ -837,10 +839,11 @@ probeHbaExists.resolve = {
|
||||
// This function helps to detect if this NFS SR already exists in XAPI
|
||||
// It returns a table of SR UUID, empty if no existing connections
|
||||
|
||||
export async function probeNfsExists({ host, server, serverPath }) {
|
||||
export async function probeNfsExists({ host, nfsVersion, server, serverPath }) {
|
||||
const xapi = this.getXapi(host)
|
||||
|
||||
const deviceConfig = {
|
||||
nfsversion: nfsVersion,
|
||||
server,
|
||||
serverpath: serverPath,
|
||||
}
|
||||
@@ -859,6 +862,7 @@ export async function probeNfsExists({ host, server, serverPath }) {
|
||||
|
||||
probeNfsExists.params = {
|
||||
host: { type: 'string' },
|
||||
nfsVersion: { type: 'string', optional: true },
|
||||
server: { type: 'string' },
|
||||
serverPath: { type: 'string' },
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import defaults from 'lodash/defaults.js'
|
||||
import findKey from 'lodash/findKey.js'
|
||||
import forEach from 'lodash/forEach.js'
|
||||
import identity from 'lodash/identity.js'
|
||||
@@ -10,9 +9,7 @@ import sum from 'lodash/sum.js'
|
||||
import uniq from 'lodash/uniq.js'
|
||||
import zipWith from 'lodash/zipWith.js'
|
||||
import { BaseError } from 'make-error'
|
||||
import { limitConcurrency } from 'limit-concurrency-decorator'
|
||||
import { parseDateTime } from '@xen-orchestra/xapi'
|
||||
import { synchronized } from 'decorator-synchronized'
|
||||
|
||||
export class FaultyGranularity extends BaseError {}
|
||||
|
||||
@@ -65,8 +62,6 @@ const computeValues = (dataRow, legendIndex, transformValue = identity) =>
|
||||
|
||||
const combineStats = (stats, path, combineValues) => zipWith(...map(stats, path), (...values) => combineValues(values))
|
||||
|
||||
const createGetProperty = (obj, property, defaultValue) => defaults(obj, { [property]: defaultValue })[property]
|
||||
|
||||
const testMetric = (test, type) =>
|
||||
typeof test === 'string' ? test === type : typeof test === 'function' ? test(type) : test.exec(type)
|
||||
|
||||
@@ -226,31 +221,20 @@ const STATS = {
|
||||
// data: Item[columns] // Item = { t: Number, values: Number[rows] }
|
||||
// }
|
||||
|
||||
// Local cache
|
||||
// _statsByObject : {
|
||||
// [uuid]: {
|
||||
// [step]: {
|
||||
// endTimestamp: Number, // the timestamp of the last statistic point
|
||||
// interval: Number, // step
|
||||
// stats: {
|
||||
// [metric1]: Number[],
|
||||
// [metric2]: {
|
||||
// [subMetric]: Number[],
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
export default class XapiStats {
|
||||
// hostCache => host uid => granularity => {
|
||||
// timestamp
|
||||
// value : promise or value
|
||||
// }
|
||||
#hostCache = {}
|
||||
constructor() {
|
||||
this._statsByObject = {}
|
||||
}
|
||||
|
||||
// Execute one http request on a XenServer for get stats
|
||||
// Return stats (Json format) or throws got exception
|
||||
@limitConcurrency(3)
|
||||
_getJson(xapi, host, timestamp, step) {
|
||||
return xapi
|
||||
_updateJsonCache(xapi, host, step, timestamp) {
|
||||
const hostUuid = host.uuid
|
||||
this.#hostCache[hostUuid] = this.#hostCache[hostUuid] ?? {}
|
||||
const promise = xapi
|
||||
.getResource('/rrd_updates', {
|
||||
host,
|
||||
query: {
|
||||
@@ -262,27 +246,40 @@ export default class XapiStats {
|
||||
},
|
||||
})
|
||||
.then(response => response.text().then(JSON5.parse))
|
||||
.catch(err => {
|
||||
delete this.#hostCache[hostUuid][step]
|
||||
throw err
|
||||
})
|
||||
|
||||
// clear cache when too old
|
||||
setTimeout(() => {
|
||||
// only if it has not been updated
|
||||
if (this.#hostCache[hostUuid]?.[step]?.timestamp === timestamp) {
|
||||
delete this.#hostCache[hostUuid][step]
|
||||
}
|
||||
}, (step + 1) * 1000)
|
||||
|
||||
this.#hostCache[hostUuid][step] = {
|
||||
timestamp,
|
||||
value: promise,
|
||||
}
|
||||
}
|
||||
|
||||
// To avoid multiple requests, we keep a cash for the stats and
|
||||
// only return it if we not exceed a step
|
||||
_getCachedStats(uuid, step, currentTimeStamp) {
|
||||
const statsByObject = this._statsByObject
|
||||
|
||||
const stats = statsByObject[uuid]?.[step]
|
||||
if (stats === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (stats.endTimestamp + step < currentTimeStamp) {
|
||||
delete statsByObject[uuid][step]
|
||||
return
|
||||
}
|
||||
|
||||
return stats
|
||||
_isCacheStale(hostUuid, step, timestamp) {
|
||||
const byHost = this.#hostCache[hostUuid]?.[step]
|
||||
// cache is empty or too old
|
||||
return byHost === undefined || byHost.timestamp + step < timestamp
|
||||
}
|
||||
|
||||
// Execute one http request on a XenServer for get stats
|
||||
// Return stats (Json format) or throws got exception
|
||||
_getJson(xapi, host, timestamp, step) {
|
||||
if (this._isCacheStale(host.uuid, step, timestamp)) {
|
||||
this._updateJsonCache(xapi, host, step, timestamp)
|
||||
}
|
||||
return this.#hostCache[host.uuid][step].value
|
||||
}
|
||||
|
||||
@synchronized.withKey((_, { host }) => host.uuid)
|
||||
async _getAndUpdateStats(xapi, { host, uuid, granularity }) {
|
||||
const step = granularity === undefined ? RRD_STEP_SECONDS : RRD_STEP_FROM_STRING[granularity]
|
||||
|
||||
@@ -294,65 +291,61 @@ export default class XapiStats {
|
||||
|
||||
const currentTimeStamp = await getServerTimestamp(xapi, host.$ref)
|
||||
|
||||
const stats = this._getCachedStats(uuid, step, currentTimeStamp)
|
||||
if (stats !== undefined) {
|
||||
return stats
|
||||
}
|
||||
|
||||
const maxDuration = step * RRD_POINTS_PER_STEP[step]
|
||||
|
||||
// To avoid crossing over the boundary, we ask for one less step
|
||||
const optimumTimestamp = currentTimeStamp - maxDuration + step
|
||||
const json = await this._getJson(xapi, host, optimumTimestamp, step)
|
||||
|
||||
const actualStep = json.meta.step
|
||||
|
||||
if (actualStep !== step) {
|
||||
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
|
||||
}
|
||||
let stepStats
|
||||
if (json.data.length > 0) {
|
||||
// fetched data is organized from the newest to the oldest
|
||||
// but this implementation requires it in the other direction
|
||||
json.data.reverse()
|
||||
const data = [...json.data]
|
||||
data.reverse()
|
||||
json.meta.legend.forEach((legend, index) => {
|
||||
const [, type, uuid, metricType] = /^AVERAGE:([^:]+):(.+):(.+)$/.exec(legend)
|
||||
const [, type, uuidInStat, metricType] = /^AVERAGE:([^:]+):(.+):(.+)$/.exec(legend)
|
||||
|
||||
const metrics = STATS[type]
|
||||
if (metrics === undefined) {
|
||||
return
|
||||
}
|
||||
if (uuidInStat !== uuid) {
|
||||
return
|
||||
}
|
||||
|
||||
const { metric, testResult } = findMetric(metrics, metricType)
|
||||
if (metric === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const xoObjectStats = createGetProperty(this._statsByObject, uuid, {})
|
||||
let stepStats = xoObjectStats[actualStep]
|
||||
if (stepStats === undefined || stepStats.endTimestamp !== json.meta.end) {
|
||||
stepStats = xoObjectStats[actualStep] = {
|
||||
stepStats = {
|
||||
endTimestamp: json.meta.end,
|
||||
interval: actualStep,
|
||||
stats: {},
|
||||
}
|
||||
}
|
||||
|
||||
const path = metric.getPath !== undefined ? metric.getPath(testResult) : [findKey(metrics, metric)]
|
||||
|
||||
const lastKey = path.length - 1
|
||||
let metricStats = createGetProperty(stepStats, 'stats', {})
|
||||
let metricStats = stepStats.stats
|
||||
path.forEach((property, key) => {
|
||||
if (key === lastKey) {
|
||||
metricStats[property] = computeValues(json.data, index, metric.transformValue)
|
||||
metricStats[property] = computeValues(data, index, metric.transformValue)
|
||||
return
|
||||
}
|
||||
|
||||
metricStats = createGetProperty(metricStats, property, {})
|
||||
metricStats = metricStats[property] = metricStats[property] ?? {}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (actualStep !== step) {
|
||||
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
|
||||
}
|
||||
|
||||
return (
|
||||
this._statsByObject[uuid]?.[step] ?? {
|
||||
stepStats ?? {
|
||||
endTimestamp: currentTimeStamp,
|
||||
interval: step,
|
||||
stats: {},
|
||||
|
||||
@@ -24,7 +24,6 @@ import { execa } from 'execa'
|
||||
export default class BackupNgFileRestore {
|
||||
constructor(app) {
|
||||
this._app = app
|
||||
this._mounts = { __proto__: null }
|
||||
|
||||
// clean any LVM volumes that might have not been properly
|
||||
// unmounted
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fromEvent } from 'promise-toolbox'
|
||||
import { createRunner } from '@xen-orchestra/backups/Backup.mjs'
|
||||
import { Task } from '@xen-orchestra/mixins/Tasks.mjs'
|
||||
import { v4 as generateUuid } from 'uuid'
|
||||
import { VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
|
||||
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
|
||||
import asyncMapSettled from '@xen-orchestra/async-map/legacy.js'
|
||||
import Esxi from '@xen-orchestra/vmware-explorer/esxi.mjs'
|
||||
import openDeltaVmdkasVhd from '@xen-orchestra/vmware-explorer/openDeltaVmdkAsVhd.mjs'
|
||||
@@ -271,10 +271,16 @@ export default class MigrateVm {
|
||||
}
|
||||
parentVhd = vhd
|
||||
}
|
||||
// it can be empty if the VM don't have a snapshot and is running
|
||||
if (vhd !== undefined) {
|
||||
// it can be empty if the VM don't have a snapshot and is running
|
||||
const stream = vhd.stream()
|
||||
await vdi.$importContent(stream, { format: VDI_FORMAT_VHD })
|
||||
if (thin) {
|
||||
const stream = vhd.stream()
|
||||
await vdi.$importContent(stream, { format: VDI_FORMAT_VHD })
|
||||
} else {
|
||||
// no transformation when there is no snapshot in thick mode
|
||||
const stream = await vhd.rawContent()
|
||||
await vdi.$importContent(stream, { format: VDI_FORMAT_RAW })
|
||||
}
|
||||
}
|
||||
return { vdi, vhd }
|
||||
})
|
||||
|
||||
@@ -589,7 +589,7 @@ export default class XenServers {
|
||||
const sourceXapi = this.getXapi(sourcePoolId)
|
||||
const {
|
||||
_auth: { user, password },
|
||||
_url: { hostname },
|
||||
_url: { hostnameRaw },
|
||||
} = this.getXapi(targetPoolId)
|
||||
|
||||
// We don't want the events of the source XAPI to interfere with
|
||||
@@ -597,7 +597,7 @@ export default class XenServers {
|
||||
sourceXapi.xo.uninstall()
|
||||
|
||||
try {
|
||||
await sourceXapi.joinPool(hostname, user, password, force)
|
||||
await sourceXapi.joinPool(hostnameRaw, user, password, force)
|
||||
} catch (e) {
|
||||
sourceXapi.xo.install()
|
||||
|
||||
|
||||
@@ -2513,7 +2513,7 @@ const messages = {
|
||||
licensesBinding: 'Licenses binding',
|
||||
notEnoughXcpngLicenses: 'Not enough XCP-ng licenses',
|
||||
notBoundSelectLicense: 'Not bound (Plan (ID), expiration date)',
|
||||
xcpngLicensesBindingAvancedView: "To bind an XCP-ng license, go the pool's Advanced tab.",
|
||||
xcpngLicensesBindingAvancedView: "To bind an XCP-ng license, go to the pool's Advanced tab.",
|
||||
xosanUnregisteredDisclaimer:
|
||||
'You are not registered and therefore will not be able to create or manage your XOSAN SRs. {link}',
|
||||
xosanSourcesDisclaimer:
|
||||
|
||||
@@ -2693,9 +2693,10 @@ export const fetchFiles = (remote, disk, partition, paths) =>
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export const probeSrNfs = (host, server) => _call('sr.probeNfs', { host, server })
|
||||
export const probeSrNfs = (host, server, nfsVersion) => _call('sr.probeNfs', { host, nfsVersion, server })
|
||||
|
||||
export const probeSrNfsExists = (host, server, serverPath) => _call('sr.probeNfsExists', { host, server, serverPath })
|
||||
export const probeSrNfsExists = (host, server, serverPath, nfsVersion) =>
|
||||
_call('sr.probeNfsExists', { host, nfsVersion, server, serverPath })
|
||||
|
||||
export const probeSrIscsiIqns = (host, target, port = undefined, chapUser = undefined, chapPassword) => {
|
||||
const params = { host, target }
|
||||
|
||||
@@ -999,7 +999,7 @@ const New = decorate([
|
||||
<Tooltip content={_('clickForMoreInformation')}>
|
||||
<a
|
||||
className='text-info'
|
||||
href='https://xen-orchestra.com/docs/delta_backups.html#full-backup-interval'
|
||||
href='https://xen-orchestra.com/docs/incremental_backups.html#key-backup-interval'
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
|
||||
@@ -467,11 +467,11 @@ export default class New extends Component {
|
||||
_handleSearchServer = async () => {
|
||||
const { password, port, server, username } = this.refs
|
||||
|
||||
const { host, type } = this.state
|
||||
const { host, nfsVersion, type } = this.state
|
||||
|
||||
try {
|
||||
if (type === 'nfs' || type === 'nfsiso') {
|
||||
const paths = await probeSrNfs(host.id, server.value)
|
||||
const paths = await probeSrNfs(host.id, server.value, nfsVersion !== '' ? nfsVersion : undefined)
|
||||
this.setState({
|
||||
usage: undefined,
|
||||
paths,
|
||||
@@ -500,12 +500,12 @@ export default class New extends Component {
|
||||
|
||||
_handleSrPathSelection = async path => {
|
||||
const { server } = this.refs
|
||||
const { host } = this.state
|
||||
const { host, nfsVersion } = this.state
|
||||
|
||||
try {
|
||||
this.setState(({ loading }) => ({ loading: loading + 1 }))
|
||||
this.setState({
|
||||
existingSrs: await probeSrNfsExists(host.id, server.value, path),
|
||||
existingSrs: await probeSrNfsExists(host.id, server.value, path, nfsVersion !== '' ? nfsVersion : undefined),
|
||||
path,
|
||||
usage: true,
|
||||
summary: true,
|
||||
|
||||
Reference in New Issue
Block a user