Initial
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user