This commit is contained in:
2026-06-25 20:57:09 -05:00
parent 552b86c323
commit e47d09d7d6
35 changed files with 4507 additions and 101 deletions

View File

@@ -13,6 +13,7 @@ import { getVCenterVmPowerState } from './vcenterService.js';
import { path } from '../utils/pathUtils.js';
import { analyzePowerShellCompatibility, compatibilityLogLines } from './powershellCompatibilityService.js';
import { normalizeOsFamily } from '../utils/osFamily.js';
import { generateJobOutputsSafe } from './jobOutputService.js';
const activeJobs = new Map();
@@ -42,6 +43,19 @@ export function powerShellSpawnFailureLines(error, executable) {
return lines;
}
export function powerShellSignalFailureLines(signal, executable) {
const lines = [
`PowerShell executable "${executable}" terminated by signal ${signal}.`,
`API process platform: ${process.platform}; PATH: ${process.env.PATH || '(empty)'}`
];
if (signal === 'SIGABRT') {
lines.push('SIGABRT usually means the PowerShell/.NET process aborted after launch, not that POSHManager could not find the executable.');
lines.push('On Linux API hosts, verify the configured pwsh can run non-interactively with: pwsh -NoProfile -Command "$PSVersionTable".');
lines.push('For Windows remoting targets, also verify PowerShell remoting/WinRM connectivity from the API host using the same credential and target address.');
}
return lines;
}
export function executeRunPlan(runplanId, userId) {
// Create the durable job record before async execution so the UI can attach to logs immediately.
const runplan = db.prepare('SELECT * FROM runplans WHERE id = ?').get(runplanId);
@@ -88,9 +102,10 @@ export function executeRunPlan(runplanId, userId) {
assets: listExecutableAssets(userId)
};
void runJob(jobId, script, hosts, Boolean(runplan.parallel), executionContext).catch((error) => {
void runJob(jobId, script, hosts, Boolean(runplan.parallel), executionContext).catch(async (error) => {
logger.error('job runner crashed', { jobId, error });
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId);
await generateJobOutputsSafe(jobId);
});
return db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId);
@@ -154,7 +169,7 @@ export function executeScriptTest(scriptId, payload, userId) {
INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
VALUES (?, NULL, ?, 'running', ?, ?, ?)
`).run(jobId, script.id, userId, now(), now());
db.prepare(`
const insertJobHost = db.prepare(`
INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
VALUES (?, ?, ?, 'queued', NULL)
`);
@@ -172,10 +187,11 @@ export function executeScriptTest(scriptId, payload, userId) {
};
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) => {
void runJob(jobId, script, hosts, false, executionContext).catch(async (error) => {
logger.error('script test runner crashed', { jobId, error });
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);
await generateJobOutputsSafe(jobId);
});
return db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId);
@@ -203,10 +219,12 @@ async function runJob(jobId, script, hosts, parallel, executionContext) {
if (isJobCanceled(jobId)) {
cancelQueuedHosts(jobId);
db.prepare('UPDATE jobs SET status = ?, ended_at = COALESCE(ended_at, ?) WHERE id = ?').run('canceled', now(), jobId);
await generateJobOutputsSafe(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);
await generateJobOutputsSafe(jobId);
} finally {
activeJobs.delete(jobId);
}
@@ -238,6 +256,7 @@ async function runHost(jobId, script, host, executionContext) {
}
if (!(await vCenterPowerPreflight(jobId, host))) return;
logTransportPreflight(jobId, host);
const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`);
@@ -284,7 +303,13 @@ async function vCenterPowerPreflight(jobId, host) {
return true;
}
function buildWrapper(content, host, executionContext = {}) {
function logTransportPreflight(jobId, host) {
if (host.transport === 'winrm' && process.platform !== 'win32') {
appendLog(jobId, host.id, 'system', 'WinRM target from a Linux API host: Invoke-Command -ComputerName requires WSMan client support inside the API environment. If PowerShell reports no supported WSMan client library, install/configure the Linux WSMan dependencies or use SSH transport for PowerShell 7 targets.');
}
}
export function buildWrapper(content, host, executionContext = {}) {
// Build PowerShell wrappers without shell interpolation; host values are quoted as PS literals.
const runtimeVariables = runtimeVariableValues(host, executionContext);
const customVariables = executionContext.customVariables || [];
@@ -296,37 +321,53 @@ function buildWrapper(content, host, executionContext = {}) {
const rendered = renderTokens(content, [...runtimeVariables, ...customVariables, ...assetVariables]);
const scriptWithPrelude = `${buildVariablePrelude(runtimeVariables, customVariables)}\n${rendered}`;
const escaped = scriptWithPrelude.replace(/'@/g, "'`@");
const scriptSetup = [
'$ErrorActionPreference = "Stop"',
'$ProgressPreference = "SilentlyContinue"',
'if (Get-Variable -Name PSStyle -ErrorAction SilentlyContinue) { $PSStyle.OutputRendering = "PlainText" }',
'try {'
];
const scriptExit = [
' if ($global:LASTEXITCODE -is [int] -and $global:LASTEXITCODE -ne 0) { exit $global:LASTEXITCODE }',
' exit 0',
'} catch {',
' $message = ($_ | Out-String).TrimEnd()',
' if (-not $message) { $message = $_.Exception.Message }',
' [Console]::Error.WriteLine($message)',
' exit 1',
'}'
];
const scriptTextBlock = [
` $scriptText = @'`,
escaped,
"'@",
' $scriptBlock = [ScriptBlock]::Create($scriptText)'
];
if (host.transport === 'local') {
return [
'$ErrorActionPreference = "Continue"',
`$scriptText = @'`,
escaped,
"'@",
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
'& $scriptBlock'
...scriptSetup,
...scriptTextBlock,
' & $scriptBlock',
...scriptExit
].join('\n');
}
if (host.transport === 'ssh') {
return [
'$ErrorActionPreference = "Continue"',
`$scriptText = @'`,
escaped,
"'@",
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
`Invoke-Command -HostName ${psString(host.address)} -UserName $env:POSHM_USERNAME -ScriptBlock $scriptBlock`
...scriptSetup,
...scriptTextBlock,
` Invoke-Command -HostName ${psString(host.address)} -UserName $env:POSHM_USERNAME -ScriptBlock $scriptBlock -ErrorAction Stop`,
...scriptExit
].join('\n');
}
return [
'$ErrorActionPreference = "Continue"',
'$securePassword = ConvertTo-SecureString $env:POSHM_SECRET -AsPlainText -Force',
'$credential = [System.Management.Automation.PSCredential]::new($env:POSHM_USERNAME, $securePassword)',
`$scriptText = @'`,
escaped,
"'@",
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
`Invoke-Command -ComputerName ${psString(host.address)} -Credential $credential -ScriptBlock $scriptBlock`
...scriptSetup,
' $securePassword = ConvertTo-SecureString $env:POSHM_SECRET -AsPlainText -Force',
' $credential = [System.Management.Automation.PSCredential]::new($env:POSHM_USERNAME, $securePassword)',
...scriptTextBlock,
` Invoke-Command -ComputerName ${psString(host.address)} -Credential $credential -ScriptBlock $scriptBlock -ErrorAction Stop`,
...scriptExit
].join('\n');
}
@@ -380,7 +421,9 @@ 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 (signal) {
for (const line of powerShellSignalFailureLines(signal, executable)) appendLog(jobId, host.id, 'stderr', line);
}
if (isJobCanceled(jobId)) {
settle('canceled', 130);
return;