Files
Xen-Orchestra-xen-orchestra…/@xen-orchestra/vmware-explorer/soap/VimClient.mjs
Florent BEAUCHAMP 3bf50c38ee fix(vmware-explorer): fixes omnibus (#10363)
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.
2026-09-11 17:36:38 +02:00

153 lines
4.6 KiB
JavaScript

import { Client } from '@vates/node-vsphere-soap'
import { createLogger } from '@xen-orchestra/log'
import { pTimeout } from 'promise-toolbox'
const { warn } = createLogger('xo:vmware-explorer:soap')
const noop = () => {}
// a vim25 call either answers or fails fast: the long running work is always delegated to a Task,
// whose completion is polled separately
const DEFAULT_CALL_TIMEOUT = 60e3
/**
* Promise interface over `@vates/node-vsphere-soap`.
*
* The library exposes one event emitter per call, and another one for the connection itself. Mixing
* both is what made the previous implementation leak a listener per call and reject a call with the
* error of another one, so this class keeps them strictly separate:
* - the connection is a memoized promise
* - every call listens on its own emitter, and removes its listeners in every outcome
*/
export class VimClient {
#client
#connected
/**
* @param {string} host
* @param {string} user
* @param {string} password
* @param {boolean} sslVerify
* @param {object} [options]
* @param {(error: Error) => void} [options.onError] - called for the errors raised by the
* underlying client once connected, e.g. a session that could not be renewed
* @param {object} [options.client] - injectable `@vates/node-vsphere-soap` client, for tests
*/
constructor(host, user, password, sslVerify, { onError, client } = {}) {
this.#client = client ?? new Client(host, user, password, sslVerify)
this.#client.on('error', error => {
if (onError !== undefined) {
onError(error)
} else {
warn('unhandled SOAP client error', { error })
}
})
}
get serviceContent() {
return this.#client.serviceContent
}
get status() {
return this.#client.status
}
/**
* Resolves once logged in. Memoized: it is safe (and expected) to await it before every call.
*
* @returns {Promise<void>}
*/
connect() {
this.#connected ??= new Promise((resolve, reject) => {
const client = this.#client
// the client starts connecting in its constructor, the event may already be gone
if (client.status === 'ready') {
return resolve()
}
const onReady = () => {
client.off('error', onError)
resolve()
}
const onError = error => {
client.off('ready', onReady)
reject(error)
}
client.once('ready', onReady)
client.once('error', onError)
if (client.status === 'disconnected') {
// the client connects on its own in its constructor only
client.emit('connect')
}
}).catch(error => {
// a failed connection must not be memoized, or every later call reports an error which may
// not be true any more
this.#connected = undefined
throw error
})
return this.#connected
}
/**
* Runs a vim25 method.
*
* @param {string} method - name of the method, as exposed by the WSDL
* @param {object} [args] - arguments of the method, `_this` included
* @param {object} [options]
* @param {number} [options.timeout] - in ms
* @returns {Promise<object>} the `result` of the call, e.g. `{ returnval }`
*/
async call(method, args, { timeout = DEFAULT_CALL_TIMEOUT } = {}) {
await this.connect()
const promise = new Promise((resolve, reject) => {
const emitter = this.#client.runCommand(method, args)
const onResult = result => {
emitter.off('error', onError)
resolve(result)
}
const onError = error => {
emitter.off('result', onResult)
reject(error)
}
emitter.once('result', onResult)
emitter.once('error', onError)
})
return pTimeout.call(promise, timeout)
}
/**
* Closes the session on the server.
*
* A session is not released when the process ends, it lingers until it expires, and a host only
* accepts a limited number of them.
*
* @returns {Promise<void>}
*/
async close() {
// a close racing the login used to return early and let the login complete afterwards, leaving
// the session open until it expires. The outcome of the connection is not the business of this
// method: it is already reported to whoever awaited `connect()`
await this.#connected?.catch(noop)
if (this.#client.status !== 'ready') {
return
}
try {
// `runCommand` handles the bookkeeping of a `Logout`: status and exit hook
await this.call('Logout', { _this: this.#client.sessionManager })
} catch (error) {
// the session will expire on its own, there is nothing better to do
warn('failed to log out', { error })
}
}
}