323 lines
9.9 KiB
JavaScript
323 lines
9.9 KiB
JavaScript
import { Client } from '@vates/node-vsphere-soap'
|
|
import { dirname } from 'node:path'
|
|
import { EventEmitter } from 'node:events'
|
|
import { strictEqual, notStrictEqual } from 'node:assert'
|
|
import fetch from 'node-fetch'
|
|
import https from 'https'
|
|
|
|
import parseVmdk from './parsers/vmdk.mjs'
|
|
import parseVmsd from './parsers/vmsd.mjs'
|
|
import parseVmx from './parsers/vmx.mjs'
|
|
|
|
export default class Esxi extends EventEmitter {
|
|
#client
|
|
#cookies
|
|
#dcPath
|
|
#host
|
|
#httpsAgent
|
|
#user
|
|
#password
|
|
#ready = false
|
|
|
|
constructor(host, user, password, sslVerify) {
|
|
super()
|
|
this.#host = host
|
|
this.#user = user
|
|
this.#password = password
|
|
if (!sslVerify) {
|
|
this.#httpsAgent = new https.Agent({
|
|
rejectUnauthorized: false,
|
|
})
|
|
}
|
|
|
|
this.#client = new Client(host, user, password, sslVerify)
|
|
this.#client.once('ready', async () => {
|
|
try {
|
|
// this.#ready is set to true to allow the this.search query to go through
|
|
// this means that the server is connected and can answer API queries
|
|
// you won't be able to download a file as long a the 'ready' event is not emitted
|
|
this.#ready = true
|
|
const res = await this.search('Datacenter', ['name'])
|
|
this.#dcPath = Object.values(res)[0].name
|
|
this.emit('ready')
|
|
} catch (error) {
|
|
this.emit('error', error)
|
|
}
|
|
})
|
|
this.#client.on('error', err => {
|
|
this.emit('error', err)
|
|
})
|
|
}
|
|
|
|
#exec(cmd, args) {
|
|
strictEqual(this.#ready, true)
|
|
const client = this.#client
|
|
return new Promise(function (resolve, reject) {
|
|
client.once('error', function (error) {
|
|
client.off('result', resolve)
|
|
reject(error)
|
|
})
|
|
client.runCommand(cmd, args).once('result', function () {
|
|
client.off('error', reject)
|
|
resolve(...arguments)
|
|
})
|
|
})
|
|
}
|
|
|
|
async download(dataStore, path, range) {
|
|
strictEqual(this.#ready, true)
|
|
notStrictEqual(this.#dcPath, undefined)
|
|
const url = new URL('https://localhost')
|
|
url.host = this.#host
|
|
url.pathname = '/folder/' + path
|
|
url.searchParams.set('dcPath', this.#dcPath)
|
|
url.searchParams.set('dsName', dataStore)
|
|
const headers = {}
|
|
if (this.#cookies) {
|
|
headers.cookie = this.#cookies
|
|
} else {
|
|
headers.Authorization = 'Basic ' + Buffer.from(this.#user + ':' + this.#password).toString('base64')
|
|
}
|
|
if (range) {
|
|
headers['content-type'] = 'multipart/byteranges'
|
|
headers.Range = 'bytes=' + range
|
|
}
|
|
const res = await fetch(url, {
|
|
agent: this.#httpsAgent,
|
|
method: 'GET',
|
|
headers,
|
|
highWaterMark: 10 * 1024 * 1024,
|
|
})
|
|
if (res.status < 200 || res.status >= 300) {
|
|
const error = new Error(res.status + ' ' + res.statusText + ' ' + url)
|
|
error.cause = res
|
|
throw error
|
|
}
|
|
if (res.headers.raw()['set-cookie']) {
|
|
this.#cookies = res.headers
|
|
.raw()
|
|
['set-cookie'].map(cookie => cookie.split(';')[0])
|
|
.join('; ')
|
|
}
|
|
return res
|
|
}
|
|
|
|
// inspired from https://github.com/reedog117/node-vsphere-soap/blob/master/test/vsphere-soap.test.js#L95
|
|
async search(type, properties) {
|
|
// get property collector
|
|
const propertyCollector = this.#client.serviceContent.propertyCollector
|
|
// get view manager
|
|
const viewManager = this.#client.serviceContent.viewManager
|
|
// get root folder
|
|
const rootFolder = this.#client.serviceContent.rootFolder
|
|
let result = await this.#exec('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
|
|
|
|
const objectSpec = {
|
|
attributes: { 'xsi:type': 'ObjectSpec' }, // setting attributes xsi:type is important or else the server may mis-recognize types!
|
|
obj: containerView,
|
|
skip: true,
|
|
selectSet: [
|
|
{
|
|
attributes: { 'xsi:type': 'TraversalSpec' },
|
|
name: 'traverseEntities',
|
|
type: 'ContainerView',
|
|
path: 'view',
|
|
skip: false,
|
|
},
|
|
],
|
|
}
|
|
|
|
const propertyFilterSpec = {
|
|
attributes: { 'xsi:type': 'PropertyFilterSpec' },
|
|
propSet: properties.map(p => ({
|
|
attributes: { 'xsi:type': 'PropertySpec' },
|
|
type,
|
|
pathSet: [p],
|
|
})),
|
|
objectSet: [objectSpec],
|
|
}
|
|
|
|
let token
|
|
const objects = {}
|
|
do {
|
|
if (token !== undefined) {
|
|
result = await this.#exec('ContinueRetrievePropertiesEx', {
|
|
_this: propertyCollector,
|
|
token,
|
|
})
|
|
} else {
|
|
result = await this.#exec('RetrievePropertiesEx', {
|
|
_this: propertyCollector,
|
|
specSet: [propertyFilterSpec],
|
|
options: { attributes: { type: 'RetrieveOptions' } },
|
|
})
|
|
}
|
|
|
|
const returnObj = Array.isArray(result.returnval.objects) ? result.returnval.objects : [result.returnval.objects]
|
|
returnObj.forEach(({ obj, propSet }) => {
|
|
objects[obj.$value] = {}
|
|
propSet = Array.isArray(propSet) ? propSet : [propSet]
|
|
propSet.forEach(({ name, val }) => {
|
|
// don't care about the type for now
|
|
delete val.attributes
|
|
// a scalar value : simplify it
|
|
if (val.$value) {
|
|
objects[obj.$value][name] = val.$value
|
|
} else {
|
|
objects[obj.$value][name] = val
|
|
}
|
|
})
|
|
})
|
|
|
|
token = result.returnval.token
|
|
} while (token)
|
|
|
|
return objects
|
|
}
|
|
|
|
async #inspectVmdk(dataStores, currentDataStore, currentPath, filePath) {
|
|
let diskDataStore, diskPath
|
|
if (filePath.startsWith('/')) {
|
|
// disk is on another datastore
|
|
Object.keys(dataStores).forEach(dataStoreUrl => {
|
|
if (filePath.startsWith(dataStoreUrl)) {
|
|
diskDataStore = dataStores[dataStoreUrl].name
|
|
diskPath = filePath.substring(dataStoreUrl.length + 1)
|
|
}
|
|
})
|
|
} else {
|
|
diskDataStore = currentDataStore
|
|
diskPath = currentPath + '/' + filePath
|
|
}
|
|
const vmdkRes = await this.download(diskDataStore, diskPath)
|
|
const text = await vmdkRes.text()
|
|
const parsed = parseVmdk(text)
|
|
|
|
return {
|
|
...parsed,
|
|
datastore: diskDataStore,
|
|
path: dirname(diskPath),
|
|
descriptionLabel: ' from esxi',
|
|
}
|
|
}
|
|
|
|
async getAllVmMetadata() {
|
|
const datas = await this.search('VirtualMachine', ['config', 'storage', 'runtime'])
|
|
|
|
return Object.keys(datas).map(id => {
|
|
const { config, storage, runtime } = datas[id]
|
|
const perDatastoreUsage = Array.isArray(storage.perDatastoreUsage)
|
|
? storage.perDatastoreUsage
|
|
: [storage.perDatastoreUsage]
|
|
return {
|
|
id,
|
|
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: perDatastoreUsage.reduce(
|
|
(prev, curr) => {
|
|
return {
|
|
used: prev.used + +curr.committed,
|
|
free: prev.free + +curr.uncommitted,
|
|
}
|
|
},
|
|
{ used: 0, free: 0 }
|
|
),
|
|
}
|
|
})
|
|
}
|
|
|
|
async getTransferableVmMetadata(vmId) {
|
|
const search = await this.search('VirtualMachine', ['name', 'config', 'storage', 'runtime', 'snapshot'])
|
|
if (search[vmId] === undefined) {
|
|
throw new Error(`VM ${vmId} not found `)
|
|
}
|
|
const { config, runtime } = search[vmId]
|
|
|
|
const [, dataStore, vmxPath] = config.files.vmPathName.match(/^\[(.*)\] (.+.vmx)$/)
|
|
const res = await this.download(dataStore, vmxPath)
|
|
const vmx = parseVmx(await res.text())
|
|
// list datastores
|
|
const dataStores = {}
|
|
Object.values(await this.search('Datastore', ['summary'])).forEach(({ summary }) => {
|
|
dataStores[summary.url] = summary
|
|
})
|
|
|
|
const disks = []
|
|
const networks = []
|
|
|
|
for (const key of Object.keys(vmx)) {
|
|
if (key.match('^scsi([0-9]+)$') !== null) {
|
|
const scsiChannel = vmx[key]
|
|
for (const diskIndex in Object.values(scsiChannel)) {
|
|
const disk = scsiChannel[diskIndex]
|
|
if (typeof disk !== 'object' || disk.deviceType !== 'scsi-hardDisk') {
|
|
continue
|
|
}
|
|
disks.push({
|
|
...(await this.#inspectVmdk(dataStores, dataStore, dirname(vmxPath), disk.fileName)),
|
|
node: `${key}:${diskIndex}`,
|
|
})
|
|
}
|
|
}
|
|
if (key.match('^ethernet([0-9]+)$') !== null) {
|
|
const ethernet = vmx[key]
|
|
|
|
networks.push({
|
|
label: ethernet.networkName,
|
|
macAddress: ethernet.generatedAddress,
|
|
isGenerated: ethernet.addressType === 'generated',
|
|
})
|
|
}
|
|
}
|
|
let snapshots
|
|
try {
|
|
const vmsd = await (await this.download(dataStore, vmxPath.replace(/\.vmx$/, '.vmsd'))).text()
|
|
snapshots = parseVmsd(vmsd)
|
|
|
|
for (const snapshotIndex in snapshots?.snapshots) {
|
|
const snapshot = snapshots.snapshots[snapshotIndex]
|
|
for (const diskIndex in snapshot.disks) {
|
|
const fileName = snapshot.disks[diskIndex].fileName
|
|
snapshot.disks[diskIndex] = {
|
|
node: snapshot.disks[diskIndex]?.node, // 'scsi0:0',
|
|
...(await this.#inspectVmdk(dataStores, dataStore, dirname(vmxPath), fileName)),
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// no vmsd file :fall back to a full withou snapshots
|
|
}
|
|
|
|
return {
|
|
name_label: 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,
|
|
snapshots,
|
|
disks,
|
|
networks,
|
|
}
|
|
}
|
|
|
|
powerOff(vmId) {
|
|
return this.#exec('PowerOffVM_Task', { _this: vmId })
|
|
}
|
|
powerOn(vmId) {
|
|
return this.#exec('PowerOnVM_Task', { _this: vmId })
|
|
}
|
|
}
|