mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-10-12 02:24:05 +00:00
This branch is a reliability overhaul of the ESXi/vSphere source side of V2V, plus a scope reduction of the package. The SOAP plumbing is rewritten:
VimClient wraps @vates/node-vsphere-soap in a promise API with one emitter per call (no more cross-talk between concurrent calls, no listener leak) and a pTimeout;
vim25 faults are now parsed (_parseFault.mjs) so callers get a real fault type instead of task execution failed;
the library no longer throws from async callbacks (which killed xo-server) and its beforeExit logout hook is per-client instead of removeAllListeners.
Request specs are built in explicit schema order (soap/specs.mjs) with xsi:type, and property values are normalized (soap/normalize.mjs) so search() and the new #retrieveProperty share one shape.
search() now paginates, cancels the retrieval and destroys the container view.
Datastore HTTP downloads get session-cookie reuse, retry with jittered backoff, a headers-only timeout, and range-honouring validation.
Task waiting polls with backoff and per-operation timeouts (6 h for snapshot consolidation).
nbdkit servers are memoized by a key that includes the export options, the readiness wait races the process death instead of sleeping 2 s
the password file is 0600 and unlinked
the thumbprint is read over TLS in-process instead of two openssl spawns.
The old VHD-over-datastore readers (VhdEsxi*, Datastore*, openDeltaVmdkAsVhd) are deleted, hence the majo, rleaving the vddk/NBD path with a COWD grain-directory parser as data-map fallback.
~1.7k lines of unit tests are added.
1198 lines
44 KiB
JavaScript
1198 lines
44 KiB
JavaScript
import { asyncEach } from '@vates/async-each'
|
|
import { createLogger } from '@xen-orchestra/log'
|
|
import { dirname, join } from 'node:path'
|
|
import { EventEmitter } from 'node:events'
|
|
import { Disposable, pTimeout } from 'promise-toolbox'
|
|
import { setTimeout as delay } from 'node:timers/promises'
|
|
import { Agent } from 'undici'
|
|
|
|
import { findFreePort, formatNbdkitArgs, waitForPort } from './_nbdkit.mjs'
|
|
import { resolveDiskLocation } from './_paths.mjs'
|
|
import { getCertificateThumbprint } from './_thumbprint.mjs'
|
|
import { VDDK_LIB_PATH } from './_vddk.mjs'
|
|
import { COWD_HEADER_LENGTH, grainDirectoryToDataMap, parseCowdHeader } from './parsers/cowd.mjs'
|
|
import parseVmdk from './parsers/vmdk.mjs'
|
|
import parseVmsd from './parsers/vmsd.mjs'
|
|
import parseVmx from './parsers/vmx.mjs'
|
|
import { asArray, normalizeSoapValue } from './soap/normalize.mjs'
|
|
import { moRef, objectSpec, propertyFilterSpec, propertySpec, retrieveOptions, traversalSpec } from './soap/specs.mjs'
|
|
import { VimClient } from './soap/VimClient.mjs'
|
|
import { spawn } from 'node:child_process'
|
|
import NbdClient from '@vates/nbd-client'
|
|
|
|
import { tmpdir } from 'node:os'
|
|
import fs from 'node:fs/promises'
|
|
|
|
const { info, warn } = createLogger('xo:vmware-explorer:esxi')
|
|
|
|
export { VDDK_LIB_DIR, VDDK_LIB_PATH } from './_vddk.mjs'
|
|
|
|
const DEFAULT_DOWNLOAD_RETRIES = 4
|
|
// a vmdk descriptor is a text file of a few hundred bytes, but a long parentFileNameHint pushes
|
|
// its extent line further
|
|
const DESCRIPTOR_READ_LENGTH = 4096
|
|
// a vmdk descriptor is a small file, but the /folder endpoint of a host is not fast
|
|
const DESCRIPTOR_CONCURRENCY = 4
|
|
const DEFAULT_HEADERS_TIMEOUT = 60e3
|
|
const DEFAULT_RETRY_DELAY = 2e3
|
|
// every caller of `#waitForTaskEnd` passes its own deadline, this is only the guardrail for the
|
|
// next one: the timeout is compared against, so an undefined one would poll for ever
|
|
const DEFAULT_TASK_TIMEOUT = 60e3
|
|
const MAX_RETRY_DELAY = 30e3
|
|
const MAX_TASK_POLL_DELAY = 5e3
|
|
const NBDKIT_KILL_TIMEOUT = 10e3
|
|
// connecting the vddk library to the host can be slow
|
|
const NBDKIT_READY_TIMEOUT = 60e3
|
|
|
|
// a failure which will not fix itself must not be retried: a missing file, a rejected
|
|
// authentication or a programming error only delay the report of the real problem
|
|
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504])
|
|
const SESSION_EXPIRED = 'ESXI_SESSION_EXPIRED'
|
|
const RETRYABLE_CODES = new Set([
|
|
SESSION_EXPIRED,
|
|
'ECONNABORTED',
|
|
'ECONNREFUSED',
|
|
'ECONNRESET',
|
|
'EHOSTUNREACH',
|
|
'ENETUNREACH',
|
|
'ENOTFOUND',
|
|
'EPIPE',
|
|
'ETIMEDOUT',
|
|
'UND_ERR_BODY_TIMEOUT',
|
|
'UND_ERR_CONNECT_TIMEOUT',
|
|
'UND_ERR_HEADERS_TIMEOUT',
|
|
'UND_ERR_SOCKET',
|
|
])
|
|
|
|
function isRetryableError(error) {
|
|
// `fetch` reports a network failure as `TypeError: fetch failed`, with the real error as cause
|
|
if (RETRYABLE_CODES.has(error?.code) || RETRYABLE_CODES.has(error?.cause?.code)) {
|
|
return true
|
|
}
|
|
const status = error?.cause?.status
|
|
if (status !== undefined) {
|
|
return RETRYABLE_STATUS.has(status)
|
|
}
|
|
// the host did not send its response headers in time
|
|
return error?.name === 'TimeoutError'
|
|
}
|
|
|
|
const noop = () => {}
|
|
|
|
// the options change what the server exports, so they are part of its identity
|
|
const nbdServerKey = (vmId, diskPath, { compression, singleLink, threads }) =>
|
|
JSON.stringify([vmId, diskPath, singleLink, threads, compression])
|
|
|
|
// `TaskInfo.error` is a LocalizedMethodFault: the concrete fault type is the `xsi:type` of its
|
|
// `fault` element, and the parser may expose it at either level depending on the response
|
|
const taskFaultType = error => error?.fault?.attributes?.['xsi:type'] ?? error?.attributes?.['xsi:type']
|
|
const taskFaultMessage = error => error?.localizedMessage ?? error?.fault?.localizedMessage
|
|
|
|
export default class Esxi extends EventEmitter {
|
|
#connected
|
|
#cookies
|
|
#dcPaths // map datastore name => datacenter name
|
|
#fetchImpl
|
|
#host
|
|
#httpsAgent
|
|
#user
|
|
#password
|
|
#spawn
|
|
#thumbprint
|
|
#vimClient
|
|
#nbdServers = new Map()
|
|
|
|
/**
|
|
* @param {string} host
|
|
* @param {string} user
|
|
* @param {string} password
|
|
* @param {boolean} sslVerify
|
|
* @param {object} [options]
|
|
* @param {typeof globalThis.fetch} [options.fetch] - injectable fetch implementation, for tests
|
|
* @param {typeof spawn} [options.spawn] - injectable process spawner, for tests
|
|
* @param {object} [options.vimClient] - injectable SOAP client, for tests
|
|
*/
|
|
constructor(
|
|
host,
|
|
user,
|
|
password,
|
|
sslVerify,
|
|
{ fetch: fetchImplementation, spawn: spawnImplementation, vimClient } = {}
|
|
) {
|
|
super()
|
|
this.#fetchImpl = fetchImplementation ?? globalThis.fetch
|
|
this.#spawn = spawnImplementation ?? spawn
|
|
this.#host = host.trim()
|
|
this.#user = user
|
|
this.#password = password
|
|
if (!sslVerify) {
|
|
this.#httpsAgent = new Agent({
|
|
connect: {
|
|
rejectUnauthorized: false,
|
|
},
|
|
})
|
|
}
|
|
|
|
this.#vimClient =
|
|
vimClient ?? new VimClient(host, user, password, sslVerify, { onError: error => this.#onError(error) })
|
|
|
|
// every method awaits this promise, the event is kept for the callers relying on it
|
|
this.#connected = this.#connect()
|
|
this.#connected.then(
|
|
() => this.emit('ready'),
|
|
error => this.#onError(error)
|
|
)
|
|
}
|
|
|
|
async #connect() {
|
|
await this.#vimClient.connect()
|
|
// the datacenter of a datastore is needed to download a file from it
|
|
await this.#computeDatacenters()
|
|
}
|
|
|
|
#onError(error) {
|
|
// an 'error' event without any listener terminates the process, and a caller waiting for
|
|
// 'ready' has no reason to still be listening once connected
|
|
if (this.listenerCount('error') > 0) {
|
|
this.emit('error', error)
|
|
} else {
|
|
warn('esxi client error', { error, host: this.#host })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Closes the session on the host.
|
|
*
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async close() {
|
|
try {
|
|
// the servers would only be reaped by `--exit-with-parent`, i.e. when this process ends
|
|
await asyncEach([...this.#nbdServers.keys()], key => this.#killNbdServerByKey(key), {
|
|
concurrency: 4,
|
|
// every server must be tried: `asyncEach` stops on the first error by default, which would
|
|
// leave the other ones running
|
|
stopOnError: false,
|
|
})
|
|
} finally {
|
|
// a server refusing to die must not leave the session open on the host, nor the sockets of
|
|
// the agent
|
|
await this.#vimClient.close()
|
|
await this.#httpsAgent?.close()
|
|
}
|
|
}
|
|
|
|
async #computeDatacenters() {
|
|
// the names of the datastores are read in one call instead of one per datastore: a host with
|
|
// many datastores used to issue as many requests, all in flight at the same time
|
|
const [datacenters, datastores] = await Promise.all([
|
|
// the datastore property is a collection of datastore ids
|
|
this.search('Datacenter', ['name', 'datastore']),
|
|
this.search('Datastore', ['name']),
|
|
])
|
|
|
|
const dcPaths = {}
|
|
for (const { datastore, name: datacenterName } of Object.values(datacenters)) {
|
|
for (const reference of asArray(datastore?.ManagedObjectReference)) {
|
|
const datastoreName = datastores[reference.$value]?.name
|
|
if (datastoreName === undefined) {
|
|
warn('a datastore of a datacenter is not listed', { datacenterName, datastore: reference.$value })
|
|
continue
|
|
}
|
|
dcPaths[datastoreName] = datacenterName
|
|
}
|
|
}
|
|
this.#dcPaths = dcPaths
|
|
}
|
|
|
|
#findDatacenter(dataStore) {
|
|
const dcPath = this.#dcPaths?.[dataStore]
|
|
if (dcPath === undefined) {
|
|
warn("can't find datacenter for datastore", { datacenters: this.#dcPaths, dataStore })
|
|
// an assertion error used to be thrown, naming neither the datastore nor the known ones
|
|
const error = new Error(`can't find the datacenter of the datastore ${dataStore}`)
|
|
error.code = 'DATACENTER_NOT_FOUND'
|
|
error.dataStore = dataStore
|
|
error.dataStores = Object.keys(this.#dcPaths ?? {})
|
|
throw error
|
|
}
|
|
return dcPath
|
|
}
|
|
|
|
async #fetch(url, { range, signal, headersTimeout = DEFAULT_HEADERS_TIMEOUT } = {}) {
|
|
const headers = {}
|
|
if (this.#cookies !== undefined) {
|
|
headers.cookie = this.#cookies
|
|
} else {
|
|
headers.Authorization = 'Basic ' + Buffer.from(this.#user + ':' + this.#password).toString('base64')
|
|
}
|
|
if (range !== undefined) {
|
|
headers.Range = 'bytes=' + range
|
|
}
|
|
|
|
// the timeout covers the response headers only: the body of a disk legitimately takes hours,
|
|
// an abort signal outliving the headers would cut the stream in the middle
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => {
|
|
controller.abort(new DOMException(`no response header after ${headersTimeout}ms`, 'TimeoutError'))
|
|
}, headersTimeout)
|
|
|
|
let res
|
|
try {
|
|
res = await this.#fetchImpl(url, {
|
|
dispatcher: this.#httpsAgent,
|
|
method: 'GET',
|
|
headers,
|
|
// the caller keeps the ability to abort the body
|
|
signal: signal === undefined ? controller.signal : AbortSignal.any([controller.signal, signal]),
|
|
})
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
|
|
if (res.status < 200 || res.status >= 300) {
|
|
// the body must be consumed or the connection is never released, and it usually explains the
|
|
// failure better than the status alone
|
|
const body = await res.text().catch(() => undefined)
|
|
const error = new Error(res.status + ' ' + res.statusText + ' ' + url)
|
|
error.cause = { status: res.status, statusText: res.statusText, url: String(url), body: body?.slice(0, 2048) }
|
|
if ((res.status === 401 || res.status === 403) && headers.cookie !== undefined) {
|
|
// the session expired, which happens on an import lasting hours: forget it so that the
|
|
// next attempt authenticates again
|
|
this.#cookies = undefined
|
|
error.code = SESSION_EXPIRED
|
|
}
|
|
throw error
|
|
}
|
|
|
|
if (range !== undefined && res.status !== 206) {
|
|
// A host answers the whole file when the range covers it entirely: `/folder` does it for a
|
|
// vmdk descriptor of a few hundred bytes read as `0-511`. That is harmless, since such an
|
|
// answer starts at the first byte and holds no more than what was asked.
|
|
//
|
|
// Anything else is not the requested range, and reading it as if it were would silently use
|
|
// the wrong offset, or as much memory as the file is big
|
|
const [start, end] = range.split('-').map(Number)
|
|
const rawLength = res.headers.get('content-length')
|
|
const length = Number(rawLength)
|
|
// an answer whose size is unknown ( chunked ) cannot be checked, and `Number(null)` is 0
|
|
if (start !== 0 || rawLength === null || !Number.isInteger(length) || length > end - start + 1) {
|
|
await res.body?.cancel()
|
|
const error = new Error(
|
|
`the range ${range} was ignored by the host (status ${res.status}, ${res.headers.get('content-length')} bytes) ${url}`
|
|
)
|
|
error.code = 'RANGE_IGNORED'
|
|
error.cause = { status: res.status, url: String(url), range, length }
|
|
throw error
|
|
}
|
|
}
|
|
|
|
const cookies = res.headers.getSetCookie?.() ?? []
|
|
if (cookies.length > 0) {
|
|
// reusing the session saves a full authentication on every range request
|
|
this.#cookies = cookies.map(cookie => cookie.split(';')[0]).join('; ')
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
async #download(dataStore, path, { range, signal } = {}) {
|
|
// the datacenter of the datastore is only known once connected
|
|
await this.#connected
|
|
const url = new URL('https://localhost')
|
|
url.host = this.#host
|
|
url.pathname = '/folder/' + path
|
|
url.searchParams.set('dcPath', this.#findDatacenter(dataStore))
|
|
url.searchParams.set('dsName', dataStore)
|
|
|
|
return this.#fetch(url, { range, signal })
|
|
}
|
|
|
|
/**
|
|
* Downloads a file of a datastore, or a range of it.
|
|
*
|
|
* @param {string} dataStore - name of the datastore
|
|
* @param {string} path - path of the file in the datastore
|
|
* @param {object} [options]
|
|
* @param {string} [options.range] - inclusive byte range, e.g. `0-511`
|
|
* @param {number} [options.retries] - how many times a retryable failure is retried
|
|
* @param {number} [options.retryDelay] - in ms, doubled at every attempt
|
|
* @param {AbortSignal} [options.signal]
|
|
* @returns {Promise<Response>}
|
|
*/
|
|
async download(
|
|
dataStore,
|
|
path,
|
|
{ range, retries = DEFAULT_DOWNLOAD_RETRIES, retryDelay = DEFAULT_RETRY_DELAY, signal } = {}
|
|
) {
|
|
for (let attempt = 0; ; attempt++) {
|
|
try {
|
|
return await this.#download(dataStore, path, { range, signal })
|
|
} catch (error) {
|
|
signal?.throwIfAborted()
|
|
// retrying a missing file or a rejected authentication only delays the failure
|
|
if (attempt >= retries || !isRetryableError(error)) {
|
|
throw error
|
|
}
|
|
const wait = Math.round(
|
|
Math.min(retryDelay * 2 ** attempt, MAX_RETRY_DELAY) * (0.5 + Math.random() / 2) // jitter
|
|
)
|
|
warn('download failed, will retry', { attempt: attempt + 1, dataStore, delay: wait, error, path, range })
|
|
await delay(wait, undefined, { signal })
|
|
}
|
|
}
|
|
}
|
|
|
|
// inspired from https://github.com/reedog117/node-vsphere-soap/blob/master/test/vsphere-soap.test.js#L95
|
|
/**
|
|
* Lists the objects of a type with the given properties.
|
|
*
|
|
* @param {string} type - "ComputeResource", "Datacenter", "Datastore", "DistributedVirtualSwitch",
|
|
* "Folder", "HostSystem", "Network", "ResourcePool" or "VirtualMachine"
|
|
* ( from https://github.com/vmware/govmomi/issues/2595#issuecomment-966604502 )
|
|
* @param {ReadonlyArray<string>} properties
|
|
* @param {object} [options]
|
|
* @param {number} [options.maxObjects] - objects per page
|
|
* @returns {Promise<Record<string, object>>} properties indexed by managed object reference
|
|
*/
|
|
async search(type, properties, { maxObjects = 100 } = {}) {
|
|
await this.#vimClient.connect()
|
|
// get property collector
|
|
const propertyCollector = this.#vimClient.serviceContent.propertyCollector
|
|
// get view manager
|
|
const viewManager = this.#vimClient.serviceContent.viewManager
|
|
// get root folder
|
|
const rootFolder = this.#vimClient.serviceContent.rootFolder
|
|
let result = await this.#vimClient.call('CreateContainerView', {
|
|
_this: viewManager,
|
|
container: rootFolder,
|
|
type: [type],
|
|
recursive: true,
|
|
})
|
|
|
|
// build all the data structures needed to query all the vm names
|
|
const containerView = result.returnval
|
|
|
|
// the specs are built in schema order, and the `xsi:type` are mandatory or else the server may
|
|
// mis-recognize the types
|
|
const filterSpec = propertyFilterSpec({
|
|
// every path has the same type, so one spec carries them all
|
|
propSet: [propertySpec({ type, pathSet: properties })],
|
|
objectSet: [
|
|
objectSpec({
|
|
obj: containerView,
|
|
skip: true,
|
|
selectSet: [traversalSpec({ name: 'traverseEntities', type: 'ContainerView', path: 'view', skip: false })],
|
|
}),
|
|
],
|
|
})
|
|
|
|
const objects = {}
|
|
// the token of the page being retrieved, as long as it has not been consumed
|
|
let pendingToken
|
|
try {
|
|
result = await this.#vimClient.call('RetrievePropertiesEx', {
|
|
_this: propertyCollector,
|
|
specSet: [filterSpec],
|
|
options: retrieveOptions({ maxObjects }),
|
|
})
|
|
|
|
for (;;) {
|
|
// `returnval` is absent when no object matches
|
|
const returnval = result?.returnval
|
|
if (returnval === undefined) {
|
|
break
|
|
}
|
|
|
|
for (const { obj, propSet } of asArray(returnval.objects)) {
|
|
// an object can be split across pages
|
|
const objectProperties = (objects[obj.$value] ??= {})
|
|
// `propSet` is absent for an object with no readable property
|
|
for (const { name, val } of asArray(propSet)) {
|
|
objectProperties[name] = normalizeSoapValue(val)
|
|
}
|
|
}
|
|
|
|
pendingToken = returnval.token
|
|
if (pendingToken === undefined) {
|
|
break
|
|
}
|
|
result = await this.#vimClient.call('ContinueRetrievePropertiesEx', {
|
|
_this: propertyCollector,
|
|
token: pendingToken,
|
|
})
|
|
}
|
|
} finally {
|
|
if (pendingToken !== undefined) {
|
|
// the retrieval was not consumed entirely, the server keeps its results until then
|
|
await this.#vimClient
|
|
.call('CancelRetrievePropertiesEx', { _this: propertyCollector, token: pendingToken })
|
|
.catch(error => warn('failed to cancel the property retrieval', { error, token: pendingToken }))
|
|
}
|
|
// a view is a server side resource: not destroying it leaks one per search
|
|
await this.#vimClient
|
|
.call('DestroyView', { _this: containerView })
|
|
.catch(error => warn('failed to destroy the container view', { error, type }))
|
|
}
|
|
|
|
return objects
|
|
}
|
|
|
|
async #inspectVmdk(dataStores, currentDataStore, currentPath, filePath, { signal } = {}) {
|
|
const { dataStore: diskDataStore, path: diskPath } = resolveDiskLocation({
|
|
dataStores,
|
|
currentDataStore,
|
|
currentPath,
|
|
filePath,
|
|
})
|
|
|
|
const vmdkRes = await this.download(diskDataStore, diskPath, { signal })
|
|
const text = await vmdkRes.text()
|
|
const parsed = parseVmdk(text)
|
|
|
|
return {
|
|
...parsed,
|
|
datastore: diskDataStore,
|
|
path: dirname(diskPath),
|
|
diskPath,
|
|
descriptionLabel: '',
|
|
}
|
|
}
|
|
|
|
async getAllVmMetadata() {
|
|
const datas = await this.search('VirtualMachine', ['config', 'storage', 'runtime', 'layoutEx'])
|
|
|
|
const metadata = []
|
|
for (const [id, { config, layoutEx, runtime, storage }] of Object.entries(datas)) {
|
|
// an incomplete VM, e.g. one being created: the fields below used to be dereferenced anyway,
|
|
// which failed the whole listing. `config` can be reported without its `hardware` yet
|
|
if (config?.hardware === undefined || runtime?.powerState === undefined || storage === undefined) {
|
|
warn('ignoring a VM whose properties are incomplete', {
|
|
id,
|
|
missing: {
|
|
'config.hardware': config?.hardware === undefined,
|
|
'runtime.powerState': runtime?.powerState === undefined,
|
|
storage: storage === undefined,
|
|
},
|
|
})
|
|
continue
|
|
}
|
|
|
|
// vsan , maybe raw disk , that forbid access to a direct vmdk
|
|
// descriptor may exist though with a .vmdk extension
|
|
// structure of layoutEx is described in https://developer.vmware.com/apis/1720/
|
|
const layoutFiles = asArray(layoutEx?.file)
|
|
let hasAllExtentsListed = true
|
|
for (const disk of asArray(layoutEx?.disk)) {
|
|
for (const link of asArray(disk.chain)) {
|
|
const fileKeys = asArray(link.fileKey)
|
|
// look for the disk extent data , not the descriptor
|
|
if (!layoutFiles.some(file => fileKeys.includes(file.key) && file.type === 'diskExtent')) {
|
|
hasAllExtentsListed = false
|
|
break
|
|
}
|
|
}
|
|
// one disk missing an extent is enough
|
|
if (!hasAllExtentsListed) {
|
|
break
|
|
}
|
|
}
|
|
|
|
metadata.push({
|
|
id,
|
|
hasAllExtentsListed,
|
|
nameLabel: config.name,
|
|
memory: +config.hardware.memoryMB * 1024 * 1024,
|
|
nCpus: +config.hardware.numCPU,
|
|
guestToolsInstalled: false,
|
|
firmware: config.firmware === 'efi' ? 'uefi' : config.firmware, // bios or uefi
|
|
powerState: runtime.powerState,
|
|
storage: asArray(storage.perDatastoreUsage).reduce(
|
|
(prev, curr) => {
|
|
return {
|
|
used: prev.used + +(curr?.committed ?? 0),
|
|
free: prev.free + +(curr?.uncommitted ?? 0),
|
|
}
|
|
},
|
|
{ used: 0, free: 0 }
|
|
),
|
|
})
|
|
}
|
|
return metadata
|
|
}
|
|
|
|
async getTransferableVmMetadata(vmId, { signal } = {}) {
|
|
const [config, runtime] = await Promise.all([
|
|
this.#retrieveProperty('VirtualMachine', vmId, 'config', { signal }),
|
|
this.#retrieveProperty('VirtualMachine', vmId, 'runtime', { signal }),
|
|
])
|
|
|
|
const vmPathName = config.files.vmPathName
|
|
const matches = vmPathName.match(/^\[(.*)\] (.+\.vmx)$/)
|
|
if (matches === null) {
|
|
// destructuring the null used to throw a TypeError naming nothing
|
|
const error = new Error(`can't parse the path of the vmx of the VM ${vmId}: ${vmPathName}`)
|
|
error.code = 'BAD_VMX_PATH'
|
|
error.vmId = vmId
|
|
throw error
|
|
}
|
|
const [, dataStore, vmxPath] = matches
|
|
|
|
const res = await this.download(dataStore, vmxPath, { signal })
|
|
const vmx = parseVmx(await res.text())
|
|
// list datastores
|
|
const dataStores = {}
|
|
Object.values(await this.search('Datastore', ['summary'])).forEach(({ summary }) => {
|
|
dataStores[summary.url] = summary
|
|
})
|
|
|
|
const diskReferences = []
|
|
const networks = []
|
|
let cdrom = false
|
|
|
|
for (const key of Object.keys(vmx)) {
|
|
const channelMatches = key.match(/^(scsi|ide|ethernet|sata)[0-9]+$/)
|
|
if (channelMatches === null) {
|
|
continue
|
|
}
|
|
const channelType = channelMatches[1]
|
|
if (channelType === 'ide' || channelType === 'scsi' || channelType === 'sata' /* cdrom */) {
|
|
const diskChannel = vmx[key]
|
|
// the indexes of the channel are its own: iterating the compacted values used to label a
|
|
// channel holding only `scsi0:1` as `scsi0:0`, and `node` is the disk identity used to
|
|
// build the chains and to order the VBDs
|
|
for (const [diskIndex, disk] of Object.entries(diskChannel)) {
|
|
if (typeof disk !== 'object' || disk === null) {
|
|
continue
|
|
}
|
|
if (disk.deviceType?.match(/cdrom/i)) {
|
|
cdrom = true
|
|
continue
|
|
}
|
|
// can be something other than a disk, like a controller card
|
|
if (channelType === 'scsi' && disk.deviceType !== 'scsi-hardDisk') {
|
|
continue
|
|
}
|
|
|
|
diskReferences.push({ fileName: disk.fileName, node: `${key}:${diskIndex}` })
|
|
}
|
|
} else if (channelType === 'ethernet') {
|
|
const ethernet = vmx[key]
|
|
|
|
networks.push({
|
|
label: ethernet.networkName,
|
|
macAddress: ethernet.generatedAddress,
|
|
isGenerated: ethernet.addressType === 'generated',
|
|
})
|
|
}
|
|
}
|
|
const inspect = fileName => this.#inspectVmdk(dataStores, dataStore, dirname(vmxPath), fileName, { signal })
|
|
|
|
// one descriptor to download per disk: read serially, a long chain added a full round trip per
|
|
// snapshot and per disk
|
|
const disks = new Array(diskReferences.length)
|
|
await asyncEach(
|
|
diskReferences,
|
|
async ({ fileName, node }, index) => {
|
|
disks[index] = { ...(await inspect(fileName)), node }
|
|
},
|
|
{ concurrency: DESCRIPTOR_CONCURRENCY, signal }
|
|
)
|
|
|
|
let snapshots
|
|
try {
|
|
const vmsd = await this.download(dataStore, vmxPath.replace(/\.vmx$/, '.vmsd'), { signal })
|
|
snapshots = parseVmsd(await vmsd.text())
|
|
} catch (error) {
|
|
if (error.cause?.status !== 404) {
|
|
// an unreadable vmsd used to be silently reported as "no snapshot", which turns a delta
|
|
// transfer into a full one without telling anybody
|
|
throw error
|
|
}
|
|
info('no vmsd file, the VM has no snapshot', { vmId })
|
|
}
|
|
|
|
if (snapshots !== undefined) {
|
|
const snapshotDisks = []
|
|
for (const snapshot of snapshots.snapshots ?? []) {
|
|
for (const diskIndex of Object.keys(snapshot.disks ?? {})) {
|
|
snapshotDisks.push({ disks: snapshot.disks, diskIndex })
|
|
}
|
|
}
|
|
await asyncEach(
|
|
snapshotDisks,
|
|
async ({ disks: chainDisks, diskIndex }) => {
|
|
const disk = chainDisks[diskIndex]
|
|
chainDisks[diskIndex] = {
|
|
node: disk?.node, // 'scsi0:0' , 'ide0:0', ...,
|
|
...(await inspect(disk.fileName)),
|
|
}
|
|
},
|
|
{ concurrency: DESCRIPTOR_CONCURRENCY, signal }
|
|
)
|
|
}
|
|
|
|
return {
|
|
name_label: config.name,
|
|
memory: +config.hardware.memoryMB * 1024 * 1024,
|
|
nCpus: +config.hardware.numCPU,
|
|
guestToolsInstalled: false,
|
|
guestId: config.guestId,
|
|
guestFullName: config.guestFullName,
|
|
firmware: config.firmware === 'efi' ? 'uefi' : config.firmware, // bios or uefi
|
|
powerState: runtime.powerState,
|
|
snapshots,
|
|
cdrom,
|
|
disks,
|
|
networks,
|
|
vmId,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extracts the id of the task started by a `*_Task` method.
|
|
*
|
|
* @param {object} result - result of the call
|
|
* @param {string} method
|
|
* @returns {string}
|
|
*/
|
|
#taskIdOf(result, method) {
|
|
const taskId = result?.returnval?.$value
|
|
if (taskId === undefined) {
|
|
const error = new Error(`${method} did not return a task`)
|
|
error.code = 'NO_TASK'
|
|
error.cause = result
|
|
throw error
|
|
}
|
|
return taskId
|
|
}
|
|
|
|
/**
|
|
* Polls a task until it ends.
|
|
*
|
|
* @param {string} taskId
|
|
* @param {object} [options]
|
|
* @param {string} [options.method] - name of the method which started the task, for the messages
|
|
* @param {number} [options.timeout] - in ms, how long the task is given to complete
|
|
* @param {AbortSignal} [options.signal]
|
|
* @returns {Promise<object>} the `info` of the successful task
|
|
*/
|
|
async #waitForTaskEnd(taskId, { method = 'task', timeout = DEFAULT_TASK_TIMEOUT, signal } = {}) {
|
|
const start = Date.now()
|
|
let pollDelay = 500
|
|
for (;;) {
|
|
signal?.throwIfAborted()
|
|
|
|
let info
|
|
try {
|
|
// https://developer.vmware.com/apis/1720/
|
|
info = await this.#retrieveProperty('Task', taskId, 'info', { signal })
|
|
} catch (error) {
|
|
// the task keeps running on the host: a transient failure of a single poll must not fail
|
|
// an operation which legitimately lasts hours, and be reported as if nothing was running
|
|
if (!isRetryableError(error) || Date.now() - start >= timeout) {
|
|
throw error
|
|
}
|
|
warn('failed to read the state of a task, will poll again', { error, method, taskId })
|
|
await delay(pollDelay, undefined, { signal })
|
|
pollDelay = Math.min(pollDelay * 2, MAX_TASK_POLL_DELAY)
|
|
continue
|
|
}
|
|
|
|
const { state } = info
|
|
|
|
if (state === 'success') {
|
|
return info
|
|
}
|
|
|
|
if (state === 'error') {
|
|
// don't burn the whole timeout on a task which already failed
|
|
const error = new Error(`${method} failed: ${taskFaultMessage(info.error) ?? 'unknown fault'}`)
|
|
// the fault type when the host named one, so that a caller can branch on it
|
|
error.code = taskFaultType(info.error) ?? 'TASK_FAILED'
|
|
error.cause = info.error
|
|
warn('task ended in error', { taskId, method, state, error })
|
|
throw error
|
|
}
|
|
|
|
if (Date.now() - start >= timeout) {
|
|
const error = new Error(`${method} did not complete within ${Math.round(timeout / 1000)}s (state: ${state})`)
|
|
// the task keeps running on the host: this is not retryable, starting a second one would
|
|
// not make the first go away
|
|
error.code = 'TASK_TIMEOUT'
|
|
error.cause = info
|
|
warn('task timed out', { taskId, method, state })
|
|
throw error
|
|
}
|
|
|
|
await delay(pollDelay, undefined, { signal })
|
|
pollDelay = Math.min(pollDelay * 2, MAX_TASK_POLL_DELAY)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts a vim25 method which returns a Task, and waits for that task to end.
|
|
*
|
|
* @param {string} method - name of the method, as exposed by the WSDL, e.g. `PowerOffVM_Task`
|
|
* @param {object} args - arguments of the method. `_this` names the VM the task runs on, and is
|
|
* attached to a failure. The schema declares them as a sequence, so they are sent in the order
|
|
* they are given in here
|
|
* @param {object} [options]
|
|
* @param {number} [options.timeout] - in ms, how long the task is given to complete
|
|
* @param {AbortSignal} [options.signal]
|
|
* @returns {Promise<object>} the `info` of the successful task
|
|
*/
|
|
async #runTask(method, args, { signal, timeout } = {}) {
|
|
const vmId = args._this
|
|
const res = await this.#vimClient.call(method, args)
|
|
try {
|
|
return await this.#waitForTaskEnd(this.#taskIdOf(res, method), { method, signal, timeout })
|
|
} catch (error) {
|
|
error.vmId = vmId
|
|
warn('a task on a VM failed', { error, method, vmId })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Powers off a VM, and waits for it to be off.
|
|
*
|
|
* @param {string} vmId - id of the VM
|
|
* @param {object} [options]
|
|
* @param {number} [options.timeout] - in ms
|
|
* @param {AbortSignal} [options.signal]
|
|
* @returns {Promise<object>} the `info` of the successful task
|
|
*/
|
|
async powerOff(vmId, { signal, timeout = 5 * 60e3 } = {}) {
|
|
return this.#runTask('PowerOffVM_Task', { _this: vmId }, { signal, timeout })
|
|
}
|
|
|
|
/**
|
|
* Powers on a VM, and waits for the hypervisor to have started it.
|
|
*
|
|
* @param {string} vmId - id of the VM
|
|
* @param {object} [options]
|
|
* @param {number} [options.timeout] - in ms
|
|
* @param {AbortSignal} [options.signal]
|
|
* @returns {Promise<object>} the `info` of the successful task
|
|
*/
|
|
async powerOn(vmId, { signal, timeout = 5 * 60e3 } = {}) {
|
|
return this.#runTask('PowerOnVM_Task', { _this: vmId }, { signal, timeout })
|
|
}
|
|
|
|
/**
|
|
* Hard resets a running VM, making its guest reboot.
|
|
*
|
|
* Unlike a powerOff/powerOn pair this keeps the VM powered on as far as the hypervisor is
|
|
* concerned, so the vmdk files are never closed: a snapshot delta keeps the CID it was
|
|
* created with. Useful to make a guest write to its active disk without altering the
|
|
* identity of the disks of the chain.
|
|
*
|
|
* @param {string} vmId - id of the VM, must be powered on
|
|
* @param {object} [options]
|
|
* @param {number} [options.timeout] - in ms
|
|
* @param {AbortSignal} [options.signal]
|
|
* @returns {Promise<object>} the `info` of the successful task
|
|
*/
|
|
async reset(vmId, { signal, timeout = 5 * 60e3 } = {}) {
|
|
return this.#runTask('ResetVM_Task', { _this: vmId }, { signal, timeout })
|
|
}
|
|
|
|
/**
|
|
* Removes every snapshot of a VM, consolidating their content into the base disks.
|
|
*
|
|
* @param {string} vmId - id of the VM
|
|
* @param {object} [options]
|
|
* @param {number} [options.timeout] - in ms, consolidating the deltas of a large disk takes as
|
|
* long as it takes
|
|
* @param {AbortSignal} [options.signal]
|
|
* @returns {Promise<object>} the `info` of the successful task
|
|
*/
|
|
async removeAllSnapshots(vmId, { signal, timeout = 6 * 3600e3 } = {}) {
|
|
return this.#runTask('RemoveAllSnapshots_Task', { _this: vmId, consolidate: true }, { signal, timeout })
|
|
}
|
|
|
|
/**
|
|
* Takes a snapshot of a VM, without its memory.
|
|
*
|
|
* @param {string} vmId - id of the VM
|
|
* @param {string} name
|
|
* @param {string} description
|
|
* @param {object} [options]
|
|
* @param {number} [options.timeout] - in ms
|
|
* @param {AbortSignal} [options.signal]
|
|
* @returns {Promise<object>} the `info` of the successful task
|
|
*/
|
|
async snapshot(vmId, name, description, { signal, timeout = 30 * 60e3 } = {}) {
|
|
return this.#runTask(
|
|
'CreateSnapshotEx_Task',
|
|
{ _this: vmId, name, description, memory: false },
|
|
{ signal, timeout }
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Reads one property of one managed object.
|
|
*
|
|
* Goes through the WSDL, and returns the value in the same shape as {@link search}.
|
|
*
|
|
* @param {string} type - type of the object, e.g. `VirtualMachine`
|
|
* @param {string} id - managed object reference of the object
|
|
* @param {string} path - property path, e.g. `config.hardware.device`
|
|
* @param {object} [options]
|
|
* @param {number} [options.timeout] - in ms
|
|
* @param {AbortSignal} [options.signal]
|
|
* @returns {Promise<unknown>}
|
|
*/
|
|
async #retrieveProperty(type, id, path, { timeout, signal } = {}) {
|
|
signal?.throwIfAborted()
|
|
await this.#vimClient.connect()
|
|
const propertyCollector = this.#vimClient.serviceContent.propertyCollector
|
|
|
|
const result = await this.#vimClient.call(
|
|
'RetrievePropertiesEx',
|
|
{
|
|
_this: propertyCollector,
|
|
specSet: [
|
|
propertyFilterSpec({
|
|
propSet: [propertySpec({ type, pathSet: [path] })],
|
|
// the object itself, no container view to create and destroy
|
|
objectSet: [objectSpec({ obj: moRef(type, id), skip: false })],
|
|
}),
|
|
],
|
|
options: retrieveOptions({}),
|
|
},
|
|
{ timeout }
|
|
)
|
|
|
|
const returnval = result?.returnval
|
|
if (returnval?.token !== undefined) {
|
|
// a single property of a single object always fits in one page, but a retrieval left open
|
|
// would keep its results on the server
|
|
await this.#vimClient
|
|
.call('CancelRetrievePropertiesEx', { _this: propertyCollector, token: returnval.token })
|
|
.catch(error => warn('failed to cancel the property retrieval', { error, token: returnval.token }))
|
|
}
|
|
|
|
const property = asArray(asArray(returnval?.objects)[0]?.propSet).find(({ name }) => name === path)
|
|
if (property === undefined) {
|
|
// the object may have been deleted since it was listed
|
|
const error = new Error(`can't get ${path} of object ${id} (Type: ${type})`)
|
|
error.code = 'NO_PROPERTY'
|
|
throw error
|
|
}
|
|
|
|
return normalizeSoapValue(property.val)
|
|
}
|
|
|
|
/**
|
|
* Touches the session on the host so that it does not expire.
|
|
*
|
|
* A vim25 session is dropped after 30 minutes without a call by default, and an import spends
|
|
* hours moving data outside of the WSDL. `CurrentTime` is the cheapest call which refreshes it:
|
|
* the keep-alive used to read the whole `config` of a VM instead.
|
|
*
|
|
* Only the session of the WSDL is concerned. The HTTP session used to download from a datastore
|
|
* carries its own cookies, and recovers on its own: a 401 drops them and the retry authenticates
|
|
* again.
|
|
*
|
|
* @param {object} [options]
|
|
* @param {number} [options.timeout] - in ms
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async keepAlive({ timeout } = {}) {
|
|
await this.#vimClient.call('CurrentTime', { _this: 'ServiceInstance' }, { timeout })
|
|
}
|
|
|
|
/**
|
|
* SHA-1 fingerprint of the certificate of the host, as the vddk library expects it.
|
|
*
|
|
* Memoized: it used to be computed again for every nbdkit server, with two openssl processes
|
|
* every time.
|
|
*
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async getServerThumbprint() {
|
|
return (this.#thumbprint ??= this.#computeServerThumbprint().catch(error => {
|
|
// a transient failure must not be memoized
|
|
this.#thumbprint = undefined
|
|
throw error
|
|
}))
|
|
}
|
|
|
|
#computeServerThumbprint() {
|
|
// the host may carry a port, e.g. `esxi.example:8443`, since it is used as the host of an url
|
|
const { hostname, port } = new URL(`https://${this.#host}`)
|
|
return getCertificateThumbprint(hostname.replace(/^\[|\]$/g, ''), port === '' ? {} : { port: Number(port) })
|
|
}
|
|
|
|
/**
|
|
* Starts an nbdkit server exporting a disk of a VM, or shares the one already serving it.
|
|
*
|
|
* The server is stopped when the disposable is disposed, and a server shared by several callers
|
|
* survives until the last of them disposes: the caller no longer has to name it again, with the
|
|
* exact same options, to stop it.
|
|
*
|
|
* @param {string} vmId
|
|
* @param {string} diskPath - `[datastore] dir/disk.vmdk`
|
|
* @param {object} [options]
|
|
* @param {string} [options.compression]
|
|
* @param {boolean} [options.singleLink] - export the top delta only
|
|
* @param {number} [options.threads]
|
|
* @returns {Promise<Disposable<{ died: Promise<object>, nbdInfos: object, process: object }>>}
|
|
*/
|
|
async getNbdServer(vmId, diskPath, { compression = 'fastlz', singleLink = false, threads = 1 } = {}) {
|
|
const key = nbdServerKey(vmId, diskPath, { compression, singleLink, threads })
|
|
|
|
let entry = this.#nbdServers.get(key)
|
|
if (entry === undefined) {
|
|
// the promise is memoized, not its result: the previous implementation had six await points
|
|
// between the check and the registration, so two concurrent calls spawned two servers and
|
|
// orphaned the first one
|
|
entry = { pending: this.#spawnNbdKitProcess(vmId, diskPath, { compression, singleLink, threads }), users: 0 }
|
|
this.#nbdServers.set(key, entry)
|
|
|
|
// neither a failed spawn nor a dead server must be handed out to the next caller
|
|
const forget = () => {
|
|
if (this.#nbdServers.get(key) === entry) {
|
|
this.#nbdServers.delete(key)
|
|
}
|
|
}
|
|
entry.pending.then(server => server.died.then(forget), forget)
|
|
}
|
|
|
|
entry.users += 1
|
|
|
|
let server
|
|
try {
|
|
server = await entry.pending
|
|
} catch (error) {
|
|
entry.users -= 1
|
|
throw error
|
|
}
|
|
|
|
// `Disposable` refuses a second disposal itself, so this runs exactly once per caller
|
|
const dispose = async () => {
|
|
entry.users -= 1
|
|
// `close()` stops every server left, a disposal after it has nothing to do
|
|
if (entry.users === 0 && this.#nbdServers.get(key) === entry) {
|
|
await this.#killNbdServerByKey(key)
|
|
}
|
|
}
|
|
|
|
return new Disposable(dispose, server)
|
|
}
|
|
|
|
async #spawnNbdKitProcess(vmId, diskPath, { compression, singleLink, threads }) {
|
|
const thumbprint = await this.getServerThumbprint()
|
|
const port = await findFreePort()
|
|
const tmpDir = await fs.mkdtemp(join(tmpdir(), 'xo-server'))
|
|
const passFile = join(tmpDir, 'params')
|
|
const outFd = await fs.open(join(tmpDir, 'stdout'), 'a')
|
|
const errFd = await fs.open(join(tmpDir, 'stderr'), 'a')
|
|
// the file holds a password, and only the directory was protecting it
|
|
await fs.writeFile(passFile, this.#password, { mode: 0o600 })
|
|
|
|
const args = formatNbdkitArgs({
|
|
compression,
|
|
diskPath,
|
|
host: this.#host,
|
|
libdir: VDDK_LIB_PATH,
|
|
passFile,
|
|
port,
|
|
singleLink,
|
|
threads,
|
|
thumbprint,
|
|
user: this.#user,
|
|
vmId,
|
|
})
|
|
|
|
const nbdKitProcess = this.#spawn('nbdkit', args, {
|
|
cwd: tmpDir,
|
|
env: {
|
|
...process.env,
|
|
LD_LIBRARY_PATH: `${VDDK_LIB_PATH}/lib64`,
|
|
},
|
|
})
|
|
nbdKitProcess.stdout.pipe(outFd.createWriteStream())
|
|
nbdKitProcess.stderr.pipe(errFd.createWriteStream())
|
|
info(`nbdkit logs of ${diskPath} are in ${tmpDir}`)
|
|
|
|
// `error` is emitted when the binary is missing: without a listener, it is an uncaught event
|
|
// which terminates the whole process
|
|
const died = new Promise(resolve => {
|
|
nbdKitProcess.once('error', error => resolve({ error }))
|
|
nbdKitProcess.once('exit', (code, signal) => resolve({ code, signal }))
|
|
})
|
|
|
|
died.then(async ({ code, error, signal }) => {
|
|
await Promise.all([outFd.close().catch(noop), errFd.close().catch(noop)])
|
|
if (error !== undefined) {
|
|
warn('nbdkit could not be started', { args, error, tmpDir })
|
|
} else if (code !== 0) {
|
|
warn(`nbdkit server process exited with code ${code} ,detailed logs are in ${tmpDir}/stderr `, { signal })
|
|
} else {
|
|
// nothing to look at, the logs would pile up in the temporary directory
|
|
await fs.rm(tmpDir, { force: true, recursive: true }).catch(noop)
|
|
}
|
|
})
|
|
|
|
// the readiness of the server and its death are racing: nbdkit exits on a bad thumbprint or a
|
|
// missing library, and waiting for the port would then burn the whole timeout
|
|
const failed = died.then(({ code, error }) => {
|
|
if (error !== undefined) {
|
|
// the spawn itself failed, and its own code already says why, e.g. `ENOENT` when nbdkit is
|
|
// not installed
|
|
throw error
|
|
}
|
|
const exited = new Error(
|
|
`nbdkit exited with code ${code} before being ready, detailed logs are in ${tmpDir}/stderr`
|
|
)
|
|
exited.code = 'NBDKIT_EXITED'
|
|
throw exited
|
|
})
|
|
failed.catch(noop) // the race is usually won by the readiness of the server
|
|
|
|
const readiness = new AbortController()
|
|
try {
|
|
await Promise.race([waitForPort(port, { signal: readiness.signal, timeout: NBDKIT_READY_TIMEOUT }), failed])
|
|
} catch (error) {
|
|
await this.#killNbdServer({ died, process: nbdKitProcess }, diskPath).catch(noop)
|
|
throw error
|
|
} finally {
|
|
// losing the race must not leave a probe running until its own timeout
|
|
readiness.abort()
|
|
// nbdkit reads the password once, while configuring its plugin, which is done by the time it
|
|
// listens
|
|
await fs.unlink(passFile).catch(error => warn('failed to remove the password file', { error, passFile }))
|
|
}
|
|
|
|
return {
|
|
died,
|
|
nbdInfos: { address: '127.0.0.1', port, exportname: diskPath },
|
|
process: nbdKitProcess,
|
|
}
|
|
}
|
|
|
|
async #killNbdServer(server, label) {
|
|
const { died, process: nbdKitProcess } = server
|
|
if (nbdKitProcess.exitCode !== null || nbdKitProcess.signalCode !== null) {
|
|
return
|
|
}
|
|
nbdKitProcess.kill()
|
|
try {
|
|
await pTimeout.call(died, NBDKIT_KILL_TIMEOUT)
|
|
} catch (error) {
|
|
warn('nbdkit did not exit, killing it', { error, label, pid: nbdKitProcess.pid })
|
|
nbdKitProcess.kill('SIGKILL')
|
|
await died
|
|
}
|
|
}
|
|
|
|
async #killNbdServerByKey(key) {
|
|
const entry = this.#nbdServers.get(key)
|
|
if (entry === undefined) {
|
|
warn(`nbdkit server ${key} was already killed`)
|
|
return
|
|
}
|
|
// the entry used to be left in place, so the next spawn handed out a dead process listening on
|
|
// nothing
|
|
this.#nbdServers.delete(key)
|
|
|
|
let server
|
|
try {
|
|
server = await entry.pending
|
|
} catch {
|
|
// the spawn failed, there is nothing left to kill
|
|
return
|
|
}
|
|
await this.#killNbdServer(server, key)
|
|
}
|
|
|
|
async #getDataMapFromVddk(vmId, datastoreName, diskPath, signal) {
|
|
return Disposable.use(
|
|
this.getNbdServer(vmId, `[${datastoreName}] ${diskPath}`, { singleLink: true }),
|
|
async ({ nbdInfos }) => {
|
|
const start = Date.now()
|
|
info('nbd server for data map spawned')
|
|
signal?.throwIfAborted()
|
|
|
|
// the client is built here, and not before the server: a spawn which failed used to leave
|
|
// it undefined, and the `TypeError` of `nbdClient.disconnect()` replaced the real error,
|
|
// feeding the fallback with a misleading cause
|
|
const nbdClient = new NbdClient(nbdInfos)
|
|
try {
|
|
await nbdClient.connect()
|
|
|
|
info('nbd client for data map connected')
|
|
|
|
const dataMap = await nbdClient.getMap(signal)
|
|
|
|
info(
|
|
`got the data map of the single disk in ${Math.round((Date.now() - start) / 1000)} seconds ,${dataMap.length} blocks`
|
|
)
|
|
|
|
return dataMap
|
|
} finally {
|
|
await nbdClient.disconnect().catch(error => warn('error while disconnecting the nbd client', { error }))
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
async #readRange(datastoreName, path, start, length, signal) {
|
|
// an HTTP range is inclusive: asking for `0-512` reads 513 bytes
|
|
const res = await this.download(datastoreName, path, { range: `${start}-${start + length - 1}`, signal })
|
|
return Buffer.from(await res.arrayBuffer())
|
|
}
|
|
|
|
async #getDataMapFromCowd(datastoreName, diskPath, signal) {
|
|
const descriptor = await this.#readRange(datastoreName, diskPath, 0, DESCRIPTOR_READ_LENGTH, signal)
|
|
let fileName
|
|
try {
|
|
fileName = parseVmdk(descriptor.toString('utf8')).fileName
|
|
} catch (error) {
|
|
// this is the fallback of a failure: an assertion error here would hide the real problem
|
|
const wrapped = new Error(`can't read the descriptor of ${diskPath}`)
|
|
wrapped.code = 'NO_DATA_MAP'
|
|
wrapped.cause = error
|
|
throw wrapped
|
|
}
|
|
|
|
const extentPath = diskPath.split('/').slice(0, -1).concat(fileName).join('/')
|
|
|
|
const geometry = parseCowdHeader(await this.#readRange(datastoreName, extentPath, 0, COWD_HEADER_LENGTH, signal))
|
|
const { grainDirectoryOffset, numGdEntries } = geometry
|
|
|
|
const grainDirectory = await this.#readRange(
|
|
datastoreName,
|
|
extentPath,
|
|
grainDirectoryOffset,
|
|
numGdEntries * 4,
|
|
signal
|
|
)
|
|
|
|
return grainDirectoryToDataMap(grainDirectory, geometry)
|
|
}
|
|
|
|
/**
|
|
* Blocks of a disk which hold data, used to transfer a delta instead of the whole disk.
|
|
*
|
|
* @param {string} vmId
|
|
* @param {string} datastoreName
|
|
* @param {string} diskPath - path of the disk in its datastore
|
|
* @param {AbortSignal} [signal]
|
|
* @returns {Promise<Array<{ length: number, offset: number, type: number }>>}
|
|
*/
|
|
async getDataMap(vmId, datastoreName, diskPath, signal) {
|
|
try {
|
|
// We await the result of getDataMapFromVddk so we can catch errors and fallback to the direct metadata reading.
|
|
return await this.#getDataMapFromVddk(vmId, datastoreName, diskPath, signal)
|
|
} catch (error) {
|
|
signal?.throwIfAborted()
|
|
warn('error while getting datamap from vddk, fall back to a direct metadata reading', { error })
|
|
return this.#getDataMapFromCowd(datastoreName, diskPath, signal)
|
|
}
|
|
}
|
|
}
|