This commit is contained in:
2026-06-25 18:02:16 -05:00
parent 402bf6782a
commit da16e5ff56
17 changed files with 1475 additions and 69 deletions

View File

@@ -272,6 +272,31 @@ function extractSoapTag(text, tagName) {
return match ? match[1].trim() : '';
}
function extractSoapBlocks(text, tagName) {
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tagName}>`, 'gi');
return [...String(text || '').matchAll(pattern)].map((match) => match[1]);
}
function decodeXml(value) {
return String(value ?? '')
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
function extractManagedObject(text, tagName, fallbackType = '') {
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}([^>]*)>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tagName}>`, 'i');
const match = String(text || '').match(pattern);
if (!match) return { type: fallbackType, value: '' };
const typeMatch = String(match[1] || '').match(/\btype=["']([^"']+)["']/i);
return {
type: typeMatch?.[1] || fallbackType,
value: decodeXml(match[2].trim())
};
}
function extractSoapFault(text) {
return extractSoapTag(text, 'faultstring') || extractSoapTag(text, 'reason') || '';
}
@@ -399,6 +424,16 @@ function localizedText(value) {
return String(value);
}
function normalizePowerState(value) {
const text = String(value || '').trim();
const map = {
poweredon: 'POWERED_ON',
poweredoff: 'POWERED_OFF',
suspended: 'SUSPENDED'
};
return map[text.toLowerCase()] || text;
}
export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null) {
const interfaces = unwrapList(interfacesPayload);
const interfaceIp = firstIpFromInterfaces(interfaces);
@@ -415,7 +450,7 @@ export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null
address,
ipAddress: ipAddresses[0] || '',
ipAddresses,
powerState: vm.power_state || vm.powerState || '',
powerState: normalizePowerState(vm.power_state || vm.powerState || ''),
osFamily: inferOsFamily(identity || {}),
guestFullName: localizedText(identity?.full_name) || identity?.name || '',
toolsAvailable: Boolean(identity || interfaces.length),
@@ -431,7 +466,7 @@ function normalizeVCenterSummaryVm(vm) {
address: vm.name || vm.vm || vm.id || '',
ipAddress: '',
ipAddresses: [],
powerState: vm.power_state || vm.powerState || '',
powerState: normalizePowerState(vm.power_state || vm.powerState || ''),
osFamily: 'other',
guestFullName: '',
toolsAvailable: false,
@@ -439,6 +474,31 @@ function normalizeVCenterSummaryVm(vm) {
};
}
function normalizeStandaloneVmObject(object, connection = null) {
const props = object.props || {};
const guestHostName = props['guest.hostName'] || props['summary.guest.hostName'] || '';
const guestIp = props['guest.ipAddress'] || props['summary.guest.ipAddress'] || '';
const guestFullName = props['guest.guestFullName'] || props['summary.config.guestFullName'] || '';
const guestId = props['guest.guestId'] || props['summary.config.guestId'] || '';
const name = props.name || guestHostName || object.id;
return {
id: object.id,
name,
fqdn: guestHostName,
address: guestHostName || guestIp || name,
ipAddress: guestIp,
ipAddresses: guestIp ? [guestIp] : [],
powerState: normalizePowerState(props['runtime.powerState'] || props['summary.runtime.powerState'] || ''),
osFamily: inferOsFamily({ full_name: guestFullName, guest_OS: guestId, host_name: guestHostName }),
guestFullName,
toolsAvailable: Boolean(guestHostName || guestIp || guestFullName || guestId),
source: 'standalone-vm',
sourceConnectionId: connection?.id || null,
sourceConnectionName: connection?.name || '',
targetType: 'host'
};
}
export function vmMatchesFilter(candidate, filter = {}) {
const operator = filter.operator || 'contains';
const needle = String(filter.value || '').trim().toLowerCase();
@@ -494,6 +554,8 @@ export function buildVmListPath(profile, filter = {}) {
}
export function buildHostPayloadFromVm(candidate, options = {}) {
if (candidate.source === 'standalone-host') return buildHostPayloadFromStandaloneHost(candidate, options);
if (candidate.source === 'standalone-vm') return buildHostPayloadFromStandaloneVm(candidate, options);
const tags = [...new Set(['vmware', 'vcenter', `vcenter:${candidate.id}`, ...(options.tags || [])].filter(Boolean))];
return {
name: candidate.name,
@@ -518,6 +580,209 @@ export function buildHostPayloadFromVm(candidate, options = {}) {
};
}
export function buildHostPayloadFromStandaloneVm(candidate, options = {}) {
const tags = [...new Set([
'vmware',
'esxi',
'standalone-esxi',
candidate.id ? `esxi-vm:${candidate.id}` : '',
...(options.tags || [])
].filter(Boolean))];
return {
name: candidate.name,
fqdn: candidate.fqdn || '',
address: candidate.address || candidate.ipAddress || candidate.name,
osFamily: normalizeOsFamily(candidate.osFamily, 'other'),
transport: options.transport || 'winrm',
port: options.port || null,
credentialId: options.credentialId || null,
tags,
notes: [
`Imported from standalone VMware ESXi VM ${candidate.id}.`,
candidate.sourceConnectionName || candidate.sourceConnectionId
? `Source ESXi connection: ${candidate.sourceConnectionName || candidate.sourceConnectionId}.`
: '',
candidate.powerState ? `Power state: ${candidate.powerState}.` : '',
candidate.guestFullName ? `Guest OS: ${candidate.guestFullName}.` : '',
candidate.ipAddresses?.length ? `IP addresses: ${candidate.ipAddresses.join(', ')}.` : 'IP address unavailable from VMware Tools at import time.'
].filter(Boolean).join(' '),
visibility: options.visibility || 'shared',
groupId: options.visibility === 'group' ? options.groupId || null : null,
sourceType: 'vmware',
sourceConnectionId: candidate.sourceConnectionId || options.connectionId || null,
sourceRef: candidate.id || ''
};
}
export function buildStandaloneHostCandidate(connection, settings = settingsFromVCenterConnection(connection)) {
const hostname = hostnameFromBaseUrl(settings.baseUrl);
const name = connection?.name || hostname || settings.baseUrl;
return {
id: `standalone-host:${connection?.id || hostname || settings.baseUrl}`,
name,
fqdn: hostname,
address: hostname || settings.baseUrl,
ipAddress: '',
ipAddresses: [],
powerState: 'Standalone host',
osFamily: 'other',
guestFullName: 'Standalone VMware ESXi host',
toolsAvailable: false,
source: 'standalone-host',
sourceConnectionId: connection?.id || null,
sourceConnectionName: connection?.name || '',
targetType: 'host'
};
}
export function buildHostPayloadFromStandaloneHost(candidate, options = {}) {
const tags = [...new Set([
'vmware',
'esxi',
'standalone-esxi',
candidate.sourceConnectionId ? `vmware-host:${candidate.sourceConnectionId}` : '',
...(options.tags || [])
].filter(Boolean))];
return {
name: candidate.name,
fqdn: candidate.fqdn || '',
address: candidate.address || candidate.fqdn || candidate.name,
osFamily: normalizeOsFamily(candidate.osFamily, 'other'),
transport: options.transport || 'ssh',
port: options.port || null,
credentialId: options.credentialId || null,
tags,
notes: [
`Imported from standalone VMware ESXi connection ${candidate.sourceConnectionName || candidate.sourceConnectionId || candidate.name}.`,
'This host was added from a direct VMware host connection, not vCenter VM inventory.'
].filter(Boolean).join(' '),
visibility: options.visibility || 'shared',
groupId: options.visibility === 'group' ? options.groupId || null : null,
sourceType: 'vmware',
sourceConnectionId: candidate.sourceConnectionId || options.connectionId || null,
sourceRef: 'standalone-esxi-host'
};
}
function hostnameFromBaseUrl(value) {
try {
return new URL(value).hostname;
} catch {
return String(value || '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '');
}
}
function soapRefElement(name, ref, fallbackType) {
return `<${name} type="${xmlEscape(ref?.type || fallbackType)}">${xmlEscape(ref?.value || '')}</${name}>`;
}
async function connectStandaloneSoap(settings, trace = null) {
pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, {
mode: 'web-services',
endpoint: soapEndpoint(settings)
});
const serviceContent = await vSphereSoapFetch(
settings,
'RetrieveServiceContent',
`<RetrieveServiceContent xmlns="urn:vim25"><_this type="ServiceInstance">ServiceInstance</_this></RetrieveServiceContent>`,
trace
);
const refs = {
rootFolder: extractManagedObject(serviceContent, 'rootFolder', 'Folder'),
propertyCollector: extractManagedObject(serviceContent, 'propertyCollector', 'PropertyCollector'),
viewManager: extractManagedObject(serviceContent, 'viewManager', 'ViewManager'),
sessionManager: extractManagedObject(serviceContent, 'sessionManager', 'SessionManager')
};
await vSphereSoapFetch(
settings,
'Login',
`<Login xmlns="urn:vim25">${soapRefElement('_this', refs.sessionManager, 'SessionManager')}<userName>${xmlEscape(settings.username)}</userName><password>${xmlEscape(settings.password)}</password></Login>`,
trace
);
return refs;
}
async function createVmContainerView(settings, refs, trace = null) {
const response = await vSphereSoapFetch(
settings,
'CreateContainerView',
`<CreateContainerView xmlns="urn:vim25">${soapRefElement('_this', refs.viewManager, 'ViewManager')}${soapRefElement('container', refs.rootFolder, 'Folder')}<type>VirtualMachine</type><recursive>true</recursive></CreateContainerView>`,
trace
);
return extractManagedObject(response, 'returnval', 'ContainerView');
}
function retrievePropertiesBody(propertyCollector, viewRef, limit = 100) {
const paths = [
'name',
'guest.hostName',
'guest.ipAddress',
'guest.guestFullName',
'guest.guestId',
'runtime.powerState',
'summary.guest.hostName',
'summary.guest.ipAddress',
'summary.config.guestFullName',
'summary.config.guestId',
'summary.runtime.powerState'
];
return `<RetrievePropertiesEx xmlns="urn:vim25" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
`${soapRefElement('_this', propertyCollector, 'PropertyCollector')}` +
`<specSet>` +
`<propSet><type>VirtualMachine</type><all>false</all>${paths.map((pathSet) => `<pathSet>${pathSet}</pathSet>`).join('')}</propSet>` +
`<objectSet>${soapRefElement('obj', viewRef, 'ContainerView')}<skip>true</skip>` +
`<selectSet xsi:type="TraversalSpec"><name>containerViewTraversal</name><type>ContainerView</type><path>view</path><skip>false</skip></selectSet>` +
`</objectSet>` +
`</specSet>` +
`<options><maxObjects>${Math.max(1, Math.min(Number(limit) || 100, 500))}</maxObjects></options>` +
`</RetrievePropertiesEx>`;
}
function continuePropertiesBody(propertyCollector, token) {
return `<ContinueRetrievePropertiesEx xmlns="urn:vim25">${soapRefElement('_this', propertyCollector, 'PropertyCollector')}<token>${xmlEscape(token)}</token></ContinueRetrievePropertiesEx>`;
}
function parseVmPropertyObjects(xml) {
return extractSoapBlocks(xml, 'objects').map((block) => {
const obj = extractManagedObject(block, 'obj', 'VirtualMachine');
const props = {};
for (const propBlock of extractSoapBlocks(block, 'propSet')) {
const name = decodeXml(extractSoapTag(propBlock, 'name'));
const rawValue = extractSoapTag(propBlock, 'val');
if (name) props[name] = decodeXml(rawValue.replace(/<[^>]+>/g, '').trim());
}
return { id: obj.value, type: obj.type, props };
}).filter((object) => object.id);
}
async function retrieveStandaloneVmInventory(settings, { connection = null, limit = 100, trace = null } = {}) {
const refs = await connectStandaloneSoap(settings, trace);
const viewRef = await createVmContainerView(settings, refs, trace);
const objects = [];
let response = await vSphereSoapFetch(settings, 'RetrievePropertiesEx', retrievePropertiesBody(refs.propertyCollector, viewRef, limit), trace);
objects.push(...parseVmPropertyObjects(response));
let token = decodeXml(extractSoapTag(response, 'token'));
while (token && objects.length < limit) {
response = await vSphereSoapFetch(settings, 'ContinueRetrievePropertiesEx', continuePropertiesBody(refs.propertyCollector, token), trace);
objects.push(...parseVmPropertyObjects(response));
token = decodeXml(extractSoapTag(response, 'token'));
}
return objects.slice(0, limit).map((object) => normalizeStandaloneVmObject(object, connection));
}
async function retrieveStandaloneVmPowerState(settings, vmId, trace = null) {
const refs = await connectStandaloneSoap(settings, trace);
const vmRef = { type: 'VirtualMachine', value: vmId };
const body = `<RetrievePropertiesEx xmlns="urn:vim25" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
`${soapRefElement('_this', refs.propertyCollector, 'PropertyCollector')}` +
`<specSet><propSet><type>VirtualMachine</type><all>false><pathSet>runtime.powerState</pathSet></propSet>` +
`<objectSet>${soapRefElement('obj', vmRef, 'VirtualMachine')}<skip>false</skip></objectSet></specSet>` +
`<options><maxObjects>1</maxObjects></options></RetrievePropertiesEx>`;
const response = await vSphereSoapFetch(settings, 'RetrievePropertiesEx', body, trace);
const object = parseVmPropertyObjects(response)[0];
return normalizePowerState(object?.props?.['runtime.powerState'] || '');
}
export async function previewVCenterHosts(options = {}) {
const trace = [];
const settings = settingsFromVCenterConnection(options.connection);
@@ -527,7 +792,37 @@ export async function previewVCenterHosts(options = {}) {
throw new VCenterTraceError(err.message, trace, 400);
}
if (settings.targetType === 'host') {
throw new VCenterTraceError('Standalone VMware host connections use the vSphere Web Services API and cannot perform vCenter VM inventory imports. Choose a vCenter connection for VM discovery.', trace, 400);
const allVms = await retrieveStandaloneVmInventory(settings, {
connection: options.connection,
limit: options.limit || 100,
trace
});
const filtered = allVms.filter((candidate) => vmMatchesFilter(candidate, options.filter)).slice(0, options.limit || 100);
pushTraceInfo(trace, 'Enumerated standalone ESXi VM inventory through the vSphere Web Services API.', {
connectionId: options.connection?.id || null,
baseUrl: settings.baseUrl,
totalFromHost: allVms.length,
matched: filtered.length
});
return {
status: vcenterConnectionStatus(options.connection),
filter: options.filter || {},
count: filtered.length,
diagnostics: {
targetType: 'host',
apiMode: 'web-services',
apiProfile: WEB_SERVICES_PROFILE_LABEL,
totalFromVCenter: allVms.length,
summaryMatched: filtered.length,
scanned: allVms.length,
resultLimit: options.limit || 100,
sampleNames: allVms.slice(0, 12).map((vm) => vm.name || vm.id).filter(Boolean),
standaloneHost: true,
filter: options.filter || {}
},
trace,
candidates: filtered
};
}
const { profile, sessionId, vmPayload } = await connectAndListVms(settings, options.filter, trace);
const allVms = unwrapList(vmPayload);
@@ -586,29 +881,17 @@ export async function testVCenterConnection(connection) {
}
export async function testStandaloneHostConnection(connection, settings = settingsFromVCenterConnection(connection), trace = []) {
pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, {
mode: 'web-services',
endpoint: soapEndpoint(settings)
const vms = await retrieveStandaloneVmInventory(settings, {
connection,
limit: 1,
trace
});
const serviceContent = await vSphereSoapFetch(
settings,
'RetrieveServiceContent',
`<vim25:RetrieveServiceContent><_this type="ServiceInstance">ServiceInstance</_this></vim25:RetrieveServiceContent>`,
trace
);
const sessionManager = extractSoapTag(serviceContent, 'sessionManager') || 'SessionManager';
await vSphereSoapFetch(
settings,
'Login',
`<vim25:Login><_this type="SessionManager">${xmlEscape(sessionManager)}</_this><userName>${xmlEscape(settings.username)}</userName><password>${xmlEscape(settings.password)}</password></vim25:Login>`,
trace
);
return {
status: vcenterConnectionStatus(connection),
apiProfile: WEB_SERVICES_PROFILE_LABEL,
count: 0,
count: vms.length,
trace,
message: `Connected to standalone VMware host ${settings.baseUrl} with ${WEB_SERVICES_PROFILE_LABEL}.`
message: `Connected to standalone VMware host ${settings.baseUrl} with ${WEB_SERVICES_PROFILE_LABEL}; VM inventory query returned ${vms.length} row(s).`
};
}
@@ -616,7 +899,14 @@ export async function getVCenterVmPowerState(connection, vmId, trace = null) {
if (!connection || !vmId) return { checked: false, reason: 'Missing vCenter connection or VM reference.' };
const settings = settingsFromVCenterConnection(connection);
if (settings.targetType === 'host') {
return { checked: false, reason: 'Standalone VMware host connections do not provide vCenter VM power-state inventory lookups.' };
assertConfigured(settings);
const state = await retrieveStandaloneVmPowerState(settings, vmId, trace);
return {
checked: Boolean(state),
state,
apiProfile: WEB_SERVICES_PROFILE_LABEL,
reason: state ? '' : 'VM power state was not returned by the ESXi Web Services API.'
};
}
assertConfigured(settings);
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];