Initial
This commit is contained in:
@@ -14,6 +14,8 @@ import { path } from '../utils/pathUtils.js';
|
||||
import { analyzePowerShellCompatibility, compatibilityLogLines } from './powershellCompatibilityService.js';
|
||||
import { normalizeOsFamily } from '../utils/osFamily.js';
|
||||
|
||||
const activeJobs = new Map();
|
||||
|
||||
// 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
|
||||
// value, when present, takes precedence so the UI toggle is actually effective.
|
||||
@@ -181,16 +183,33 @@ 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));
|
||||
} else {
|
||||
for (const host of hosts) await runner(host);
|
||||
}
|
||||
const state = { canceled: false, children: new Map() };
|
||||
activeJobs.set(jobId, state);
|
||||
try {
|
||||
logCompatibilityPreflight(jobId, script, hosts);
|
||||
const runner = async (host) => runHost(jobId, script, host, executionContext);
|
||||
if (parallel) {
|
||||
await Promise.all(hosts.map(runner));
|
||||
} else {
|
||||
for (const host of hosts) {
|
||||
if (isJobCanceled(jobId)) {
|
||||
cancelQueuedHosts(jobId);
|
||||
break;
|
||||
}
|
||||
await runner(host);
|
||||
}
|
||||
}
|
||||
|
||||
const failed = db.prepare('SELECT COUNT(*) AS count FROM job_hosts WHERE job_id = ? AND status = ?').get(jobId, 'failed').count;
|
||||
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId);
|
||||
if (isJobCanceled(jobId)) {
|
||||
cancelQueuedHosts(jobId);
|
||||
db.prepare('UPDATE jobs SET status = ?, ended_at = COALESCE(ended_at, ?) WHERE id = ?').run('canceled', now(), jobId);
|
||||
return;
|
||||
}
|
||||
const failed = db.prepare('SELECT COUNT(*) AS count FROM job_hosts WHERE job_id = ? AND status = ?').get(jobId, 'failed').count;
|
||||
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId);
|
||||
} finally {
|
||||
activeJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
function logCompatibilityPreflight(jobId, script, hosts) {
|
||||
@@ -204,6 +223,10 @@ function logCompatibilityPreflight(jobId, script, hosts) {
|
||||
|
||||
async function runHost(jobId, script, host, executionContext) {
|
||||
// Each host owns its own temp wrapper and job_host lifecycle for per-target evidence.
|
||||
if (isJobCanceled(jobId)) {
|
||||
finishHost(jobId, host.id, 'canceled', 130);
|
||||
return;
|
||||
}
|
||||
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')}`);
|
||||
@@ -233,6 +256,7 @@ async function runHost(jobId, script, host, executionContext) {
|
||||
|
||||
async function vCenterPowerPreflight(jobId, host) {
|
||||
if ((host.source_type || 'manual') !== 'vmware') return true;
|
||||
if (isJobCanceled(jobId)) return false;
|
||||
if (!host.source_connection_id || !host.source_ref) {
|
||||
appendLog(jobId, host.id, 'system', 'Host is marked as VMware-sourced, but no vCenter connection or VM reference is stored. Continuing without a power-state gate.');
|
||||
return true;
|
||||
@@ -328,11 +352,22 @@ function spawnPowerShell(jobId, host, file, secret, executionContext = {}) {
|
||||
},
|
||||
shell: false
|
||||
});
|
||||
const active = activeJobs.get(jobId);
|
||||
if (active) {
|
||||
active.children.set(host.id, child);
|
||||
if (active.canceled) {
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (!child.killed) child.kill('SIGKILL');
|
||||
}, 5000).unref?.();
|
||||
}
|
||||
}
|
||||
let settled = false;
|
||||
|
||||
function settle(status, exitCode) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
activeJobs.get(jobId)?.children.delete(host.id);
|
||||
finishHost(jobId, host.id, status, exitCode);
|
||||
resolve();
|
||||
}
|
||||
@@ -346,6 +381,10 @@ function spawnPowerShell(jobId, host, file, secret, executionContext = {}) {
|
||||
child.on('close', (code, signal) => {
|
||||
if (settled) return;
|
||||
if (signal) appendLog(jobId, host.id, 'stderr', `PowerShell terminated by signal ${signal}`);
|
||||
if (isJobCanceled(jobId)) {
|
||||
settle('canceled', 130);
|
||||
return;
|
||||
}
|
||||
const exitCode = code ?? 1;
|
||||
settle(exitCode === 0 ? 'completed' : 'failed', exitCode);
|
||||
});
|
||||
@@ -366,6 +405,10 @@ function appendLog(jobId, hostId, stream, line) {
|
||||
`).run(id('log'), jobId, hostId, stream, line, now());
|
||||
}
|
||||
|
||||
export function appendJobLog(jobId, stream, line, hostId = null) {
|
||||
appendLog(jobId, hostId, stream, line);
|
||||
}
|
||||
|
||||
function finishHost(jobId, hostId, status, exitCode) {
|
||||
db.prepare(`
|
||||
UPDATE job_hosts SET status = ?, exit_code = ?, ended_at = ?
|
||||
@@ -374,6 +417,52 @@ function finishHost(jobId, hostId, status, exitCode) {
|
||||
appendLog(jobId, hostId, 'system', `Finished with status ${status} and exit code ${exitCode}`);
|
||||
}
|
||||
|
||||
function cancelQueuedHosts(jobId) {
|
||||
const timestamp = now();
|
||||
const rows = db.prepare('SELECT host_id FROM job_hosts WHERE job_id = ? AND status IN (?, ?)').all(jobId, 'queued', 'running');
|
||||
db.prepare(`
|
||||
UPDATE job_hosts
|
||||
SET status = 'canceled', exit_code = COALESCE(exit_code, 130), ended_at = COALESCE(ended_at, ?)
|
||||
WHERE job_id = ? AND status IN ('queued', 'running')
|
||||
`).run(timestamp, jobId);
|
||||
for (const row of rows) appendLog(jobId, row.host_id, 'system', 'Host execution canceled by job management.');
|
||||
}
|
||||
|
||||
function isJobCanceled(jobId) {
|
||||
if (activeJobs.get(jobId)?.canceled) return true;
|
||||
const row = db.prepare('SELECT status FROM jobs WHERE id = ?').get(jobId);
|
||||
return row?.status === 'canceled';
|
||||
}
|
||||
|
||||
export function activeExecutionJobIds() {
|
||||
return [...activeJobs.keys()];
|
||||
}
|
||||
|
||||
export function cancelExecutionJob(jobId, { reason = '', actorUserId = '' } = {}) {
|
||||
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId);
|
||||
if (!job) return { canceled: false, reason: 'Job not found.' };
|
||||
if (!['queued', 'running'].includes(job.status)) {
|
||||
return { canceled: false, reason: `Job is ${job.status} and cannot be canceled.` };
|
||||
}
|
||||
const active = activeJobs.get(jobId);
|
||||
if (active) active.canceled = true;
|
||||
const children = active ? [...active.children.values()] : [];
|
||||
appendLog(jobId, null, 'system', `Cancel requested${actorUserId ? ` by ${actorUserId}` : ''}${reason ? `: ${reason}` : '.'}`);
|
||||
db.prepare('UPDATE jobs SET status = ?, ended_at = COALESCE(ended_at, ?) WHERE id = ?').run('canceled', now(), jobId);
|
||||
cancelQueuedHosts(jobId);
|
||||
for (const child of children) {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (!child.killed) child.kill('SIGKILL');
|
||||
}, 5000).unref?.();
|
||||
} catch (error) {
|
||||
logger.warn('failed to signal child process during job cancel', { jobId, error: error.message });
|
||||
}
|
||||
}
|
||||
return { canceled: true, signaledProcesses: children.length };
|
||||
}
|
||||
|
||||
function runtimeVariableValues(host, executionContext) {
|
||||
const assetManifest = JSON.stringify(executionContext.assets || []);
|
||||
return [
|
||||
|
||||
257
server/services/systemJobService.js
Normal file
257
server/services/systemJobService.js
Normal file
@@ -0,0 +1,257 @@
|
||||
import { db, now } from '../db.js';
|
||||
import { camelJob } from '../models/mappers.js';
|
||||
import { listSystemJobActions, recordSystemJobAction } from '../models/systemJobModel.js';
|
||||
import { syncDynamicHostGroups } from '../models/hostGroupModel.js';
|
||||
import { runCatalogChecks } from './catalogWorker.js';
|
||||
import { runAutoPromotions } from './promotionWorker.js';
|
||||
import { cancelExecutionJob, appendJobLog, activeExecutionJobIds } from './powershellRunner.js';
|
||||
|
||||
const backgroundRuns = new Map();
|
||||
|
||||
const BACKGROUND_JOBS = [
|
||||
{
|
||||
id: 'worker:host-group-sync',
|
||||
name: 'Dynamic Host Group Sync',
|
||||
category: 'Inventory',
|
||||
description: 'Reconciles rule-based Host Groups against the current Host Library.',
|
||||
run: async ({ signal } = {}) => syncDynamicHostGroups({ signal })
|
||||
},
|
||||
{
|
||||
id: 'worker:catalog-auto-check',
|
||||
name: 'Application Catalog Auto-Check',
|
||||
category: 'Applications',
|
||||
description: 'Checks opted-in application records for newer upstream versions.',
|
||||
run: async ({ signal } = {}) => runCatalogChecks({ signal })
|
||||
},
|
||||
{
|
||||
id: 'worker:intune-auto-promote',
|
||||
name: 'Intune Auto-Promotion',
|
||||
category: 'Intune',
|
||||
description: 'Promotes deployment rings when configured soak windows elapse.',
|
||||
run: async ({ signal } = {}) => runAutoPromotions({ signal })
|
||||
}
|
||||
];
|
||||
|
||||
export function listSystemJobs() {
|
||||
return {
|
||||
jobs: [...executionJobs(), ...backgroundJobs()],
|
||||
actions: listSystemJobActions(200),
|
||||
activeExecutionJobIds: activeExecutionJobIds()
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSystemJob(targetId, actorUserId) {
|
||||
const definition = BACKGROUND_JOBS.find((job) => job.id === targetId);
|
||||
if (!definition) {
|
||||
const action = recordSystemJobAction({
|
||||
targetType: 'system',
|
||||
targetId,
|
||||
action: 'run-now',
|
||||
status: 'failed',
|
||||
message: 'System job not found.',
|
||||
actorUserId
|
||||
});
|
||||
return { ok: false, action, error: 'System job not found.' };
|
||||
}
|
||||
if (backgroundRuns.get(targetId)?.running) {
|
||||
const action = recordSystemJobAction({
|
||||
targetType: 'system',
|
||||
targetId,
|
||||
action: 'run-now',
|
||||
status: 'skipped',
|
||||
message: `${definition.name} is already running.`,
|
||||
actorUserId
|
||||
});
|
||||
return { ok: false, action, error: `${definition.name} is already running.` };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
backgroundRuns.set(targetId, {
|
||||
running: true,
|
||||
cancelRequested: false,
|
||||
startedAt: now(),
|
||||
lastError: '',
|
||||
lastSummary: null,
|
||||
controller
|
||||
});
|
||||
const startAction = recordSystemJobAction({
|
||||
targetType: 'system',
|
||||
targetId,
|
||||
action: 'run-now',
|
||||
status: 'started',
|
||||
message: `${definition.name} started by operator.`,
|
||||
actorUserId
|
||||
});
|
||||
try {
|
||||
const summary = await definition.run({ signal: controller.signal });
|
||||
const canceled = Boolean(backgroundRuns.get(targetId)?.cancelRequested || controller.signal.aborted);
|
||||
backgroundRuns.set(targetId, {
|
||||
running: false,
|
||||
cancelRequested: false,
|
||||
startedAt: '',
|
||||
lastRunAt: now(),
|
||||
lastError: canceled ? 'Canceled by operator.' : '',
|
||||
lastSummary: summary
|
||||
});
|
||||
const action = recordSystemJobAction({
|
||||
targetType: 'system',
|
||||
targetId,
|
||||
action: 'run-now',
|
||||
status: canceled ? 'canceled' : 'success',
|
||||
message: canceled ? `${definition.name} canceled by operator.` : `${definition.name} completed.`,
|
||||
actorUserId,
|
||||
metadata: { summary, startActionId: startAction.id }
|
||||
});
|
||||
return { ok: !canceled, action, summary, canceled };
|
||||
} catch (error) {
|
||||
const canceled = Boolean(backgroundRuns.get(targetId)?.cancelRequested || controller.signal.aborted);
|
||||
backgroundRuns.set(targetId, {
|
||||
running: false,
|
||||
cancelRequested: false,
|
||||
startedAt: '',
|
||||
lastRunAt: now(),
|
||||
lastError: canceled ? 'Canceled by operator.' : error.message,
|
||||
lastSummary: null
|
||||
});
|
||||
const action = recordSystemJobAction({
|
||||
targetType: 'system',
|
||||
targetId,
|
||||
action: 'run-now',
|
||||
status: canceled ? 'canceled' : 'failed',
|
||||
message: canceled ? `${definition.name} canceled by operator.` : error.message,
|
||||
actorUserId,
|
||||
metadata: { startActionId: startAction.id }
|
||||
});
|
||||
return { ok: false, action, error: canceled ? 'Canceled by operator.' : error.message, canceled };
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelSystemJob(targetId, actorUserId, reason = '') {
|
||||
if (targetId.startsWith('worker:')) {
|
||||
const definition = BACKGROUND_JOBS.find((job) => job.id === targetId);
|
||||
const state = backgroundRuns.get(targetId);
|
||||
if (!definition || !state?.running) {
|
||||
const action = recordSystemJobAction({
|
||||
targetType: 'system',
|
||||
targetId,
|
||||
action: 'cancel',
|
||||
status: 'failed',
|
||||
message: definition ? `${definition.name} is not running.` : 'System job not found.',
|
||||
actorUserId,
|
||||
metadata: { reason }
|
||||
});
|
||||
return { ok: false, action, error: action.message };
|
||||
}
|
||||
state.cancelRequested = true;
|
||||
state.controller?.abort?.(new Error(reason || 'Canceled by operator.'));
|
||||
backgroundRuns.set(targetId, state);
|
||||
const action = recordSystemJobAction({
|
||||
targetType: 'system',
|
||||
targetId,
|
||||
action: 'cancel',
|
||||
status: 'success',
|
||||
message: `Cancel requested for ${definition.name}.`,
|
||||
actorUserId,
|
||||
metadata: { reason }
|
||||
});
|
||||
return { ok: true, action, result: { canceled: true, targetType: 'background' } };
|
||||
}
|
||||
|
||||
if (!targetId.startsWith('job_')) {
|
||||
const action = recordSystemJobAction({
|
||||
targetType: 'system',
|
||||
targetId,
|
||||
action: 'cancel',
|
||||
status: 'failed',
|
||||
message: 'Only running script execution jobs can be canceled.',
|
||||
actorUserId,
|
||||
metadata: { reason }
|
||||
});
|
||||
return { ok: false, action, error: 'Only running script execution jobs can be canceled.' };
|
||||
}
|
||||
const result = cancelExecutionJob(targetId, { actorUserId, reason });
|
||||
const status = result.canceled ? 'success' : 'failed';
|
||||
const message = result.canceled
|
||||
? `Cancel requested; signaled ${result.signaledProcesses || 0} running process(es).`
|
||||
: result.reason;
|
||||
const action = recordSystemJobAction({
|
||||
jobId: targetId,
|
||||
targetType: 'execution',
|
||||
targetId,
|
||||
action: 'cancel',
|
||||
status,
|
||||
message,
|
||||
actorUserId,
|
||||
metadata: { reason, result }
|
||||
});
|
||||
if (result.canceled) appendJobLog(targetId, 'system', `Job management action logged: cancel (${action.id}).`, null);
|
||||
return { ok: result.canceled, action, result, error: result.canceled ? null : result.reason };
|
||||
}
|
||||
|
||||
function executionJobs() {
|
||||
const rows = db.prepare(`
|
||||
SELECT j.*, r.name AS runplan_name, s.name AS script_name,
|
||||
COUNT(jh.id) AS host_count,
|
||||
SUM(CASE WHEN jh.status = 'running' THEN 1 ELSE 0 END) AS running_hosts,
|
||||
SUM(CASE WHEN jh.status = 'queued' THEN 1 ELSE 0 END) AS queued_hosts
|
||||
FROM jobs j
|
||||
LEFT JOIN runplans r ON r.id = j.runplan_id
|
||||
LEFT JOIN scripts s ON s.id = j.script_id
|
||||
LEFT JOIN job_hosts jh ON jh.job_id = j.id
|
||||
GROUP BY j.id
|
||||
ORDER BY j.created_at DESC
|
||||
LIMIT 200
|
||||
`).all();
|
||||
return rows.map((row) => {
|
||||
const job = camelJob(row);
|
||||
const running = ['queued', 'running'].includes(job.status);
|
||||
return {
|
||||
id: job.id,
|
||||
type: 'execution',
|
||||
category: 'Script Execution',
|
||||
name: job.runplanName || job.scriptName || job.id,
|
||||
description: job.runplanName ? `RunPlan ${job.runplanName}` : `Script test ${job.scriptName || job.id}`,
|
||||
status: job.status,
|
||||
canCancel: running,
|
||||
canRunNow: false,
|
||||
startedAt: job.startedAt || '',
|
||||
endedAt: job.endedAt || '',
|
||||
createdAt: job.createdAt,
|
||||
hostCount: row.host_count || 0,
|
||||
runningHosts: row.running_hosts || 0,
|
||||
queuedHosts: row.queued_hosts || 0,
|
||||
metadata: {
|
||||
runplanId: job.runplanId,
|
||||
scriptId: job.scriptId,
|
||||
triggeredBy: job.triggeredBy
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function backgroundJobs() {
|
||||
return BACKGROUND_JOBS.map((job) => {
|
||||
const state = backgroundRuns.get(job.id) || {};
|
||||
return {
|
||||
id: job.id,
|
||||
type: 'background',
|
||||
category: job.category,
|
||||
name: job.name,
|
||||
description: job.description,
|
||||
status: state.cancelRequested ? 'canceling' : state.running ? 'running' : 'idle',
|
||||
canCancel: Boolean(state.running),
|
||||
canRunNow: !state.running,
|
||||
startedAt: state.startedAt || '',
|
||||
endedAt: '',
|
||||
createdAt: '',
|
||||
hostCount: 0,
|
||||
runningHosts: 0,
|
||||
queuedHosts: 0,
|
||||
metadata: {
|
||||
lastRunAt: state.lastRunAt || '',
|
||||
lastError: state.lastError || '',
|
||||
lastSummary: state.lastSummary || null
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/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];
|
||||
|
||||
Reference in New Issue
Block a user