This commit is contained in:
2026-06-25 13:43:12 -05:00
parent dfc2c9b475
commit d8342a6475
9 changed files with 395 additions and 31 deletions

View File

@@ -5,6 +5,17 @@ import { getBooleanSetting, getStringSetting } from '../models/settingsModel.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;
export class VCenterTraceError extends Error {
constructor(message, trace = [], statusCode = 502) {
super(message);
this.name = 'VCenterTraceError';
this.trace = trace;
this.statusCode = statusCode;
}
}
export function getVCenterSettings() {
return {
@@ -53,22 +64,89 @@ async function parseVCenterResponse(response) {
}
}
async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow404 = false } = {}) {
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') 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.`
});
}
}
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 response = await fetch(`${settings.baseUrl}${path}`, {
const startedAt = Date.now();
let response;
let payload = null;
try {
response = await fetch(`${settings.baseUrl}${path}`, {
method,
headers,
dispatcher: dispatcherFor(settings)
});
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,
networkError: err.message
});
throw new VCenterTraceError(`Unable to reach vCenter ${method} ${path}: ${err.message}`, trace);
}
pushTrace(trace, {
method,
headers,
dispatcher: dispatcherFor(settings)
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;
const payload = await parseVCenterResponse(response);
if (!response.ok) {
const message = payload?.messages?.[0]?.default_message || payload?.error || payload?.message || response.statusText;
throw new Error(`vCenter ${method} ${path} failed with HTTP ${response.status}: ${message}`);
throw new VCenterTraceError(`vCenter ${method} ${path} failed with HTTP ${response.status}: ${message}`, trace, response.status);
}
return payload;
}
@@ -79,16 +157,16 @@ function unwrapList(payload) {
return [];
}
async function createSession(settings) {
const payload = await vcenterFetch(settings, '/api/session', { method: 'POST' });
async function createSession(settings, trace = null) {
const payload = await vcenterFetch(settings, '/api/session', { method: 'POST', trace });
const sessionId = typeof payload === 'string' ? payload : payload?.value || payload?.session_id || payload?.id;
if (!sessionId) throw new Error('vCenter session authentication succeeded but no session id was returned.');
if (!sessionId) throw new VCenterTraceError('vCenter session authentication succeeded but no session id was returned.', trace);
return sessionId;
}
async function optionalVCenterFetch(settings, path, sessionId) {
async function optionalVCenterFetch(settings, path, sessionId, trace = null) {
try {
return await vcenterFetch(settings, path, { sessionId, allow404: true });
return await vcenterFetch(settings, path, { sessionId, allow404: true, trace });
} catch (err) {
return { unavailable: true, reason: err.message };
}
@@ -189,8 +267,14 @@ export function vmMatchesFilter(candidate, filter = {}) {
}
export function filterSummaryVms(vms = [], filter = {}) {
if ((filter?.field || 'hostname') !== 'name') return vms;
return vms.filter((vm) => vmMatchesFilter(normalizeVCenterSummaryVm(vm), 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' && !matched.length) {
return { vms, summaryMatched: 0, summaryFilterApplied: true, summaryFilterFallback: true };
}
return { vms: matched, summaryMatched: matched.length, summaryFilterApplied: true, summaryFilterFallback: false };
}
export function buildHostPayloadFromVm(candidate, options = {}) {
@@ -216,17 +300,25 @@ export function buildHostPayloadFromVm(candidate, options = {}) {
}
export async function previewVCenterHosts(options = {}) {
const trace = [];
const settings = getVCenterSettings();
assertConfigured(settings);
const sessionId = await createSession(settings);
const vmPayload = await vcenterFetch(settings, '/api/vcenter/vm', { sessionId });
try {
assertConfigured(settings);
} catch (err) {
throw new VCenterTraceError(err.message, trace, 400);
}
const sessionId = await createSession(settings, trace);
const vmPayload = await vcenterFetch(settings, '/api/vcenter/vm', { sessionId, trace });
const allVms = unwrapList(vmPayload);
const vms = filterSummaryVms(allVms, options.filter);
const summaryFilter = filterSummaryVms(allVms, options.filter);
const vms = summaryFilter.vms;
const candidates = [];
const diagnostics = {
totalFromVCenter: allVms.length,
scanned: 0,
summaryMatched: vms.length,
summaryMatched: summaryFilter.summaryMatched,
summaryFilterApplied: summaryFilter.summaryFilterApplied,
summaryFilterFallback: summaryFilter.summaryFilterFallback,
resultLimit: options.limit || 100,
sampleNames: allVms.slice(0, 12).map((vm) => vm.name || vm.vm || vm.id).filter(Boolean),
filter: options.filter || {}
@@ -236,8 +328,8 @@ export async function previewVCenterHosts(options = {}) {
const vmId = vm.vm || vm.id;
if (!vmId) continue;
diagnostics.scanned += 1;
const identity = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`, sessionId);
const interfaces = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`, sessionId);
const identity = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`, sessionId, trace);
const interfaces = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`, sessionId, trace);
const candidate = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, interfaces?.unavailable ? null : interfaces);
if (vmMatchesFilter(candidate, options.filter)) candidates.push(candidate);
if (candidates.length >= (options.limit || 100)) break;
@@ -248,6 +340,7 @@ export async function previewVCenterHosts(options = {}) {
filter: options.filter || {},
count: candidates.length,
diagnostics,
trace,
candidates
};
}