703 lines
27 KiB
JavaScript
703 lines
27 KiB
JavaScript
import { Buffer } from 'node:buffer';
|
|
import { Agent } from 'undici';
|
|
import { config } from '../config.js';
|
|
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
|
|
import { normalizeOsFamily } from '../utils/osFamily.js';
|
|
|
|
const WINDOWS_HINTS = ['windows', 'microsoft'];
|
|
const LINUX_HINTS = ['linux', 'ubuntu', 'debian', 'red hat', 'rhel', 'centos', 'suse', 'oracle linux'];
|
|
const MAX_TRACE_ENTRIES = 700;
|
|
const MAX_TRACE_BODY_CHARS = 6000;
|
|
const VM_ENRICHMENT_CONCURRENCY = 8;
|
|
const WEB_SERVICES_PROFILE_LABEL = 'vSphere Web Services API';
|
|
const API_PROFILES = {
|
|
api: {
|
|
mode: 'api',
|
|
label: 'vSphere Automation API',
|
|
sessionPath: '/api/session',
|
|
vmListPath: '/api/vcenter/vm',
|
|
vmNameParam: 'names',
|
|
vmDetailPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}`,
|
|
guestIdentityPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`,
|
|
guestNetworkingPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`
|
|
},
|
|
rest: {
|
|
mode: 'rest',
|
|
label: 'Legacy vCenter REST API',
|
|
sessionPath: '/rest/com/vmware/cis/session',
|
|
vmListPath: '/rest/vcenter/vm',
|
|
vmNameParam: 'filter.names',
|
|
vmDetailPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}`,
|
|
guestIdentityPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`,
|
|
guestNetworkingPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`
|
|
}
|
|
};
|
|
|
|
export class VCenterTraceError extends Error {
|
|
constructor(message, trace = [], statusCode = 502) {
|
|
super(message);
|
|
this.name = 'VCenterTraceError';
|
|
this.trace = trace;
|
|
this.statusCode = statusCode;
|
|
}
|
|
}
|
|
|
|
export function getVCenterSettings() {
|
|
const apiMode = getStringSetting('vcenter_api_mode', config.vcenter.apiMode || 'auto').toLowerCase();
|
|
const requestTimeoutMs = Number(getStringSetting('vcenter_request_timeout_ms', String(config.vcenter.requestTimeoutMs || 20000)));
|
|
return {
|
|
enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled),
|
|
targetType: 'vcenter',
|
|
baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)),
|
|
username: getStringSetting('vcenter_username', config.vcenter.username),
|
|
password: getStringSetting('vcenter_password', config.vcenter.password),
|
|
apiMode: ['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto',
|
|
requestTimeoutMs: Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0 ? requestTimeoutMs : 20000,
|
|
allowUntrustedTls: getBooleanSetting('vcenter_allow_untrusted_tls', config.vcenter.allowUntrustedTls)
|
|
};
|
|
}
|
|
|
|
export function settingsFromVCenterConnection(connection) {
|
|
if (!connection) return getVCenterSettings();
|
|
const apiMode = String(connection.api_mode || connection.apiMode || 'auto').toLowerCase();
|
|
const targetType = String(connection.target_type || connection.targetType || 'vcenter').toLowerCase() === 'host' ? 'host' : 'vcenter';
|
|
return {
|
|
enabled: Boolean(connection.enabled ?? true),
|
|
targetType,
|
|
baseUrl: normalizeBaseUrl(connection.base_url || connection.baseUrl),
|
|
username: connection.username || '',
|
|
password: connection.password || '',
|
|
apiMode: targetType === 'host'
|
|
? 'web-services'
|
|
: (['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto'),
|
|
requestTimeoutMs: Number(connection.request_timeout_ms || connection.requestTimeoutMs || 20000),
|
|
allowUntrustedTls: Boolean(connection.allow_untrusted_tls ?? connection.allowUntrustedTls)
|
|
};
|
|
}
|
|
|
|
export function normalizeBaseUrl(value) {
|
|
return String(value || '').trim().replace(/\/+$/, '');
|
|
}
|
|
|
|
export function vcenterStatus() {
|
|
const settings = getVCenterSettings();
|
|
return {
|
|
enabled: settings.enabled,
|
|
configured: Boolean(settings.baseUrl && settings.username && settings.password),
|
|
targetType: settings.targetType,
|
|
baseUrl: settings.baseUrl,
|
|
username: settings.username,
|
|
apiMode: settings.apiMode,
|
|
requestTimeoutMs: settings.requestTimeoutMs,
|
|
allowUntrustedTls: settings.allowUntrustedTls
|
|
};
|
|
}
|
|
|
|
export function vcenterConnectionStatus(connection) {
|
|
const settings = settingsFromVCenterConnection(connection);
|
|
return {
|
|
id: connection?.id || '',
|
|
name: connection?.name || 'Configured default',
|
|
enabled: settings.enabled,
|
|
configured: Boolean(settings.baseUrl && settings.username && settings.password),
|
|
targetType: settings.targetType,
|
|
baseUrl: settings.baseUrl,
|
|
username: settings.username,
|
|
apiMode: settings.apiMode,
|
|
requestTimeoutMs: settings.requestTimeoutMs,
|
|
allowUntrustedTls: settings.allowUntrustedTls
|
|
};
|
|
}
|
|
|
|
function assertConfigured(settings) {
|
|
const label = settings.targetType === 'host' ? 'VMware standalone host' : 'VMware vCenter';
|
|
if (!settings.enabled) throw new Error(`${label} integration is disabled in Configuration.`);
|
|
if (!settings.baseUrl) throw new Error(`${label} base URL is missing.`);
|
|
if (!settings.username || !settings.password) throw new Error(`${label} credentials are missing.`);
|
|
}
|
|
|
|
function dispatcherFor(settings) {
|
|
return settings.allowUntrustedTls
|
|
? new Agent({ connect: { rejectUnauthorized: false } })
|
|
: undefined;
|
|
}
|
|
|
|
async function parseVCenterResponse(response) {
|
|
const text = await response.text();
|
|
if (!text) return null;
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return text.replace(/^"|"$/g, '');
|
|
}
|
|
}
|
|
|
|
function summarizePayload(payload) {
|
|
if (Array.isArray(payload)) return { type: 'array', count: payload.length };
|
|
if (Array.isArray(payload?.value)) return { type: 'value-array', count: payload.value.length };
|
|
if (payload && typeof payload === 'object') return { type: 'object', keys: Object.keys(payload).slice(0, 20) };
|
|
return { type: typeof payload };
|
|
}
|
|
|
|
function bodyPreview(payload, path) {
|
|
if (path === '/api/session' || path === '/rest/com/vmware/cis/session') return '[redacted vCenter session id]';
|
|
const raw = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
|
const text = raw || '';
|
|
return text.length > MAX_TRACE_BODY_CHARS
|
|
? `${text.slice(0, MAX_TRACE_BODY_CHARS)}\n... truncated ${text.length - MAX_TRACE_BODY_CHARS} character(s)`
|
|
: text;
|
|
}
|
|
|
|
function pushTrace(trace, entry) {
|
|
if (!trace) return;
|
|
if (trace.length < MAX_TRACE_ENTRIES) {
|
|
trace.push(entry);
|
|
return;
|
|
}
|
|
if (!trace.some((item) => item.truncated)) {
|
|
trace.push({
|
|
truncated: true,
|
|
message: `vCenter trace limited to ${MAX_TRACE_ENTRIES} entries. Narrow the filter to capture fewer guest enrichment calls.`
|
|
});
|
|
}
|
|
}
|
|
|
|
function pushTraceInfo(trace, message, details = {}) {
|
|
pushTrace(trace, {
|
|
info: true,
|
|
message,
|
|
details
|
|
});
|
|
}
|
|
|
|
async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow404 = false, trace = null } = {}) {
|
|
const headers = { Accept: 'application/json' };
|
|
if (sessionId) headers['vmware-api-session-id'] = sessionId;
|
|
else headers.Authorization = `Basic ${Buffer.from(`${settings.username}:${settings.password}`).toString('base64')}`;
|
|
|
|
const startedAt = Date.now();
|
|
let response;
|
|
let payload = null;
|
|
try {
|
|
response = await fetch(`${settings.baseUrl}${path}`, {
|
|
method,
|
|
headers,
|
|
dispatcher: dispatcherFor(settings),
|
|
signal: AbortSignal.timeout(settings.requestTimeoutMs)
|
|
});
|
|
payload = await parseVCenterResponse(response);
|
|
} catch (err) {
|
|
pushTrace(trace, {
|
|
method,
|
|
url: `${settings.baseUrl}${path}`,
|
|
requestHeaders: {
|
|
Accept: headers.Accept,
|
|
Authorization: headers.Authorization ? '[redacted basic auth]' : undefined,
|
|
'vmware-api-session-id': headers['vmware-api-session-id'] ? '[redacted session id]' : undefined
|
|
},
|
|
durationMs: Date.now() - startedAt,
|
|
timeoutMs: settings.requestTimeoutMs,
|
|
networkError: err.name === 'TimeoutError'
|
|
? `Timed out after ${settings.requestTimeoutMs} ms waiting for vCenter.`
|
|
: err.message
|
|
});
|
|
const message = err.name === 'TimeoutError'
|
|
? `Timed out after ${settings.requestTimeoutMs} ms waiting for vCenter ${method} ${path}.`
|
|
: `Unable to reach vCenter ${method} ${path}: ${err.message}`;
|
|
throw new VCenterTraceError(message, trace);
|
|
}
|
|
|
|
pushTrace(trace, {
|
|
method,
|
|
url: `${settings.baseUrl}${path}`,
|
|
requestHeaders: {
|
|
Accept: headers.Accept,
|
|
Authorization: headers.Authorization ? '[redacted basic auth]' : undefined,
|
|
'vmware-api-session-id': headers['vmware-api-session-id'] ? '[redacted session id]' : undefined
|
|
},
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
durationMs: Date.now() - startedAt,
|
|
responseHeaders: {
|
|
'content-type': response.headers.get('content-type') || '',
|
|
date: response.headers.get('date') || ''
|
|
},
|
|
responseSummary: summarizePayload(payload),
|
|
responseBody: bodyPreview(payload, path)
|
|
});
|
|
|
|
if (allow404 && response.status === 404) return null;
|
|
if (!response.ok) {
|
|
const message = payload?.messages?.[0]?.default_message || payload?.error || payload?.message || response.statusText;
|
|
throw new VCenterTraceError(`vCenter ${method} ${path} failed with HTTP ${response.status}: ${message}`, trace, response.status);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function soapEndpoint(settings) {
|
|
return `${settings.baseUrl.replace(/\/sdk$/i, '')}/sdk`;
|
|
}
|
|
|
|
function soapEnvelope(body) {
|
|
return `<?xml version="1.0" encoding="UTF-8"?>` +
|
|
`<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:vim25="urn:vim25">` +
|
|
`<soapenv:Body>${body}</soapenv:Body></soapenv:Envelope>`;
|
|
}
|
|
|
|
function xmlEscape(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function redactSoapBody(value) {
|
|
return String(value || '')
|
|
.replace(/(<password>)([\s\S]*?)(<\/password>)/gi, '$1[redacted]$3')
|
|
.replace(/(<userName>)([\s\S]*?)(<\/userName>)/gi, '$1[redacted username]$3');
|
|
}
|
|
|
|
function soapBodyPreview(value) {
|
|
const text = redactSoapBody(value);
|
|
return text.length > MAX_TRACE_BODY_CHARS
|
|
? `${text.slice(0, MAX_TRACE_BODY_CHARS)}\n... truncated ${text.length - MAX_TRACE_BODY_CHARS} character(s)`
|
|
: text;
|
|
}
|
|
|
|
function extractSoapTag(text, tagName) {
|
|
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tagName}>`, 'i');
|
|
const match = String(text || '').match(pattern);
|
|
return match ? match[1].trim() : '';
|
|
}
|
|
|
|
function extractSoapFault(text) {
|
|
return extractSoapTag(text, 'faultstring') || extractSoapTag(text, 'reason') || '';
|
|
}
|
|
|
|
async function vSphereSoapFetch(settings, action, body, trace = null) {
|
|
const url = soapEndpoint(settings);
|
|
const requestBody = soapEnvelope(body);
|
|
const headers = {
|
|
Accept: 'text/xml',
|
|
'Content-Type': 'text/xml; charset=utf-8',
|
|
SOAPAction: `"urn:vim25/${action}"`
|
|
};
|
|
const startedAt = Date.now();
|
|
let response;
|
|
let text = '';
|
|
try {
|
|
response = await fetch(url, {
|
|
method: 'POST',
|
|
headers,
|
|
body: requestBody,
|
|
dispatcher: dispatcherFor(settings),
|
|
signal: AbortSignal.timeout(settings.requestTimeoutMs)
|
|
});
|
|
text = await response.text();
|
|
} catch (err) {
|
|
pushTrace(trace, {
|
|
method: 'POST',
|
|
url,
|
|
soapAction: action,
|
|
requestHeaders: { ...headers, SOAPAction: headers.SOAPAction },
|
|
requestBody: soapBodyPreview(requestBody),
|
|
durationMs: Date.now() - startedAt,
|
|
timeoutMs: settings.requestTimeoutMs,
|
|
networkError: err.name === 'TimeoutError'
|
|
? `Timed out after ${settings.requestTimeoutMs} ms waiting for VMware host SOAP endpoint.`
|
|
: err.message
|
|
});
|
|
const message = err.name === 'TimeoutError'
|
|
? `Timed out after ${settings.requestTimeoutMs} ms waiting for VMware host SOAP action ${action}.`
|
|
: `Unable to reach VMware host SOAP action ${action}: ${err.message}`;
|
|
throw new VCenterTraceError(message, trace);
|
|
}
|
|
|
|
pushTrace(trace, {
|
|
method: 'POST',
|
|
url,
|
|
soapAction: action,
|
|
requestHeaders: { ...headers, SOAPAction: headers.SOAPAction },
|
|
requestBody: soapBodyPreview(requestBody),
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
durationMs: Date.now() - startedAt,
|
|
responseHeaders: {
|
|
'content-type': response.headers.get('content-type') || '',
|
|
date: response.headers.get('date') || ''
|
|
},
|
|
responseSummary: {
|
|
type: 'soap',
|
|
bytes: text.length,
|
|
fault: Boolean(extractSoapFault(text))
|
|
},
|
|
responseBody: soapBodyPreview(text)
|
|
});
|
|
|
|
const fault = extractSoapFault(text);
|
|
if (!response.ok || fault) {
|
|
throw new VCenterTraceError(`VMware host SOAP action ${action} failed${response.ok ? '' : ` with HTTP ${response.status}`}: ${fault || response.statusText}`, trace, response.status || 502);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function unwrapList(payload) {
|
|
if (Array.isArray(payload)) return payload;
|
|
if (Array.isArray(payload?.value)) return payload.value;
|
|
return [];
|
|
}
|
|
|
|
async function createSession(settings, profile, trace = null) {
|
|
const payload = await vcenterFetch(settings, profile.sessionPath, { method: 'POST', trace });
|
|
const sessionId = typeof payload === 'string' ? payload : payload?.value || payload?.session_id || payload?.id;
|
|
if (!sessionId) throw new VCenterTraceError('vCenter session authentication succeeded but no session id was returned.', trace);
|
|
return sessionId;
|
|
}
|
|
|
|
async function optionalVCenterFetch(settings, path, sessionId, trace = null) {
|
|
try {
|
|
return await vcenterFetch(settings, path, { sessionId, allow404: true, trace });
|
|
} catch (err) {
|
|
return { unavailable: true, reason: err.message };
|
|
}
|
|
}
|
|
|
|
function firstIpFromInterfaces(interfaces = []) {
|
|
for (const nic of interfaces) {
|
|
const addresses = nic?.ip?.ip_addresses || nic?.ip_addresses || [];
|
|
const firstUsable = addresses.find((entry) => {
|
|
const address = entry?.ip_address || entry?.address || entry;
|
|
return address && !String(address).startsWith('fe80:');
|
|
});
|
|
if (firstUsable) return firstUsable.ip_address || firstUsable.address || firstUsable;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function allIpsFromInterfaces(interfaces = []) {
|
|
return interfaces.flatMap((nic) => {
|
|
const addresses = nic?.ip?.ip_addresses || nic?.ip_addresses || [];
|
|
return addresses
|
|
.map((entry) => entry?.ip_address || entry?.address || entry)
|
|
.filter((address) => address && !String(address).startsWith('fe80:'));
|
|
});
|
|
}
|
|
|
|
function inferOsFamily(identity = {}) {
|
|
const haystack = [identity.family, identity.name, localizedText(identity.full_name), identity.guest_OS].filter(Boolean).join(' ').toLowerCase();
|
|
if (WINDOWS_HINTS.some((hint) => haystack.includes(hint))) return 'windows';
|
|
if (LINUX_HINTS.some((hint) => haystack.includes(hint))) return 'linux';
|
|
return 'other';
|
|
}
|
|
|
|
function localizedText(value) {
|
|
if (!value) return '';
|
|
if (typeof value === 'string') return value;
|
|
if (typeof value === 'object') return value.default_message || value.id || '';
|
|
return String(value);
|
|
}
|
|
|
|
export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null) {
|
|
const interfaces = unwrapList(interfacesPayload);
|
|
const interfaceIp = firstIpFromInterfaces(interfaces);
|
|
const identityIp = identity?.ip_address || identity?.ipAddress || '';
|
|
const fqdn = identity?.host_name || identity?.hostName || '';
|
|
const name = vm.name || fqdn || vm.vm || vm.id;
|
|
const address = fqdn || identityIp || interfaceIp || name;
|
|
const ipAddresses = [...new Set([identityIp, interfaceIp, ...allIpsFromInterfaces(interfaces)].filter(Boolean))];
|
|
|
|
return {
|
|
id: vm.vm || vm.id,
|
|
name,
|
|
fqdn,
|
|
address,
|
|
ipAddress: ipAddresses[0] || '',
|
|
ipAddresses,
|
|
powerState: vm.power_state || vm.powerState || '',
|
|
osFamily: inferOsFamily(identity || {}),
|
|
guestFullName: localizedText(identity?.full_name) || identity?.name || '',
|
|
toolsAvailable: Boolean(identity || interfaces.length),
|
|
source: 'vcenter'
|
|
};
|
|
}
|
|
|
|
function normalizeVCenterSummaryVm(vm) {
|
|
return {
|
|
id: vm.vm || vm.id,
|
|
name: vm.name || vm.vm || vm.id || '',
|
|
fqdn: '',
|
|
address: vm.name || vm.vm || vm.id || '',
|
|
ipAddress: '',
|
|
ipAddresses: [],
|
|
powerState: vm.power_state || vm.powerState || '',
|
|
osFamily: 'other',
|
|
guestFullName: '',
|
|
toolsAvailable: false,
|
|
source: 'vcenter'
|
|
};
|
|
}
|
|
|
|
export function vmMatchesFilter(candidate, filter = {}) {
|
|
const operator = filter.operator || 'contains';
|
|
const needle = String(filter.value || '').trim().toLowerCase();
|
|
if (!needle) return true;
|
|
const field = filter.field || 'hostname';
|
|
if (field === 'ip') {
|
|
const values = [candidate.ipAddress, ...(candidate.ipAddresses || [])]
|
|
.filter(Boolean)
|
|
.map((value) => String(value).toLowerCase());
|
|
if (operator === 'equals') return values.some((value) => value === needle);
|
|
if (operator === 'startsWith') return values.some((value) => value.startsWith(needle));
|
|
if (operator === 'endsWith') return values.some((value) => value.endsWith(needle));
|
|
return values.some((value) => value.includes(needle));
|
|
}
|
|
const haystacks = {
|
|
name: [candidate.name],
|
|
hostname: [candidate.name, candidate.fqdn, candidate.address].filter(Boolean),
|
|
fqdn: [candidate.fqdn, candidate.address].filter(Boolean),
|
|
}[field] || [candidate.name];
|
|
const values = haystacks.map((value) => String(value || '').toLowerCase());
|
|
if (operator === 'equals') return values.some((value) => value === needle);
|
|
if (operator === 'startsWith') return values.some((value) => value.startsWith(needle));
|
|
if (operator === 'endsWith') return values.some((value) => value.endsWith(needle));
|
|
return values.some((value) => value.includes(needle));
|
|
}
|
|
|
|
export function filterSummaryVms(vms = [], filter = {}) {
|
|
const field = filter?.field || 'hostname';
|
|
if (!['name', 'hostname'].includes(field)) return { vms, summaryMatched: vms.length, summaryFilterApplied: false, summaryFilterFallback: false };
|
|
const summaryFilter = { ...filter, field: 'name' };
|
|
const matched = vms.filter((vm) => vmMatchesFilter(normalizeVCenterSummaryVm(vm), summaryFilter));
|
|
if (field === 'hostname') {
|
|
const matchedIds = new Set(matched.map((vm) => vm.vm || vm.id));
|
|
return {
|
|
vms: [...matched, ...vms.filter((vm) => !matchedIds.has(vm.vm || vm.id))],
|
|
summaryMatched: matched.length,
|
|
summaryFilterApplied: true,
|
|
summaryFilterFallback: matched.length === 0
|
|
};
|
|
}
|
|
return { vms: matched, summaryMatched: matched.length, summaryFilterApplied: true, summaryFilterFallback: false };
|
|
}
|
|
|
|
function canUseServerSideNameFilter(filter = {}) {
|
|
return filter.field === 'name' && filter.operator === 'equals' && String(filter.value || '').trim();
|
|
}
|
|
|
|
export function buildVmListPath(profile, filter = {}) {
|
|
if (!canUseServerSideNameFilter(filter)) return profile.vmListPath;
|
|
const params = new URLSearchParams();
|
|
params.append(profile.vmNameParam, String(filter.value).trim());
|
|
return `${profile.vmListPath}?${params.toString()}`;
|
|
}
|
|
|
|
export function buildHostPayloadFromVm(candidate, options = {}) {
|
|
const tags = [...new Set(['vmware', 'vcenter', `vcenter:${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 VMware vCenter VM ${candidate.id}.`,
|
|
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: options.connectionId || null,
|
|
sourceRef: candidate.id || ''
|
|
};
|
|
}
|
|
|
|
export async function previewVCenterHosts(options = {}) {
|
|
const trace = [];
|
|
const settings = settingsFromVCenterConnection(options.connection);
|
|
try {
|
|
assertConfigured(settings);
|
|
} catch (err) {
|
|
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 { profile, sessionId, vmPayload } = await connectAndListVms(settings, options.filter, trace);
|
|
const allVms = unwrapList(vmPayload);
|
|
const summaryFilter = filterSummaryVms(allVms, options.filter);
|
|
const vms = summaryFilter.vms;
|
|
const candidates = [];
|
|
const diagnostics = {
|
|
totalFromVCenter: allVms.length,
|
|
scanned: 0,
|
|
summaryMatched: summaryFilter.summaryMatched,
|
|
summaryFilterApplied: summaryFilter.summaryFilterApplied,
|
|
summaryFilterFallback: summaryFilter.summaryFilterFallback,
|
|
apiMode: profile.mode,
|
|
apiProfile: profile.label,
|
|
serverSideNameFilter: canUseServerSideNameFilter(options.filter),
|
|
resultLimit: options.limit || 100,
|
|
sampleNames: allVms.slice(0, 12).map((vm) => vm.name || vm.vm || vm.id).filter(Boolean),
|
|
filter: options.filter || {}
|
|
};
|
|
|
|
await enrichVmsForPreview({
|
|
vms,
|
|
settings,
|
|
profile,
|
|
sessionId,
|
|
trace,
|
|
filter: options.filter,
|
|
limit: options.limit || 100,
|
|
diagnostics,
|
|
candidates
|
|
});
|
|
|
|
return {
|
|
status: options.connection ? vcenterConnectionStatus(options.connection) : vcenterStatus(),
|
|
filter: options.filter || {},
|
|
count: candidates.length,
|
|
diagnostics,
|
|
trace,
|
|
candidates
|
|
};
|
|
}
|
|
|
|
export async function testVCenterConnection(connection) {
|
|
const trace = [];
|
|
const settings = settingsFromVCenterConnection(connection);
|
|
assertConfigured(settings);
|
|
if (settings.targetType === 'host') return testStandaloneHostConnection(connection, settings, trace);
|
|
const { profile, vmPayload } = await connectAndListVms(settings, {}, trace);
|
|
return {
|
|
status: vcenterConnectionStatus(connection),
|
|
apiProfile: profile.label,
|
|
count: unwrapList(vmPayload).length,
|
|
trace,
|
|
message: `Connected to ${settings.baseUrl} with ${profile.label}; ${unwrapList(vmPayload).length} VM summary row(s) returned.`
|
|
};
|
|
}
|
|
|
|
export async function testStandaloneHostConnection(connection, settings = settingsFromVCenterConnection(connection), trace = []) {
|
|
pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, {
|
|
mode: 'web-services',
|
|
endpoint: soapEndpoint(settings)
|
|
});
|
|
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,
|
|
trace,
|
|
message: `Connected to standalone VMware host ${settings.baseUrl} with ${WEB_SERVICES_PROFILE_LABEL}.`
|
|
};
|
|
}
|
|
|
|
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 modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];
|
|
let lastError = null;
|
|
for (const mode of modes) {
|
|
const profile = API_PROFILES[mode];
|
|
try {
|
|
const sessionId = await createSession(settings, profile, trace);
|
|
const payload = await vcenterFetch(settings, profile.vmDetailPath(vmId), { sessionId, trace });
|
|
const value = payload?.value || payload || {};
|
|
return {
|
|
checked: true,
|
|
state: value.power_state || value.powerState || '',
|
|
apiProfile: profile.label
|
|
};
|
|
} catch (err) {
|
|
lastError = err;
|
|
if (settings.apiMode !== 'auto' || mode === modes.at(-1) || !shouldTryFallback(err)) throw err;
|
|
pushTraceInfo(trace, `Falling back after ${profile.label} power-state lookup failed`, {
|
|
mode,
|
|
error: err.message,
|
|
statusCode: err.statusCode
|
|
});
|
|
}
|
|
}
|
|
throw lastError || new VCenterTraceError('No vCenter API profile could be attempted for power-state lookup.', trace);
|
|
}
|
|
|
|
async function enrichVmsForPreview({ vms, settings, profile, sessionId, trace, filter, limit, diagnostics, candidates }) {
|
|
let index = 0;
|
|
async function worker() {
|
|
while (index < vms.length && candidates.length < limit) {
|
|
const vm = vms[index++];
|
|
const vmId = vm.vm || vm.id;
|
|
if (!vmId) continue;
|
|
diagnostics.scanned += 1;
|
|
const identity = await optionalVCenterFetch(settings, profile.guestIdentityPath(vmId), sessionId, trace);
|
|
const candidateWithoutNetworking = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, null);
|
|
if (!vmMatchesFilter(candidateWithoutNetworking, filter)) continue;
|
|
const interfaces = await optionalVCenterFetch(settings, profile.guestNetworkingPath(vmId), sessionId, trace);
|
|
const candidate = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, interfaces?.unavailable ? null : interfaces);
|
|
if (candidates.length < limit) candidates.push(candidate);
|
|
}
|
|
}
|
|
|
|
await Promise.all(
|
|
Array.from({ length: Math.min(VM_ENRICHMENT_CONCURRENCY, Math.max(vms.length, 1)) }, () => worker())
|
|
);
|
|
}
|
|
|
|
function shouldTryFallback(err) {
|
|
return err instanceof VCenterTraceError && [404, 405, 501].includes(err.statusCode);
|
|
}
|
|
|
|
async function connectAndListWithProfile(settings, filter, profile, trace) {
|
|
pushTraceInfo(trace, `Using ${profile.label}`, {
|
|
mode: profile.mode,
|
|
sessionPath: profile.sessionPath,
|
|
vmListPath: buildVmListPath(profile, filter)
|
|
});
|
|
const sessionId = await createSession(settings, profile, trace);
|
|
const vmPayload = await vcenterFetch(settings, buildVmListPath(profile, filter), { sessionId, trace });
|
|
return { profile, sessionId, vmPayload };
|
|
}
|
|
|
|
async function connectAndListVms(settings, filter, trace) {
|
|
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];
|
|
let lastError = null;
|
|
for (const mode of modes) {
|
|
try {
|
|
return await connectAndListWithProfile(settings, filter, API_PROFILES[mode], trace);
|
|
} catch (err) {
|
|
lastError = err;
|
|
if (settings.apiMode !== 'auto' || mode === modes.at(-1) || !shouldTryFallback(err)) throw err;
|
|
pushTraceInfo(trace, `Falling back after ${API_PROFILES[mode].label} failed`, {
|
|
mode,
|
|
error: err.message,
|
|
statusCode: err.statusCode
|
|
});
|
|
}
|
|
}
|
|
throw lastError || new VCenterTraceError('No vCenter API profile could be attempted.', trace);
|
|
}
|