Initial
This commit is contained in:
41
server/services/hostGroupWorker.js
Normal file
41
server/services/hostGroupWorker.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getStringSetting } from '../models/settingsModel.js';
|
||||
import { syncDynamicHostGroups } from '../models/hostGroupModel.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
let timer = null;
|
||||
let running = false;
|
||||
|
||||
export function hostGroupSyncIntervalMinutes() {
|
||||
const minutes = Number(getStringSetting('host_group_sync_interval_minutes', process.env.HOST_GROUP_SYNC_INTERVAL_MINUTES || '60'));
|
||||
return Number.isFinite(minutes) ? minutes : 60;
|
||||
}
|
||||
|
||||
export function startHostGroupWorker() {
|
||||
const minutes = hostGroupSyncIntervalMinutes();
|
||||
if (minutes <= 0) return null;
|
||||
running = true;
|
||||
scheduleNext(minutes);
|
||||
logger.info(`host group sync scheduled every ${minutes} minute(s)`);
|
||||
return timer;
|
||||
}
|
||||
|
||||
export function stopHostGroupWorker() {
|
||||
running = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function scheduleNext(minutes) {
|
||||
if (!running || minutes <= 0) return;
|
||||
timer = setTimeout(() => {
|
||||
try {
|
||||
const summary = syncDynamicHostGroups();
|
||||
logger.info('host group sync complete', summary);
|
||||
} catch (error) {
|
||||
logger.error('host group sync failed', { error: error.message });
|
||||
} finally {
|
||||
scheduleNext(hostGroupSyncIntervalMinutes());
|
||||
}
|
||||
}, minutes * 60 * 1000);
|
||||
timer.unref?.();
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { logger } from './logger.js';
|
||||
import { listExecutableAssets } from '../models/assetModel.js';
|
||||
import { listExecutableCustomVariables } from '../models/variableModel.js';
|
||||
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
|
||||
import { getVCenterConnectionSystem } from '../models/vcenterConnectionModel.js';
|
||||
import { getVCenterVmPowerState } from './vcenterService.js';
|
||||
import { path } from '../utils/pathUtils.js';
|
||||
|
||||
// The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and
|
||||
@@ -45,12 +47,18 @@ export function executeRunPlan(runplanId, userId) {
|
||||
const hosts = db.prepare(`
|
||||
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
|
||||
c.secret_cipher, c.secret_iv, c.secret_tag
|
||||
FROM runplan_hosts rh
|
||||
JOIN hosts h ON h.id = rh.host_id
|
||||
FROM hosts h
|
||||
LEFT JOIN credentials c ON c.id = h.credential_id
|
||||
WHERE rh.runplan_id = ?
|
||||
WHERE h.id IN (
|
||||
SELECT host_id FROM runplan_hosts WHERE runplan_id = ?
|
||||
UNION
|
||||
SELECT hgm.host_id
|
||||
FROM runplan_host_groups rhg
|
||||
JOIN host_group_members hgm ON hgm.host_group_id = rhg.host_group_id
|
||||
WHERE rhg.runplan_id = ?
|
||||
)
|
||||
ORDER BY h.name
|
||||
`).all(runplanId);
|
||||
`).all(runplanId, runplanId);
|
||||
if (hosts.length === 0) throw new Error('RunPlan has no hosts');
|
||||
|
||||
const jobId = id('job');
|
||||
@@ -95,7 +103,31 @@ export function executeScriptTest(scriptId, payload, userId) {
|
||||
`).get(scriptId, userId, userId);
|
||||
if (!script) throw new Error('Script not found');
|
||||
|
||||
const host = db.prepare(`
|
||||
const hosts = payload.hostGroupId ? db.prepare(`
|
||||
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
|
||||
c.secret_cipher, c.secret_iv, c.secret_tag
|
||||
FROM host_group_members hgm
|
||||
JOIN host_groups hg ON hg.id = hgm.host_group_id
|
||||
AND (
|
||||
hg.visibility = 'shared'
|
||||
OR hg.owner_user_id = ?
|
||||
OR hg.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
)
|
||||
JOIN hosts h ON h.id = hgm.host_id
|
||||
AND (
|
||||
h.visibility = 'shared'
|
||||
OR h.owner_user_id = ?
|
||||
OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
)
|
||||
JOIN credentials c ON c.id = ?
|
||||
AND (
|
||||
c.visibility = 'shared'
|
||||
OR c.owner_user_id = ?
|
||||
OR c.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
)
|
||||
WHERE hgm.host_group_id = ?
|
||||
ORDER BY h.name
|
||||
`).all(userId, userId, userId, userId, payload.credentialId, userId, userId, payload.hostGroupId) : [db.prepare(`
|
||||
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
|
||||
c.secret_cipher, c.secret_iv, c.secret_tag
|
||||
FROM hosts h
|
||||
@@ -110,8 +142,8 @@ export function executeScriptTest(scriptId, payload, userId) {
|
||||
OR h.owner_user_id = ?
|
||||
OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
)
|
||||
`).get(payload.credentialId, userId, userId, payload.hostId, userId, userId);
|
||||
if (!host) throw new Error('Visible host and credential are required');
|
||||
`).get(payload.credentialId, userId, userId, payload.hostId, userId, userId)].filter(Boolean);
|
||||
if (!hosts.length) throw new Error('Visible host or host group members and credential are required');
|
||||
|
||||
const jobId = id('job');
|
||||
db.prepare(`
|
||||
@@ -121,7 +153,8 @@ export function executeScriptTest(scriptId, payload, userId) {
|
||||
db.prepare(`
|
||||
INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
|
||||
VALUES (?, ?, ?, 'queued', NULL)
|
||||
`).run(id('jhost'), jobId, host.id);
|
||||
`);
|
||||
for (const host of hosts) insertJobHost.run(id('jhost'), jobId, host.id);
|
||||
|
||||
const executionContext = {
|
||||
jobId,
|
||||
@@ -134,10 +167,10 @@ export function executeScriptTest(scriptId, payload, userId) {
|
||||
assets: listExecutableAssets(userId)
|
||||
};
|
||||
|
||||
appendLog(jobId, host.id, 'system', `Test run requested for ${script.name} using credential ${host.credential_name}`);
|
||||
void runJob(jobId, script, [host], false, executionContext).catch((error) => {
|
||||
for (const host of hosts) appendLog(jobId, host.id, 'system', `Test run requested for ${script.name} using credential ${host.credential_name}`);
|
||||
void runJob(jobId, script, hosts, false, executionContext).catch((error) => {
|
||||
logger.error('script test runner crashed', { jobId, error });
|
||||
appendLog(jobId, host.id, 'stderr', `Script test runner crashed: ${error.message}`);
|
||||
for (const host of hosts) appendLog(jobId, host.id, 'stderr', `Script test runner crashed: ${error.message}`);
|
||||
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId);
|
||||
});
|
||||
|
||||
@@ -168,6 +201,8 @@ async function runHost(jobId, script, host, executionContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await vCenterPowerPreflight(jobId, host))) return;
|
||||
|
||||
const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`);
|
||||
|
||||
try {
|
||||
@@ -183,6 +218,35 @@ async function runHost(jobId, script, host, executionContext) {
|
||||
}
|
||||
}
|
||||
|
||||
async function vCenterPowerPreflight(jobId, host) {
|
||||
if ((host.source_type || 'manual') !== 'vmware') return true;
|
||||
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;
|
||||
}
|
||||
const connection = getVCenterConnectionSystem(host.source_connection_id, true);
|
||||
if (!connection) {
|
||||
appendLog(jobId, host.id, 'system', `Host is VMware-sourced, but vCenter connection ${host.source_connection_id} no longer exists. Continuing without a power-state gate.`);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const result = await getVCenterVmPowerState(connection, host.source_ref);
|
||||
if (!result.checked) {
|
||||
appendLog(jobId, host.id, 'system', `vCenter power-state check skipped: ${result.reason || 'not enough source metadata'}`);
|
||||
return true;
|
||||
}
|
||||
appendLog(jobId, host.id, 'system', `vCenter power state for VM ${host.source_ref}: ${result.state || 'unknown'} (${result.apiProfile || 'vCenter API'})`);
|
||||
if (result.state && result.state !== 'POWERED_ON') {
|
||||
appendLog(jobId, host.id, 'system', `Skipping ${host.name}; VMware VM is not powered on.`);
|
||||
finishHost(jobId, host.id, 'skipped', 0);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
appendLog(jobId, host.id, 'system', `Unable to verify vCenter power state before execution: ${err.message}. Continuing without a power-state gate.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildWrapper(content, host, executionContext = {}) {
|
||||
// Build PowerShell wrappers without shell interpolation; host values are quoted as PS literals.
|
||||
const runtimeVariables = runtimeVariableValues(host, executionContext);
|
||||
|
||||
@@ -15,6 +15,7 @@ const API_PROFILES = {
|
||||
sessionPath: '/api/session',
|
||||
vmListPath: '/api/vcenter/vm',
|
||||
vmNameParam: 'names',
|
||||
vmDetailPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}`,
|
||||
guestIdentityPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`,
|
||||
guestNetworkingPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`
|
||||
},
|
||||
@@ -24,6 +25,7 @@ const API_PROFILES = {
|
||||
sessionPath: '/rest/com/vmware/cis/session',
|
||||
vmListPath: '/rest/vcenter/vm',
|
||||
vmNameParam: 'filter.names',
|
||||
vmDetailPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}`,
|
||||
guestIdentityPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`,
|
||||
guestNetworkingPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`
|
||||
}
|
||||
@@ -52,6 +54,20 @@ export function getVCenterSettings() {
|
||||
};
|
||||
}
|
||||
|
||||
export function settingsFromVCenterConnection(connection) {
|
||||
if (!connection) return getVCenterSettings();
|
||||
const apiMode = String(connection.api_mode || connection.apiMode || 'auto').toLowerCase();
|
||||
return {
|
||||
enabled: Boolean(connection.enabled ?? true),
|
||||
baseUrl: normalizeBaseUrl(connection.base_url || connection.baseUrl),
|
||||
username: connection.username || '',
|
||||
password: connection.password || '',
|
||||
apiMode: ['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto',
|
||||
requestTimeoutMs: Number(connection.request_timeout_ms || connection.requestTimeoutMs || 20000),
|
||||
allowUntrustedTls: Boolean(connection.allow_untrusted_tls ?? connection.allowUntrustedTls)
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
@@ -69,6 +85,21 @@ export function vcenterStatus() {
|
||||
};
|
||||
}
|
||||
|
||||
export function vcenterConnectionStatus(connection) {
|
||||
const settings = settingsFromVCenterConnection(connection);
|
||||
return {
|
||||
id: connection?.id || '',
|
||||
name: connection?.name || 'Configured default',
|
||||
enabled: settings.enabled,
|
||||
configured: Boolean(settings.baseUrl && settings.username && settings.password),
|
||||
baseUrl: settings.baseUrl,
|
||||
username: settings.username,
|
||||
apiMode: settings.apiMode,
|
||||
requestTimeoutMs: settings.requestTimeoutMs,
|
||||
allowUntrustedTls: settings.allowUntrustedTls
|
||||
};
|
||||
}
|
||||
|
||||
function assertConfigured(settings) {
|
||||
if (!settings.enabled) throw new Error('VMware vCenter integration is disabled in Configuration.');
|
||||
if (!settings.baseUrl) throw new Error('VMware vCenter base URL is missing. Set vcenter_base_url in Configuration.');
|
||||
@@ -361,13 +392,16 @@ export function buildHostPayloadFromVm(candidate, options = {}) {
|
||||
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
|
||||
groupId: options.visibility === 'group' ? options.groupId || null : null,
|
||||
sourceType: 'vmware',
|
||||
sourceConnectionId: options.connectionId || null,
|
||||
sourceRef: candidate.id || ''
|
||||
};
|
||||
}
|
||||
|
||||
export async function previewVCenterHosts(options = {}) {
|
||||
const trace = [];
|
||||
const settings = getVCenterSettings();
|
||||
const settings = settingsFromVCenterConnection(options.connection);
|
||||
try {
|
||||
assertConfigured(settings);
|
||||
} catch (err) {
|
||||
@@ -405,7 +439,7 @@ export async function previewVCenterHosts(options = {}) {
|
||||
});
|
||||
|
||||
return {
|
||||
status: vcenterStatus(),
|
||||
status: options.connection ? vcenterConnectionStatus(options.connection) : vcenterStatus(),
|
||||
filter: options.filter || {},
|
||||
count: candidates.length,
|
||||
diagnostics,
|
||||
@@ -414,6 +448,50 @@ export async function previewVCenterHosts(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function testVCenterConnection(connection) {
|
||||
const trace = [];
|
||||
const settings = settingsFromVCenterConnection(connection);
|
||||
assertConfigured(settings);
|
||||
const { profile, vmPayload } = await connectAndListVms(settings, {}, trace);
|
||||
return {
|
||||
status: vcenterConnectionStatus(connection),
|
||||
apiProfile: profile.label,
|
||||
count: unwrapList(vmPayload).length,
|
||||
trace,
|
||||
message: `Connected to ${settings.baseUrl} with ${profile.label}; ${unwrapList(vmPayload).length} VM summary row(s) returned.`
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
assertConfigured(settings);
|
||||
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];
|
||||
let lastError = null;
|
||||
for (const mode of modes) {
|
||||
const profile = API_PROFILES[mode];
|
||||
try {
|
||||
const sessionId = await createSession(settings, profile, trace);
|
||||
const payload = await vcenterFetch(settings, profile.vmDetailPath(vmId), { sessionId, trace });
|
||||
const value = payload?.value || payload || {};
|
||||
return {
|
||||
checked: true,
|
||||
state: value.power_state || value.powerState || '',
|
||||
apiProfile: profile.label
|
||||
};
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (settings.apiMode !== 'auto' || mode === modes.at(-1) || !shouldTryFallback(err)) throw err;
|
||||
pushTraceInfo(trace, `Falling back after ${profile.label} power-state lookup failed`, {
|
||||
mode,
|
||||
error: err.message,
|
||||
statusCode: err.statusCode
|
||||
});
|
||||
}
|
||||
}
|
||||
throw lastError || new VCenterTraceError('No vCenter API profile could be attempted for power-state lookup.', trace);
|
||||
}
|
||||
|
||||
async function enrichVmsForPreview({ vms, settings, profile, sessionId, trace, filter, limit, diagnostics, candidates }) {
|
||||
let index = 0;
|
||||
async function worker() {
|
||||
|
||||
Reference in New Issue
Block a user