This commit is contained in:
2026-06-25 18:02:16 -05:00
parent 402bf6782a
commit da16e5ff56
17 changed files with 1475 additions and 69 deletions

View File

@@ -0,0 +1,18 @@
import { cancelSystemJob, listSystemJobs, runSystemJob } from '../services/systemJobService.js';
export function index(req, res) {
res.json(listSystemJobs());
}
export async function runNow(req, res) {
const result = await runSystemJob(req.params.jobId, req.user.id);
if (!result.ok) return res.status(409).json(result);
res.json(result);
}
export function cancel(req, res) {
const result = cancelSystemJob(req.params.jobId, req.user.id, req.body?.reason || '');
if (!result.ok) return res.status(409).json(result);
res.json(result);
}

View File

@@ -68,13 +68,18 @@ const operationsArticles = [
]),
section('Host Groups and VMware sources', [
'Host Groups are target collections. The table supports search, sort, pagination, a read-only summary view, and CSV/XLS exports of assigned hostnames, IP addresses, and VMware source system.',
'Config / VMware supports two connection types: vCenter inventory systems and standalone ESXi hosts. vCenter systems can import VM inventory and run pre-execution power-state checks.',
'Standalone ESXi host connections use the vSphere Web Services API for direct host validation. They are not vCenter inventory sources, so they cannot be selected in the VM import wizard.'
'Config / VMware supports two connection types: vCenter inventory systems and standalone ESXi hosts. Both can import VM inventory and run pre-execution power-state checks.',
'Standalone ESXi host connections use the vSphere Web Services API at /sdk. In the import wizard, selecting one enumerates VirtualMachine managed objects directly from that ESXi host with RetrieveServiceContent, Login, CreateContainerView, and RetrievePropertiesEx.'
]),
section('RunPlans', [
'A RunPlan joins one script with one or more hosts. The backend creates a job and host-level log rows during execution.',
'RunPlans can be personal, shared, or group-scoped. Use the execute action to queue the plan.'
]),
section('System Jobs', [
'Admins can open System Jobs to see queued/running script executions, controllable background workers, and recent management actions in one place.',
'Use Run now for supported background workers such as dynamic Host Group sync, application catalog auto-check, and Intune auto-promotion. Use Cancel on stuck execution jobs to mark queued/running hosts canceled and signal active PowerShell child processes.',
'Every run-now and cancel action is written to the system job action audit trail with actor, target, status, message, and timestamp.'
]),
section('Logs', [
'Job logs are aggregated by job and host. System Logs show Winston request/application logs for API request and response troubleshooting.'
])
@@ -129,11 +134,14 @@ const apiArticles = [
apiExample('Post', '/api/runplans/rp_123/execute', 'Execute a RunPlan and create a job. Replace rp_123 with the RunPlan id.'),
apiExample('Get', '/api/jobs', 'List job history.'),
apiExample('Get', '/api/jobs/job_123', 'Read one job with host rows and logs.'),
apiExample('Get', '/api/system-jobs', 'Admin-only control plane for execution jobs, background workers, and recent management audit actions.'),
apiExample('Post', '/api/system-jobs/worker%3Ahost-group-sync/run', 'Run a supported background worker immediately. Other worker ids include worker:catalog-auto-check and worker:intune-auto-promote.'),
apiExample('Post', '/api/system-jobs/job_123/cancel', 'Cancel a queued or running execution job and audit the reason.', '@{ reason = "Operator canceled stuck remoting session" }'),
apiExample('Get', '/api/logs/system', 'Read system/request logs from the Winston log stream.')
,
section('VMware connection payloads', [
'Saved VMware connection records use targetType = vcenter for inventory import/power-state checks or targetType = host for a standalone ESXi host connection.',
'Standalone host connections force apiMode = web-services and test with the vSphere Web Services SOAP endpoint at /sdk. Inventory preview/import requires a vCenter connection.'
'Standalone host connections force apiMode = web-services and test with the vSphere Web Services SOAP endpoint at /sdk. Import preview enumerates VM inventory from the selected ESXi host, applies the same filter fields as vCenter imports, and stores the VM managed-object reference for later power-state checks.'
], '@{\n name = "Production vCenter"\n targetType = "vcenter"\n baseUrl = "https://vcenter.contoso.local"\n username = "administrator@vsphere.local"\n password = "secret"\n apiMode = "auto"\n requestTimeoutMs = 20000\n allowUntrustedTls = $false\n enabled = $true\n visibility = "shared"\n}\n\n@{\n name = "Standalone ESXi 01"\n targetType = "host"\n baseUrl = "https://esxi01.contoso.local"\n username = "root"\n password = "secret"\n apiMode = "web-services"\n requestTimeoutMs = 20000\n allowUntrustedTls = $true\n enabled = $true\n visibility = "shared"\n}')
], ['api', 'credentials', 'hosts', 'runplans', 'jobs']),
article('api-psadt', 'API', 'PSADT And Intune API', 'Use the PSADT catalog, verifier, migration wizard, profiles, and Intune publishing plans through API calls.', [

View File

@@ -471,6 +471,19 @@ export function migrate() {
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS system_job_actions (
id TEXT PRIMARY KEY,
job_id TEXT,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
action TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
@@ -515,6 +528,7 @@ export function migrate() {
// VMware connection type separates vCenter inventory/power-state APIs from
// standalone ESXi host checks that use the vSphere Web Services SOAP API.
ensureColumn('vcenter_connections', 'target_type', "TEXT NOT NULL DEFAULT 'vcenter'");
ensureColumn('system_job_actions', 'metadata_json', "TEXT NOT NULL DEFAULT '{}'");
// Tenant write-back is opt-in per Graph connection. Default 0 so existing
// read-only connections can never be used to change a tenant by accident.
ensureColumn('graph_connections', 'allow_write', 'INTEGER NOT NULL DEFAULT 0');

View File

@@ -399,3 +399,19 @@ export function camelJobLog(row) {
createdAt: row.created_at
};
}
export function camelSystemJobAction(row) {
return {
id: row.id,
jobId: row.job_id || null,
targetType: row.target_type,
targetId: row.target_id,
action: row.action,
status: row.status,
message: row.message || '',
actorUserId: row.actor_user_id || null,
actorName: row.actor_name || null,
metadata: parseJson(row.metadata_json, {}),
createdAt: row.created_at
};
}

View File

@@ -0,0 +1,55 @@
import { db, id, json, now } from '../db.js';
import { camelSystemJobAction } from './mappers.js';
export function recordSystemJobAction({
jobId = null,
targetType,
targetId,
action,
status,
message = '',
actorUserId = null,
metadata = {}
}) {
const actionId = id('sja');
db.prepare(`
INSERT INTO system_job_actions (
id, job_id, target_type, target_id, action, status, message,
actor_user_id, metadata_json, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
actionId,
jobId,
targetType,
targetId,
action,
status,
message,
actorUserId,
json(metadata),
now()
);
return getSystemJobAction(actionId);
}
export function getSystemJobAction(actionId) {
const row = db.prepare(`
SELECT a.*, u.display_name AS actor_name
FROM system_job_actions a
LEFT JOIN users u ON u.id = a.actor_user_id
WHERE a.id = ?
`).get(actionId);
return row ? camelSystemJobAction(row) : null;
}
export function listSystemJobActions(limit = 200) {
return db.prepare(`
SELECT a.*, u.display_name AS actor_name
FROM system_job_actions a
LEFT JOIN users u ON u.id = a.actor_user_id
ORDER BY a.created_at DESC
LIMIT ?
`).all(Math.max(1, Math.min(Number(limit) || 200, 500))).map(camelSystemJobAction);
}

View File

@@ -16,6 +16,7 @@ import { packagingRoutes } from './packagingRoutes.js';
import { psadtRoutes } from './psadtRoutes.js';
import { runPlanRoutes } from './runPlanRoutes.js';
import { settingsRoutes } from './settingsRoutes.js';
import { systemJobRoutes } from './systemJobRoutes.js';
import { userRoutes } from './userRoutes.js';
import { variableRoutes } from './variableRoutes.js';
@@ -41,4 +42,5 @@ apiRoutes.use('/intune/applications', applicationRoutes);
apiRoutes.use('/intune', governanceRoutes);
apiRoutes.use('/runplans', runPlanRoutes);
apiRoutes.use('/jobs', jobRoutes);
apiRoutes.use('/system-jobs', systemJobRoutes);
apiRoutes.use('/logs', logRoutes);

View File

@@ -0,0 +1,11 @@
import { Router } from 'express';
import { cancel, index, runNow } from '../controllers/systemJobController.js';
import { requireAdmin, requireAuth } from '../middleware/auth.js';
export const systemJobRoutes = Router();
// Control-plane job management is admin-only and every action is audited.
systemJobRoutes.get('/', requireAuth, requireAdmin, index);
systemJobRoutes.post('/:jobId/run', requireAuth, requireAdmin, runNow);
systemJobRoutes.post('/:jobId/cancel', requireAuth, requireAdmin, cancel);

View File

@@ -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 [

View 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
}
};
});
}

View File

@@ -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(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/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];

View File

@@ -3,6 +3,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
const { listJobs, getJob } = await load('../models/jobModel.js');
const { cancelSystemJob, listSystemJobs, runSystemJob } = await load('../services/systemJobService.js');
const owner = makeUser({ email: 'job-owner@test.local' });
const stranger = makeUser({ email: 'job-stranger@test.local' });
@@ -23,6 +24,11 @@ db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, c
const scriptTestJobId = 'job_script_test_1';
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, created_at)
VALUES (?, NULL, ?, 'failed', ?, ?)`).run(scriptTestJobId, scriptId, owner, ts);
const runningJobId = 'job_cancel_test_1';
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
VALUES (?, ?, ?, 'running', ?, ?, ?)`).run(runningJobId, runplanId, scriptId, owner, ts, ts);
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
VALUES ('jhost_cancel_test_1', ?, NULL, 'queued', NULL)`).run(runningJobId);
test('the triggering operator can list and read their job', () => {
assert.ok(listJobs(owner, false).some((j) => j.id === jobId));
@@ -45,3 +51,36 @@ test('admins can see any job', () => {
assert.ok(listJobs(admin, true).some((j) => j.id === jobId));
assert.ok(getJob(jobId, admin, true));
});
test('system jobs list execution and background jobs', () => {
const listJobId = 'job_list_system_test_1';
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
VALUES (?, ?, ?, 'running', ?, ?, ?)`).run(listJobId, runplanId, scriptId, owner, ts, ts);
const payload = listSystemJobs();
assert.ok(payload.jobs.some((job) => job.id === listJobId && job.canCancel));
assert.ok(payload.jobs.some((job) => job.id === 'worker:host-group-sync' && job.canRunNow));
});
test('cancelSystemJob marks running jobs canceled and audits the action', () => {
const result = cancelSystemJob(runningJobId, admin, 'test cancellation');
assert.equal(result.ok, true);
assert.equal(db.prepare('SELECT status FROM jobs WHERE id = ?').get(runningJobId).status, 'canceled');
assert.equal(db.prepare('SELECT status FROM job_hosts WHERE job_id = ?').get(runningJobId).status, 'canceled');
const action = db.prepare('SELECT * FROM system_job_actions WHERE target_id = ? AND action = ?').get(runningJobId, 'cancel');
assert.equal(action.status, 'success');
});
test('cancelSystemJob audits idle background worker cancellation attempts', () => {
const result = cancelSystemJob('worker:host-group-sync', admin, 'not running');
assert.equal(result.ok, false);
assert.match(result.error, /not running/i);
const action = db.prepare('SELECT * FROM system_job_actions WHERE target_id = ? AND action = ? ORDER BY created_at DESC').get('worker:host-group-sync', 'cancel');
assert.equal(action.status, 'failed');
});
test('runSystemJob executes host group sync and audits run-now', async () => {
const result = await runSystemJob('worker:host-group-sync', admin);
assert.equal(result.ok, true);
const action = db.prepare('SELECT * FROM system_job_actions WHERE target_id = ? AND action = ? ORDER BY created_at DESC').get('worker:host-group-sync', 'run-now');
assert.ok(action);
});

View File

@@ -6,7 +6,9 @@ import { load } from './setup.mjs';
const {
buildVmListPath,
buildHostPayloadFromVm,
buildStandaloneHostCandidate,
filterSummaryVms,
getVCenterVmPowerState,
normalizeBaseUrl,
normalizeVCenterVm,
previewVCenterHosts,
@@ -14,29 +16,124 @@ const {
vmMatchesFilter
} = await load('../services/vcenterService.js');
const originalFetch = globalThis.fetch;
function soapResponse(body) {
return new Response(`<?xml version="1.0" encoding="UTF-8"?><soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"><soapenv:Body>${body}</soapenv:Body></soapenv:Envelope>`, {
status: 200,
headers: { 'content-type': 'text/xml' }
});
}
function installStandaloneEsxiSoapStub() {
globalThis.fetch = async (_url, options = {}) => {
const body = String(options.body || '');
if (body.includes('<RetrieveServiceContent')) {
return soapResponse(`<RetrieveServiceContentResponse xmlns="urn:vim25"><returnval><rootFolder type="Folder">ha-folder-root</rootFolder><propertyCollector type="PropertyCollector">ha-property-collector</propertyCollector><viewManager type="ViewManager">ViewManager</viewManager><sessionManager type="SessionManager">ha-sessionmgr</sessionManager></returnval></RetrieveServiceContentResponse>`);
}
if (body.includes('<Login')) {
return soapResponse(`<LoginResponse xmlns="urn:vim25"><returnval type="UserSession">session-1</returnval></LoginResponse>`);
}
if (body.includes('<CreateContainerView')) {
return soapResponse(`<CreateContainerViewResponse xmlns="urn:vim25"><returnval type="ContainerView">session[52b6]-vm-view</returnval></CreateContainerViewResponse>`);
}
if (body.includes('<RetrievePropertiesEx')) {
return soapResponse(`<RetrievePropertiesExResponse xmlns="urn:vim25"><returnval><objects><obj type="VirtualMachine">vim.VirtualMachine:101</obj><propSet><name>name</name><val>ENG-ENT-APP01</val></propSet><propSet><name>guest.hostName</name><val>eng-ent-app01.contoso.local</val></propSet><propSet><name>guest.ipAddress</name><val>10.42.8.21</val></propSet><propSet><name>guest.guestFullName</name><val>Microsoft Windows Server 2022</val></propSet><propSet><name>runtime.powerState</name><val>poweredOn</val></propSet></objects></returnval></RetrievePropertiesExResponse>`);
}
return soapResponse('');
};
return () => { globalThis.fetch = originalFetch; };
}
test('normalizeBaseUrl trims trailing slashes', () => {
assert.equal(normalizeBaseUrl('https://vcenter.lab.local///'), 'https://vcenter.lab.local');
});
test('standalone VMware host connections use the Web Services profile', async () => {
const settings = settingsFromVCenterConnection({
const restoreFetch = installStandaloneEsxiSoapStub();
const connection = {
id: 'vc_host',
name: 'ESXi 01',
target_type: 'host',
base_url: 'https://esxi01.lab.local/sdk',
username: 'root',
password: 'secret',
enabled: 1,
api_mode: 'api'
});
};
const settings = settingsFromVCenterConnection(connection);
assert.equal(settings.targetType, 'host');
assert.equal(settings.apiMode, 'web-services');
assert.equal(settings.baseUrl, 'https://esxi01.lab.local/sdk');
await assert.rejects(
() => previewVCenterHosts({ connection: { ...settings, name: 'ESXi host' } }),
/Standalone VMware host connections/
const preview = await previewVCenterHosts({ connection, filter: { field: 'hostname', operator: 'contains', value: 'ENG-ENT' } });
restoreFetch();
assert.equal(preview.count, 1);
assert.equal(preview.diagnostics.standaloneHost, true);
assert.equal(preview.candidates[0].source, 'standalone-vm');
assert.equal(preview.candidates[0].fqdn, 'eng-ent-app01.contoso.local');
assert.equal(preview.candidates[0].ipAddress, '10.42.8.21');
assert.equal(preview.candidates[0].powerState, 'POWERED_ON');
});
test('standalone VMware VM candidates build managed host payloads', async () => {
const restoreFetch = installStandaloneEsxiSoapStub();
const connection = {
id: 'vc_host',
name: 'ESXi 01',
target_type: 'host',
base_url: 'https://esxi01.lab.local',
username: 'root',
password: 'secret',
enabled: 1
};
const preview = await previewVCenterHosts({ connection, filter: { field: 'hostname', operator: 'contains', value: 'eng-ent' } });
const payload = buildHostPayloadFromVm(preview.candidates[0], {
connectionId: 'vc_host',
credentialId: 'cred_1',
transport: 'winrm',
tags: ['lab'],
visibility: 'shared'
});
const power = await getVCenterVmPowerState(connection, preview.candidates[0].id);
restoreFetch();
assert.equal(payload.name, 'ENG-ENT-APP01');
assert.equal(payload.address, 'eng-ent-app01.contoso.local');
assert.equal(payload.transport, 'winrm');
assert.equal(payload.sourceType, 'vmware');
assert.equal(payload.sourceConnectionId, 'vc_host');
assert.equal(payload.sourceRef, 'vim.VirtualMachine:101');
assert.ok(payload.tags.includes('standalone-esxi'));
assert.ok(payload.tags.includes('esxi-vm:vim.VirtualMachine:101'));
assert.equal(power.checked, true);
assert.equal(power.state, 'POWERED_ON');
});
test('standalone VMware host candidates build managed host payloads', () => {
const candidate = buildStandaloneHostCandidate(
{ id: 'vc_host', name: 'ESXi 01' },
{ baseUrl: 'https://esxi01.lab.local', targetType: 'host' }
);
const payload = buildHostPayloadFromVm(candidate, {
connectionId: 'vc_host',
credentialId: 'cred_1',
transport: 'ssh',
tags: ['lab'],
visibility: 'shared'
});
assert.equal(payload.name, 'ESXi 01');
assert.equal(payload.address, 'esxi01.lab.local');
assert.equal(payload.transport, 'ssh');
assert.equal(payload.credentialId, 'cred_1');
assert.equal(payload.sourceType, 'vmware');
assert.equal(payload.sourceConnectionId, 'vc_host');
assert.equal(payload.sourceRef, 'standalone-esxi-host');
assert.ok(payload.tags.includes('standalone-esxi'));
assert.ok(payload.tags.includes('lab'));
});
test('normalizeVCenterVm maps guest identity and networking into a host candidate', () => {