mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06: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.
224 lines
7.4 KiB
JavaScript
224 lines
7.4 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { describe, it } from 'node:test'
|
|
import { EventEmitter, once } from 'node:events'
|
|
import { setImmediate as flush } from 'node:timers/promises'
|
|
|
|
import { VimClient } from './VimClient.mjs'
|
|
|
|
class FakeSoapClient extends EventEmitter {
|
|
constructor({ status = 'ready' } = {}) {
|
|
super()
|
|
this.status = status
|
|
this.sessionManager = 'SessionManager'
|
|
this.serviceContent = { propertyCollector: 'propertyCollector' }
|
|
this.calls = []
|
|
}
|
|
|
|
runCommand(command, args) {
|
|
const emitter = new EventEmitter()
|
|
this.calls.push({ command, args, emitter })
|
|
return emitter
|
|
}
|
|
|
|
get lastCall() {
|
|
return this.calls[this.calls.length - 1]
|
|
}
|
|
}
|
|
|
|
const makeClient = (options = {}) => {
|
|
const client = new FakeSoapClient(options)
|
|
return { client, vimClient: new VimClient('host', 'user', 'password', true, { ...options, client }) }
|
|
}
|
|
|
|
describe('VimClient', function () {
|
|
describe('connect', function () {
|
|
it('resolves immediately when the client is already connected', async function () {
|
|
const { vimClient } = makeClient()
|
|
await vimClient.connect()
|
|
})
|
|
|
|
it('waits for the ready event', async function () {
|
|
const { client, vimClient } = makeClient({ status: 'connecting' })
|
|
const promise = vimClient.connect()
|
|
client.emit('ready')
|
|
await promise
|
|
})
|
|
|
|
it('rejects with the connection error', async function () {
|
|
const { client, vimClient } = makeClient({ status: 'connecting' })
|
|
const error = new Error('login failed')
|
|
const promise = vimClient.connect()
|
|
client.emit('error', error)
|
|
await assert.rejects(promise, /login failed/)
|
|
})
|
|
|
|
it('does not memoize a failed connection', async function () {
|
|
const { client, vimClient } = makeClient({ status: 'connecting' })
|
|
const first = vimClient.connect()
|
|
client.emit('error', new Error('login failed'))
|
|
await assert.rejects(first, /login failed/)
|
|
|
|
// the client is disconnected, connecting again must trigger a new attempt
|
|
client.status = 'disconnected'
|
|
const connecting = once(client, 'connect')
|
|
const second = vimClient.connect()
|
|
await connecting
|
|
client.status = 'ready'
|
|
client.emit('ready')
|
|
|
|
await second
|
|
assert.notEqual(first, second)
|
|
})
|
|
|
|
it('is memoized', async function () {
|
|
const { client, vimClient } = makeClient()
|
|
assert.equal(vimClient.connect(), vimClient.connect())
|
|
// no listener is left on the client, besides the one forwarding errors
|
|
assert.equal(client.listenerCount('ready'), 0)
|
|
})
|
|
})
|
|
|
|
describe('call', function () {
|
|
it('resolves with the result of the call', async function () {
|
|
const { client, vimClient } = makeClient()
|
|
const promise = vimClient.call('PowerOffVM_Task', { _this: 'vm-1' })
|
|
|
|
await flush() // let the call go through the awaited connection
|
|
const { command, args, emitter } = client.lastCall
|
|
assert.equal(command, 'PowerOffVM_Task')
|
|
assert.deepEqual(args, { _this: 'vm-1' })
|
|
emitter.emit('result', { returnval: { $value: 'task-1' } })
|
|
|
|
assert.deepEqual(await promise, { returnval: { $value: 'task-1' } })
|
|
})
|
|
|
|
it('rejects with the error of its own call', async function () {
|
|
const { client, vimClient } = makeClient()
|
|
const promise = vimClient.call('Whatever')
|
|
|
|
await flush()
|
|
client.lastCall.emitter.emit('error', new Error('InvalidDeviceSpec'))
|
|
|
|
await assert.rejects(promise, /InvalidDeviceSpec/)
|
|
})
|
|
|
|
it('is not disturbed by the failure of a concurrent call', async function () {
|
|
const { client, vimClient } = makeClient()
|
|
const failing = vimClient.call('Failing')
|
|
const succeeding = vimClient.call('Succeeding')
|
|
|
|
await flush()
|
|
const [first, second] = client.calls
|
|
first.emitter.emit('error', new Error('nope'))
|
|
second.emitter.emit('result', 'ok')
|
|
|
|
await assert.rejects(failing, /nope/)
|
|
assert.equal(await succeeding, 'ok')
|
|
})
|
|
|
|
it('does not leak listeners', async function () {
|
|
const { client, vimClient } = makeClient()
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
const promise = vimClient.call(`Call${i}`)
|
|
await flush()
|
|
const { emitter } = client.lastCall
|
|
if (i % 2 === 0) {
|
|
emitter.emit('result', i)
|
|
assert.equal(await promise, i)
|
|
} else {
|
|
emitter.emit('error', new Error(`error ${i}`))
|
|
await assert.rejects(promise)
|
|
}
|
|
assert.equal(emitter.listenerCount('result'), 0)
|
|
assert.equal(emitter.listenerCount('error'), 0)
|
|
}
|
|
|
|
// the only listener on the client is the one forwarding its errors
|
|
assert.equal(client.listenerCount('error'), 1)
|
|
assert.equal(client.listenerCount('result'), 0)
|
|
})
|
|
|
|
it('rejects when the server does not answer', async function () {
|
|
const { vimClient } = makeClient()
|
|
await assert.rejects(vimClient.call('Silent', undefined, { timeout: 1 }), { name: 'TimeoutError' })
|
|
})
|
|
})
|
|
|
|
describe('close', function () {
|
|
it('logs out', async function () {
|
|
const { client, vimClient } = makeClient()
|
|
const promise = vimClient.close()
|
|
|
|
await flush()
|
|
assert.equal(client.lastCall.command, 'Logout')
|
|
assert.deepEqual(client.lastCall.args, { _this: 'SessionManager' })
|
|
client.lastCall.emitter.emit('result', {})
|
|
|
|
await promise
|
|
})
|
|
|
|
it('does nothing when not connected', async function () {
|
|
const { client, vimClient } = makeClient({ status: 'disconnected' })
|
|
await vimClient.close()
|
|
assert.equal(client.calls.length, 0)
|
|
})
|
|
|
|
it('waits for a login in flight before logging out', async function () {
|
|
const { client, vimClient } = makeClient({ status: 'connecting' })
|
|
const connecting = vimClient.connect()
|
|
|
|
// this used to return early on a status which was not 'ready' yet: the login then completed
|
|
// afterwards and its session lingered on the server until it expired
|
|
const closing = vimClient.close()
|
|
await flush()
|
|
assert.deepEqual(client.calls, [])
|
|
|
|
client.status = 'ready'
|
|
client.emit('ready')
|
|
await connecting
|
|
|
|
await flush()
|
|
assert.equal(client.lastCall.command, 'Logout')
|
|
client.lastCall.emitter.emit('result', {})
|
|
|
|
await closing
|
|
})
|
|
|
|
it('does not log out when the login it waited for failed', async function () {
|
|
const { client, vimClient } = makeClient({ status: 'connecting' })
|
|
const connecting = vimClient.connect()
|
|
const closing = vimClient.close()
|
|
|
|
client.emit('error', new Error('login failed'))
|
|
await assert.rejects(connecting, /login failed/)
|
|
|
|
// the failure belongs to the caller of `connect`, closing has nothing to report
|
|
await closing
|
|
assert.deepEqual(client.calls, [])
|
|
})
|
|
|
|
it('does not throw when the logout fails', async function () {
|
|
const { client, vimClient } = makeClient()
|
|
const promise = vimClient.close()
|
|
|
|
await flush()
|
|
client.lastCall.emitter.emit('error', new Error('session already gone'))
|
|
|
|
await promise
|
|
})
|
|
})
|
|
|
|
it('forwards the errors raised once connected', async function () {
|
|
const client = new FakeSoapClient()
|
|
const errors = []
|
|
// eslint-disable-next-line no-new
|
|
new VimClient('host', 'user', 'password', true, { client, onError: error => errors.push(error) })
|
|
|
|
const error = new Error('session expired')
|
|
client.emit('error', error)
|
|
|
|
assert.deepEqual(errors, [error])
|
|
})
|
|
})
|