This commit is contained in:
2026-06-25 16:51:56 -05:00
parent 94f4d8959d
commit 402bf6782a
25 changed files with 1323 additions and 103 deletions

View File

@@ -0,0 +1,334 @@
import { normalizeOsFamily } from '../utils/osFamily.js';
import { db } from '../db.js';
const WINDOWS_EXECUTABLES = [
'cmd.exe',
'powershell.exe',
'wmic.exe',
'msiexec.exe',
'reg.exe',
'sc.exe',
'net.exe',
'netsh.exe',
'schtasks.exe',
'whoami.exe',
'gpupdate.exe'
];
const ALIASES_TO_AVOID = {
'%': 'ForEach-Object',
'?': 'Where-Object',
cat: 'Get-Content',
cd: 'Set-Location',
cls: 'Clear-Host',
copy: 'Copy-Item',
cp: 'Copy-Item',
del: 'Remove-Item',
dir: 'Get-ChildItem',
echo: 'Write-Output',
erase: 'Remove-Item',
h: 'Get-History',
history: 'Get-History',
kill: 'Stop-Process',
ls: 'Get-ChildItem',
md: 'mkdir',
move: 'Move-Item',
mv: 'Move-Item',
popd: 'Pop-Location',
ps: 'Get-Process',
pushd: 'Push-Location',
pwd: 'Get-Location',
r: 'Invoke-History',
rd: 'Remove-Item',
ren: 'Rename-Item',
rm: 'Remove-Item',
rmdir: 'Remove-Item',
sl: 'Set-Location',
sort: 'Sort-Object',
type: 'Get-Content',
where: 'Where-Object'
};
const RULES = [
{
id: 'windows-registry-provider',
level: 'warning',
title: 'Windows registry provider usage',
pattern: /\b(?:HKLM:|HKCU:|HKCR:|HKU:|Registry::|SOFTWARE\\|SYSTEM\\CurrentControlSet\\)/i,
message: 'The script references Windows registry providers or registry paths, which are not available on Linux targets.',
remediation: 'Guard registry logic with $IsWindows or split the script by OS target.'
},
{
id: 'windows-drive-or-unc-path',
level: 'warning',
title: 'Windows filesystem path usage',
pattern: /(?:\b[A-Za-z]:\\|\\\\[A-Za-z0-9_.-]+\\)/,
message: 'The script contains Windows drive or UNC paths. Linux PowerShell uses Linux paths and case-sensitive filesystems.',
remediation: 'Use Join-Path and OS-aware path roots, or branch path logic with $IsWindows/$IsLinux.'
},
{
id: 'windows-environment-variable',
level: 'warning',
title: 'Windows environment variable usage',
pattern: /\$env:(?:ProgramFiles|ProgramFiles\(x86\)|SystemRoot|WINDIR|COMPUTERNAME|USERPROFILE|APPDATA|LOCALAPPDATA)\b/i,
message: 'The script references Windows-specific environment variables that are usually absent on Linux.',
remediation: 'Use cross-platform variables such as $HOME where possible, or provide Linux-specific fallbacks.'
},
{
id: 'wmi-or-win32-class',
level: 'warning',
title: 'WMI or Win32 class usage',
pattern: /\b(?:Get-WmiObject|gwmi|Win32_[A-Za-z0-9_]+|root\\cimv2)\b/i,
message: 'The script uses WMI/Win32 inventory patterns that are Windows-specific and commonly fail on Linux.',
remediation: 'Use cross-platform PowerShell/.NET APIs, SSH-native commands, or OS-specific branches.'
},
{
id: 'windows-service-or-eventlog-cmdlet',
level: 'warning',
title: 'Windows service or event log cmdlet usage',
pattern: /\b(?:Get-EventLog|New-EventLog|Write-EventLog|Get-WinEvent|Get-Service|Set-Service|Restart-Service)\b/i,
message: 'This cmdlet often targets Windows service or event-log subsystems and may not behave the same on Linux hosts.',
remediation: 'Validate Linux behavior explicitly or wrap the command in an OS-specific branch.'
},
{
id: 'windows-executable',
level: 'warning',
title: 'Windows executable usage',
pattern: new RegExp(`\\b(?:${WINDOWS_EXECUTABLES.map(escapeRegExp).join('|')})\\b`, 'i'),
message: 'The script launches Windows executables that are not expected to exist on Linux targets.',
remediation: 'Replace with PowerShell-native commands, Linux equivalents, or target Windows hosts only.'
}
];
export function analyzePowerShellCompatibility(content, targets = []) {
const targetOsFamilies = [...new Set(targets.map((target) => normalizeOsFamily(target.osFamily || target.os_family)).filter(Boolean))];
const hasLinuxTargets = targetOsFamilies.includes('linux');
const findings = [];
if (!hasLinuxTargets) {
return {
targetOsFamilies,
hasLinuxTargets,
findings,
summary: 'No Linux targets selected.'
};
}
const text = String(content || '');
const lines = text.split(/\r?\n/);
for (const rule of RULES) {
const match = findRuleMatch(lines, rule.pattern);
if (match) findings.push(toFinding(rule, match));
}
for (const match of findAliasMatches(lines).slice(0, 6)) {
findings.push({
id: `alias-${match.alias}`,
level: 'info',
title: `Alias "${match.alias}" used`,
message: `Alias "${match.alias}" maps to ${match.command}. Aliases can be profile or host dependent, so they are risky in scripts that run across Windows and Linux.`,
remediation: `Use the full command name ${match.command}.`,
line: match.line,
excerpt: match.excerpt,
source: 'Microsoft PowerShell alias guidance'
});
}
if (!findings.length) {
findings.push({
id: 'linux-ready',
level: 'ok',
title: 'No obvious Linux blockers detected',
message: 'Static analysis did not find common Windows-only patterns. Runtime behavior still depends on installed modules and host configuration.',
remediation: 'Run a controlled test against a Linux host before broad execution.',
line: null,
excerpt: '',
source: 'POSHManager compatibility analyzer'
});
}
return {
targetOsFamilies,
hasLinuxTargets,
findings,
summary: `${findings.filter((finding) => finding.level === 'warning').length} warning(s), ${findings.filter((finding) => finding.level === 'info').length} note(s) for Linux targets.`
};
}
export function analyzeScriptTargetCompatibility(scriptId, payload, userId) {
const script = db.prepare(`
SELECT *
FROM scripts
WHERE id = ? AND (
visibility = 'shared'
OR owner_user_id = ?
OR group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
`).get(scriptId, userId, userId);
if (!script) return null;
const targets = resolveVisibleCompatibilityTargets(payload, userId);
return {
scriptId: script.id,
scriptName: script.name,
targets: summarizeTargets(targets),
...analyzePowerShellCompatibility(script.content, targets)
};
}
export function analyzeRunPlanCompatibility(runplanId, userId) {
const runplan = db.prepare(`
SELECT r.*, s.content AS script_content, s.name AS script_name
FROM runplans r
JOIN scripts s ON s.id = r.script_id
WHERE r.id = ? AND (
r.visibility = 'shared'
OR r.owner_user_id = ?
OR r.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
`).get(runplanId, userId, userId);
if (!runplan) return null;
const targets = resolveRunPlanTargets(runplanId);
return {
runplanId,
scriptId: runplan.script_id,
scriptName: runplan.script_name,
targets: summarizeTargets(targets),
...analyzePowerShellCompatibility(runplan.script_content, targets)
};
}
export function compatibilityLogLines(analysis) {
if (!analysis?.hasLinuxTargets) return [];
return [
`Linux target compatibility preflight: ${analysis.summary}`,
...analysis.findings
.filter((finding) => finding.level !== 'ok')
.slice(0, 10)
.map((finding) => `${finding.level.toUpperCase()}: ${finding.title}${finding.line ? ` on line ${finding.line}` : ''}. ${finding.message} ${finding.remediation}`)
];
}
function findRuleMatch(lines, pattern) {
for (const [index, line] of lines.entries()) {
if (pattern.test(line)) return { line: index + 1, excerpt: line.trim().slice(0, 260) };
}
return null;
}
function toFinding(rule, match) {
return {
id: rule.id,
level: rule.level,
title: rule.title,
message: rule.message,
remediation: rule.remediation,
line: match.line,
excerpt: match.excerpt,
source: 'PowerShell cross-platform compatibility'
};
}
function findAliasMatches(lines) {
const matches = [];
const aliases = Object.keys(ALIASES_TO_AVOID).sort((a, b) => b.length - a.length).map(escapeRegExp).join('|');
const commandPattern = new RegExp(`^\\s*(?:&\\s*)?(${aliases})(?:\\s|$|\\|)`, 'i');
const pipelinePattern = new RegExp(`\\|\\s*(${aliases})(?:\\s|$|\\|)`, 'i');
for (const [index, line] of lines.entries()) {
const stripped = line.replace(/#.*/, '');
const match = stripped.match(commandPattern) || stripped.match(pipelinePattern);
if (!match) continue;
const alias = match[1].toLowerCase();
matches.push({
alias,
command: ALIASES_TO_AVOID[alias],
line: index + 1,
excerpt: line.trim().slice(0, 260)
});
}
return matches;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function resolveVisibleCompatibilityTargets(payload = {}, userId) {
const hostIds = [...new Set([payload.hostId, ...(payload.hostIds || [])].filter(Boolean))];
const groupIds = [...new Set([payload.hostGroupId, ...(payload.hostGroupIds || [])].filter(Boolean))];
const targets = [];
if (hostIds.length) {
targets.push(...db.prepare(`
SELECT h.id, h.name, h.address, h.os_family AS osFamily, h.transport
FROM hosts h
WHERE h.id IN (${hostIds.map(() => '?').join(',')})
AND (
h.visibility = 'shared'
OR h.owner_user_id = ?
OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
`).all(...hostIds, userId, userId));
}
if (groupIds.length) {
targets.push(...db.prepare(`
SELECT h.id, h.name, h.address, h.os_family AS osFamily, h.transport
FROM host_group_members hgm
JOIN host_groups hg ON hg.id = hgm.host_group_id
AND (
hg.visibility = 'shared'
OR hg.owner_user_id = ?
OR hg.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
JOIN hosts h ON h.id = hgm.host_id
AND (
h.visibility = 'shared'
OR h.owner_user_id = ?
OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
WHERE hgm.host_group_id IN (${groupIds.map(() => '?').join(',')})
`).all(userId, userId, userId, userId, ...groupIds));
}
return dedupeTargets(targets);
}
function resolveRunPlanTargets(runplanId) {
return dedupeTargets(db.prepare(`
SELECT h.id, h.name, h.address, h.os_family AS osFamily, h.transport
FROM hosts h
WHERE h.id IN (
SELECT host_id FROM runplan_hosts WHERE runplan_id = ?
UNION
SELECT hgm.host_id
FROM runplan_host_groups rhg
JOIN host_group_members hgm ON hgm.host_group_id = rhg.host_group_id
WHERE rhg.runplan_id = ?
)
ORDER BY h.name
`).all(runplanId, runplanId));
}
function dedupeTargets(targets = []) {
const seen = new Set();
return targets.filter((target) => {
if (!target?.id || seen.has(target.id)) return false;
seen.add(target.id);
target.osFamily = normalizeOsFamily(target.osFamily || target.os_family, 'other');
return true;
});
}
function summarizeTargets(targets = []) {
return {
count: targets.length,
osFamilies: [...new Set(targets.map((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other')))],
linuxCount: targets.filter((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other') === 'linux').length,
windowsCount: targets.filter((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other') === 'windows').length,
otherCount: targets.filter((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other') === 'other').length,
sample: targets.slice(0, 8).map((target) => ({
id: target.id,
name: target.name,
address: target.address,
osFamily: normalizeOsFamily(target.osFamily || target.os_family, 'other'),
transport: target.transport
}))
};
}

View File

@@ -11,6 +11,8 @@ import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js'
import { getVCenterConnectionSystem } from '../models/vcenterConnectionModel.js';
import { getVCenterVmPowerState } from './vcenterService.js';
import { path } from '../utils/pathUtils.js';
import { analyzePowerShellCompatibility, compatibilityLogLines } from './powershellCompatibilityService.js';
import { normalizeOsFamily } from '../utils/osFamily.js';
// The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and
// flipped at runtime via the Config screen (allow_script_execution). The DB
@@ -179,6 +181,7 @@ export function executeScriptTest(scriptId, payload, userId) {
async function runJob(jobId, script, hosts, parallel, executionContext) {
// RunPlans can fan out in parallel or execute serially while preserving identical log semantics.
logCompatibilityPreflight(jobId, script, hosts);
const runner = async (host) => runHost(jobId, script, host, executionContext);
if (parallel) {
await Promise.all(hosts.map(runner));
@@ -190,10 +193,20 @@ async function runJob(jobId, script, hosts, parallel, executionContext) {
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId);
}
function logCompatibilityPreflight(jobId, script, hosts) {
const analysis = analyzePowerShellCompatibility(script.content, hosts);
const lines = compatibilityLogLines(analysis);
if (!lines.length) return;
for (const host of hosts.filter((target) => normalizeOsFamily(target.os_family || target.osFamily, 'other') === 'linux')) {
for (const line of lines) appendLog(jobId, host.id, 'system', line);
}
}
async function runHost(jobId, script, host, executionContext) {
// Each host owns its own temp wrapper and job_host lifecycle for per-target evidence.
db.prepare('UPDATE job_hosts SET status = ?, started_at = ? WHERE job_id = ? AND host_id = ?').run('running', now(), jobId, host.id);
appendLog(jobId, host.id, 'system', `Starting ${script.name} on ${host.name} (${host.transport})`);
appendLog(jobId, host.id, 'system', `Target OS type: ${normalizeOsFamily(host.os_family || host.osFamily, 'other')}`);
if (!scriptExecutionAllowed()) {
appendLog(jobId, host.id, 'stderr', 'Script execution is disabled by ALLOW_SCRIPT_EXECUTION=false.');
@@ -372,6 +385,7 @@ function runtimeVariableValues(host, executionContext) {
{ name: 'POSHM_HOST_NAME', value: host.name, valueType: 'string' },
{ name: 'POSHM_HOST_ADDRESS', value: host.address, valueType: 'string' },
{ name: 'POSHM_HOST_TRANSPORT', value: host.transport, valueType: 'string' },
{ name: 'POSHM_HOST_OS_FAMILY', value: normalizeOsFamily(host.os_family || host.osFamily, 'other'), valueType: 'string' },
{ name: 'POSHM_ASSET_MANIFEST', value: assetManifest, valueType: 'string' },
{ name: 'POSHM_TRIGGERED_BY', value: executionContext.triggeredBy || '', valueType: 'string' }
];

View File

@@ -2,12 +2,14 @@ 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',
@@ -45,6 +47,7 @@ export function getVCenterSettings() {
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),
@@ -57,12 +60,16 @@ export function getVCenterSettings() {
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: ['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto',
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)
};
@@ -77,6 +84,7 @@ export function vcenterStatus() {
return {
enabled: settings.enabled,
configured: Boolean(settings.baseUrl && settings.username && settings.password),
targetType: settings.targetType,
baseUrl: settings.baseUrl,
username: settings.username,
apiMode: settings.apiMode,
@@ -92,6 +100,7 @@ export function vcenterConnectionStatus(connection) {
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,
@@ -101,9 +110,10 @@ export function vcenterConnectionStatus(connection) {
}
function assertConfigured(settings) {
if (!settings.enabled) throw new Error('VMware vCenter integration is disabled in Configuration.');
if (!settings.baseUrl) throw new Error('VMware vCenter base URL is missing. Set vcenter_base_url in Configuration.');
if (!settings.username || !settings.password) throw new Error('VMware vCenter credentials are missing. Set vcenter_username and vcenter_password in Configuration.');
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) {
@@ -224,6 +234,115 @@ async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow40
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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;
@@ -270,7 +389,7 @@ 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 'windows';
return 'other';
}
function localizedText(value) {
@@ -313,7 +432,7 @@ function normalizeVCenterSummaryVm(vm) {
ipAddress: '',
ipAddresses: [],
powerState: vm.power_state || vm.powerState || '',
osFamily: 'windows',
osFamily: 'other',
guestFullName: '',
toolsAvailable: false,
source: 'vcenter'
@@ -380,7 +499,7 @@ export function buildHostPayloadFromVm(candidate, options = {}) {
name: candidate.name,
fqdn: candidate.fqdn || '',
address: candidate.address || candidate.ipAddress || candidate.name,
osFamily: candidate.osFamily || 'windows',
osFamily: normalizeOsFamily(candidate.osFamily, 'other'),
transport: options.transport || 'winrm',
port: options.port || null,
credentialId: options.credentialId || null,
@@ -407,6 +526,9 @@ export async function previewVCenterHosts(options = {}) {
} 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);
@@ -452,6 +574,7 @@ 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),
@@ -462,9 +585,39 @@ 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 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;