This commit is contained in:
2026-06-25 13:58:04 -05:00
parent 411e8325b0
commit acc94dc6a1
6 changed files with 91 additions and 19 deletions

View File

@@ -163,6 +163,7 @@ Important environment variables:
| `VCENTER_USERNAME` | vCenter account used by the backend to create API sessions. | | `VCENTER_USERNAME` | vCenter account used by the backend to create API sessions. |
| `VCENTER_PASSWORD` | vCenter password. Prefer environment pinning for production deployments. | | `VCENTER_PASSWORD` | vCenter password. Prefer environment pinning for production deployments. |
| `VCENTER_API_MODE` | `auto`, `api`, or `rest`. Defaults to `auto`, which tries modern `/api` then legacy `/rest` on incompatible endpoints. | | `VCENTER_API_MODE` | `auto`, `api`, or `rest`. Defaults to `auto`, which tries modern `/api` then legacy `/rest` on incompatible endpoints. |
| `VCENTER_REQUEST_TIMEOUT_MS` | Per-request vCenter HTTP timeout. Defaults to `20000`. |
| `VCENTER_ALLOW_UNTRUSTED_TLS` | Set `true` to allow self-signed/private CA vCenter certificates. | | `VCENTER_ALLOW_UNTRUSTED_TLS` | Set `true` to allow self-signed/private CA vCenter certificates. |
| `CATALOG_CHECK_INTERVAL_MINUTES` | Minutes between catalog upstream-version checks. `0` disables. | | `CATALOG_CHECK_INTERVAL_MINUTES` | Minutes between catalog upstream-version checks. `0` disables. |
| `AUTO_PROMOTE_INTERVAL_MINUTES` | Minutes between auto-promote soak checks. `0` disables. | | `AUTO_PROMOTE_INTERVAL_MINUTES` | Minutes between auto-promote soak checks. `0` disables. |
@@ -534,11 +535,13 @@ target hosts visible to the operator who creates or edits them.
VMware vCenter import uses the effective Config / Integrations values (`vcenter_*` settings or the `VCENTER_*` environment variables). `vcenter_api_mode` can be `auto`, `api`, or `rest`. `api` uses the current vSphere Automation API flow (`POST /api/session`, `GET /api/vcenter/vm`). `rest` uses the legacy/community vCenter REST flow (`POST /rest/com/vmware/cis/session`, `GET /rest/vcenter/vm`). `auto` tries the current `/api` profile first and falls back to `/rest` when the endpoint is not supported. VMware vCenter import uses the effective Config / Integrations values (`vcenter_*` settings or the `VCENTER_*` environment variables). `vcenter_api_mode` can be `auto`, `api`, or `rest`. `api` uses the current vSphere Automation API flow (`POST /api/session`, `GET /api/vcenter/vm`). `rest` uses the legacy/community vCenter REST flow (`POST /rest/com/vmware/cis/session`, `GET /rest/vcenter/vm`). `auto` tries the current `/api` profile first and falls back to `/rest` when the endpoint is not supported.
For exact VM-name searches (`field: "name"`, `operator: "equals"`), POSHManager uses the documented server-side name filters: `names=<vm>` for `/api/vcenter/vm` and `filter.names=<vm>` for `/rest/vcenter/vm`. Contains/starts-with/ends-with searches are filtered locally because vCenter VM list filters do not provide wildcard contains semantics. FQDN and IP discovery depends on VMware Tools data; when guest data is unavailable, the import falls back to the VM name as the host address and records the limitation in host notes. For exact VM-name searches (`field: "name"`, `operator: "equals"`), POSHManager uses the documented server-side name filters: `names=<vm>` for `/api/vcenter/vm` and `filter.names=<vm>` for `/rest/vcenter/vm`. Contains/starts-with/ends-with searches are filtered locally because vCenter VM list filters do not provide wildcard contains semantics. The combined `hostname` field searches both the VM inventory name and the VMware Tools guest `host_name`/FQDN after enrichment. FQDN and IP discovery depends on VMware Tools data; when guest data is unavailable, the import falls back to the VM name as the host address and records the limitation in host notes.
For `field: "name"` filters, the API scans the full VM summary list returned by vCenter before guest enrichment, so large inventories are not accidentally missed by the preview limit. The preview response includes `diagnostics` with `totalFromVCenter`, `summaryMatched`, `scanned`, `resultLimit`, and `sampleNames`; the wizard displays those values when troubleshooting a no-result preview. For `field: "name"` filters, the API scans the full VM summary list returned by vCenter before guest enrichment, so large inventories are not accidentally missed by the preview limit. The preview response includes `diagnostics` with `totalFromVCenter`, `summaryMatched`, `scanned`, `resultLimit`, and `sampleNames`; the wizard displays those values when troubleshooting a no-result preview.
Each preview/import response also includes a redacted `trace` array. The wizard automatically opens a vCenter API trace modal after preview so operators can inspect the actual request URL, redacted headers, HTTP status, duration, response summary, and truncated response body from each vCenter call. Basic auth and `vmware-api-session-id` values are never shown in the trace. Each preview/import response also includes a redacted `trace` array. The wizard opens a vCenter API trace modal immediately when Preview starts, then replaces the running entry with the captured trace when the API responds. Operators can inspect the actual request URL, redacted headers, HTTP status, duration, response summary, and truncated response body from each vCenter call. Basic auth and `vmware-api-session-id` values are never shown in the trace.
If vCenter DNS/TCP/TLS/authentication hangs, the backend aborts each vCenter HTTP request after `vcenter_request_timeout_ms` and returns a traceable timeout entry. The browser also adds watchdog entries while it is still waiting for the POSHManager API response, so operators can distinguish a pending backend call from an empty trace.
Preview payload: Preview payload:

View File

@@ -1142,6 +1142,12 @@ const settingMetadata = {
placeholder: 'auto', placeholder: 'auto',
section: 'Integrations' section: 'Integrations'
}, },
vcenter_request_timeout_ms: {
label: 'vCenter Request Timeout',
description: 'Maximum time the backend waits for one vCenter HTTP request before returning a traceable timeout.',
placeholder: '20000',
section: 'Integrations'
},
vcenter_allow_untrusted_tls: { vcenter_allow_untrusted_tls: {
label: 'Allow Untrusted vCenter TLS', label: 'Allow Untrusted vCenter TLS',
description: 'Permit self-signed or private CA vCenter certificates for lab and internal deployments.', description: 'Permit self-signed or private CA vCenter certificates for lab and internal deployments.',
@@ -2391,6 +2397,36 @@ async function previewVCenterImport(payload) {
} }
}]; }];
vcenterTraceOpen.value = true; vcenterTraceOpen.value = true;
const watchdogs = [
setTimeout(() => {
vcenterTraceEntries.value = [
...vcenterTraceEntries.value,
{
info: true,
message: 'Still waiting for the POSHManager API preview response.',
details: {
route: '/api/hosts/import/vcenter/preview',
elapsedMs: 8000,
note: 'If this remains here, the backend is still waiting on vCenter DNS, TCP, TLS, authentication, or VM inventory response.'
}
}
];
}, 8000),
setTimeout(() => {
vcenterTraceEntries.value = [
...vcenterTraceEntries.value,
{
info: true,
message: 'The preview request is still pending.',
details: {
route: '/api/hosts/import/vcenter/preview',
elapsedMs: 18000,
note: 'The backend vCenter request timeout should return a traceable timeout shortly unless the API process is blocked before dispatch.'
}
}
];
}, 18000)
];
try { try {
const result = await api.post('/api/hosts/import/vcenter/preview', payload); const result = await api.post('/api/hosts/import/vcenter/preview', payload);
vcenterImportStatus.value = result.status; vcenterImportStatus.value = result.status;
@@ -2413,6 +2449,7 @@ async function previewVCenterImport(payload) {
vcenterImportError.value = err.message; vcenterImportError.value = err.message;
notify(err.message, 'error'); notify(err.message, 'error');
} finally { } finally {
watchdogs.forEach((timer) => clearTimeout(timer));
vcenterImportLoading.value = false; vcenterImportLoading.value = false;
} }
} }

View File

@@ -46,6 +46,7 @@ export const config = {
username: process.env.VCENTER_USERNAME || '', username: process.env.VCENTER_USERNAME || '',
password: process.env.VCENTER_PASSWORD || '', password: process.env.VCENTER_PASSWORD || '',
apiMode: process.env.VCENTER_API_MODE || 'auto', apiMode: process.env.VCENTER_API_MODE || 'auto',
requestTimeoutMs: Number(process.env.VCENTER_REQUEST_TIMEOUT_MS || 20000),
allowUntrustedTls: (process.env.VCENTER_ALLOW_UNTRUSTED_TLS || 'false').toLowerCase() === 'true' allowUntrustedTls: (process.env.VCENTER_ALLOW_UNTRUSTED_TLS || 'false').toLowerCase() === 'true'
}, },
entra: { entra: {

View File

@@ -39,12 +39,14 @@ export class VCenterTraceError extends Error {
export function getVCenterSettings() { export function getVCenterSettings() {
const apiMode = getStringSetting('vcenter_api_mode', config.vcenter.apiMode || 'auto').toLowerCase(); 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 { return {
enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled), enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled),
baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)), baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)),
username: getStringSetting('vcenter_username', config.vcenter.username), username: getStringSetting('vcenter_username', config.vcenter.username),
password: getStringSetting('vcenter_password', config.vcenter.password), password: getStringSetting('vcenter_password', config.vcenter.password),
apiMode: ['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto', 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) allowUntrustedTls: getBooleanSetting('vcenter_allow_untrusted_tls', config.vcenter.allowUntrustedTls)
}; };
} }
@@ -61,6 +63,7 @@ export function vcenterStatus() {
baseUrl: settings.baseUrl, baseUrl: settings.baseUrl,
username: settings.username, username: settings.username,
apiMode: settings.apiMode, apiMode: settings.apiMode,
requestTimeoutMs: settings.requestTimeoutMs,
allowUntrustedTls: settings.allowUntrustedTls allowUntrustedTls: settings.allowUntrustedTls
}; };
} }
@@ -137,7 +140,8 @@ async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow40
response = await fetch(`${settings.baseUrl}${path}`, { response = await fetch(`${settings.baseUrl}${path}`, {
method, method,
headers, headers,
dispatcher: dispatcherFor(settings) dispatcher: dispatcherFor(settings),
signal: AbortSignal.timeout(settings.requestTimeoutMs)
}); });
payload = await parseVCenterResponse(response); payload = await parseVCenterResponse(response);
} catch (err) { } catch (err) {
@@ -150,9 +154,15 @@ async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow40
'vmware-api-session-id': headers['vmware-api-session-id'] ? '[redacted session id]' : undefined 'vmware-api-session-id': headers['vmware-api-session-id'] ? '[redacted session id]' : undefined
}, },
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
networkError: err.message timeoutMs: settings.requestTimeoutMs,
networkError: err.name === 'TimeoutError'
? `Timed out after ${settings.requestTimeoutMs} ms waiting for vCenter.`
: err.message
}); });
throw new VCenterTraceError(`Unable to reach vCenter ${method} ${path}: ${err.message}`, trace); 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, { pushTrace(trace, {
@@ -225,12 +235,19 @@ function allIpsFromInterfaces(interfaces = []) {
} }
function inferOsFamily(identity = {}) { function inferOsFamily(identity = {}) {
const haystack = [identity.family, identity.name, identity.full_name, identity.guest_OS].filter(Boolean).join(' ').toLowerCase(); 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 (WINDOWS_HINTS.some((hint) => haystack.includes(hint))) return 'windows';
if (LINUX_HINTS.some((hint) => haystack.includes(hint))) return 'linux'; if (LINUX_HINTS.some((hint) => haystack.includes(hint))) return 'linux';
return 'windows'; return 'windows';
} }
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) { export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null) {
const interfaces = unwrapList(interfacesPayload); const interfaces = unwrapList(interfacesPayload);
const interfaceIp = firstIpFromInterfaces(interfaces); const interfaceIp = firstIpFromInterfaces(interfaces);
@@ -249,7 +266,7 @@ export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null
ipAddresses, ipAddresses,
powerState: vm.power_state || vm.powerState || '', powerState: vm.power_state || vm.powerState || '',
osFamily: inferOsFamily(identity || {}), osFamily: inferOsFamily(identity || {}),
guestFullName: identity?.full_name || identity?.name || '', guestFullName: localizedText(identity?.full_name) || identity?.name || '',
toolsAvailable: Boolean(identity || interfaces.length), toolsAvailable: Boolean(identity || interfaces.length),
source: 'vcenter' source: 'vcenter'
}; };
@@ -285,16 +302,16 @@ export function vmMatchesFilter(candidate, filter = {}) {
if (operator === 'endsWith') return values.some((value) => value.endsWith(needle)); if (operator === 'endsWith') return values.some((value) => value.endsWith(needle));
return values.some((value) => value.includes(needle)); return values.some((value) => value.includes(needle));
} }
const haystack = { const haystacks = {
name: candidate.name, name: [candidate.name],
hostname: candidate.name || candidate.fqdn, hostname: [candidate.name, candidate.fqdn, candidate.address].filter(Boolean),
fqdn: candidate.fqdn || candidate.address, fqdn: [candidate.fqdn, candidate.address].filter(Boolean),
}[field] || candidate.name; }[field] || [candidate.name];
const value = String(haystack || '').toLowerCase(); const values = haystacks.map((value) => String(value || '').toLowerCase());
if (operator === 'equals') return value === needle; if (operator === 'equals') return values.some((value) => value === needle);
if (operator === 'startsWith') return value.startsWith(needle); if (operator === 'startsWith') return values.some((value) => value.startsWith(needle));
if (operator === 'endsWith') return value.endsWith(needle); if (operator === 'endsWith') return values.some((value) => value.endsWith(needle));
return value.includes(needle); return values.some((value) => value.includes(needle));
} }
export function filterSummaryVms(vms = [], filter = {}) { export function filterSummaryVms(vms = [], filter = {}) {

View File

@@ -40,6 +40,7 @@ export function settingDefinitions() {
{ key: 'vcenter_username', value: config.vcenter.username, pinned: envProvided('VCENTER_USERNAME') }, { key: 'vcenter_username', value: config.vcenter.username, pinned: envProvided('VCENTER_USERNAME') },
{ key: 'vcenter_password', value: config.vcenter.password, pinned: envProvided('VCENTER_PASSWORD') }, { key: 'vcenter_password', value: config.vcenter.password, pinned: envProvided('VCENTER_PASSWORD') },
{ key: 'vcenter_api_mode', value: config.vcenter.apiMode, pinned: envProvided('VCENTER_API_MODE') }, { key: 'vcenter_api_mode', value: config.vcenter.apiMode, pinned: envProvided('VCENTER_API_MODE') },
{ key: 'vcenter_request_timeout_ms', value: String(config.vcenter.requestTimeoutMs), pinned: envProvided('VCENTER_REQUEST_TIMEOUT_MS') },
{ key: 'vcenter_allow_untrusted_tls', value: String(config.vcenter.allowUntrustedTls), pinned: envProvided('VCENTER_ALLOW_UNTRUSTED_TLS') } { key: 'vcenter_allow_untrusted_tls', value: String(config.vcenter.allowUntrustedTls), pinned: envProvided('VCENTER_ALLOW_UNTRUSTED_TLS') }
]; ];
} }

View File

@@ -19,7 +19,7 @@ test('normalizeBaseUrl trims trailing slashes', () => {
test('normalizeVCenterVm maps guest identity and networking into a host candidate', () => { test('normalizeVCenterVm maps guest identity and networking into a host candidate', () => {
const candidate = normalizeVCenterVm( const candidate = normalizeVCenterVm(
{ vm: 'vm-42', name: 'ENG-ENT-APP01', power_state: 'POWERED_ON' }, { vm: 'vm-42', name: 'ENG-ENT-APP01', power_state: 'POWERED_ON' },
{ host_name: 'eng-ent-app01.contoso.local', ip_address: '10.42.1.20', full_name: 'Microsoft Windows Server 2022' }, { host_name: 'eng-ent-app01.contoso.local', ip_address: '10.42.1.20', full_name: { default_message: 'Microsoft Windows Server 2022' } },
[{ ip: { ip_addresses: [{ ip_address: 'fe80::1' }, { ip_address: '10.42.1.21' }] } }] [{ ip: { ip_addresses: [{ ip_address: 'fe80::1' }, { ip_address: '10.42.1.21' }] } }]
); );
@@ -29,16 +29,29 @@ test('normalizeVCenterVm maps guest identity and networking into a host candidat
assert.equal(candidate.address, 'eng-ent-app01.contoso.local'); assert.equal(candidate.address, 'eng-ent-app01.contoso.local');
assert.deepEqual(candidate.ipAddresses, ['10.42.1.20', '10.42.1.21']); assert.deepEqual(candidate.ipAddresses, ['10.42.1.20', '10.42.1.21']);
assert.equal(candidate.osFamily, 'windows'); assert.equal(candidate.osFamily, 'windows');
assert.equal(candidate.guestFullName, 'Microsoft Windows Server 2022');
}); });
test('vmMatchesFilter supports hostname contains and IP equals matching', () => { test('vmMatchesFilter supports hostname contains and IP equals matching', () => {
const candidate = { name: 'ENG-ENT-APP01', fqdn: 'app01.contoso.local', ipAddress: '10.42.1.20', ipAddresses: ['10.42.1.20'] }; const candidate = { name: 'ENG-ENT-APP01', fqdn: 'app01.contoso.local', address: 'app01.contoso.local', ipAddress: '10.42.1.20', ipAddresses: ['10.42.1.20'] };
assert.equal(vmMatchesFilter(candidate, { field: 'hostname', operator: 'contains', value: 'eng-ent' }), true); assert.equal(vmMatchesFilter(candidate, { field: 'hostname', operator: 'contains', value: 'eng-ent' }), true);
assert.equal(vmMatchesFilter(candidate, { field: 'hostname', operator: 'contains', value: 'contoso' }), true);
assert.equal(vmMatchesFilter(candidate, { field: 'ip', operator: 'equals', value: '10.42.1.20' }), true); assert.equal(vmMatchesFilter(candidate, { field: 'ip', operator: 'equals', value: '10.42.1.20' }), true);
assert.equal(vmMatchesFilter(candidate, { field: 'fqdn', operator: 'startsWith', value: 'db' }), false); assert.equal(vmMatchesFilter(candidate, { field: 'fqdn', operator: 'startsWith', value: 'db' }), false);
}); });
test('vmMatchesFilter matches guest-only host names after enrichment', () => {
const candidate = normalizeVCenterVm(
{ vm: 'vm-200', name: 'KSUS01PAPP01', power_state: 'POWERED_ON' },
{ host_name: 'MCS-WIN-APP01.ent.mitekindustries.com', ip_address: '10.42.5.44', family: 'WINDOWS' },
[]
);
assert.equal(vmMatchesFilter(candidate, { field: 'hostname', operator: 'contains', value: 'MCS' }), true);
assert.equal(vmMatchesFilter(candidate, { field: 'name', operator: 'contains', value: 'MCS' }), false);
});
test('filterSummaryVms scans the full VM summary list for VM-name filters', () => { test('filterSummaryVms scans the full VM summary list for VM-name filters', () => {
const vms = [ const vms = [
...Array.from({ length: 900 }, (_, index) => ({ vm: `vm-${index}`, name: `ZZZ-${index}` })), ...Array.from({ length: 900 }, (_, index) => ({ vm: `vm-${index}`, name: `ZZZ-${index}` })),