Initial
This commit is contained in:
18
server/controllers/systemJobController.js
Normal file
18
server/controllers/systemJobController.js
Normal 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);
|
||||
}
|
||||
|
||||
@@ -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.', [
|
||||
|
||||
14
server/db.js
14
server/db.js
@@ -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');
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
55
server/models/systemJobModel.js
Normal file
55
server/models/systemJobModel.js
Normal 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
11
server/routes/systemJobRoutes.js
Normal file
11
server/routes/systemJobRoutes.js
Normal 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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user