Initial
This commit is contained in:
@@ -12,6 +12,12 @@ export function getBooleanSetting(key, fallback) {
|
|||||||
return String(value).toLowerCase() === 'true';
|
return String(value).toLowerCase() === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStringSetting(key, fallback = '') {
|
||||||
|
const value = getSetting(key);
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
return normalized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
// Effective CORS allow-list: admin-editable `trusted_origins` merged with the
|
// Effective CORS allow-list: admin-editable `trusted_origins` merged with the
|
||||||
// built-in localhost defaults so the dev experience never locks itself out.
|
// built-in localhost defaults so the dev experience never locks itself out.
|
||||||
export function effectiveTrustedOrigins() {
|
export function effectiveTrustedOrigins() {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { config } from '../config.js';
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { listExecutableAssets } from '../models/assetModel.js';
|
import { listExecutableAssets } from '../models/assetModel.js';
|
||||||
import { listExecutableCustomVariables } from '../models/variableModel.js';
|
import { listExecutableCustomVariables } from '../models/variableModel.js';
|
||||||
import { getBooleanSetting } from '../models/settingsModel.js';
|
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
|
||||||
import { path } from '../utils/pathUtils.js';
|
import { path } from '../utils/pathUtils.js';
|
||||||
|
|
||||||
// The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and
|
// The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and
|
||||||
@@ -17,6 +17,25 @@ function scriptExecutionAllowed() {
|
|||||||
return getBooleanSetting('allow_script_execution', config.allowScriptExecution);
|
return getBooleanSetting('allow_script_execution', config.allowScriptExecution);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolvePowerShellExecutable() {
|
||||||
|
return getStringSetting('powershell_bin', config.powershellBin).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function powerShellSpawnFailureLines(error, executable) {
|
||||||
|
const code = error?.code ? ` (${error.code})` : '';
|
||||||
|
const lines = [
|
||||||
|
`Unable to start configured PowerShell executable "${executable}"${code}.`,
|
||||||
|
`Node spawn error: ${error?.message || 'unknown process start failure'}`
|
||||||
|
];
|
||||||
|
if (error?.code === 'ENOENT') {
|
||||||
|
lines.push('The executable was not found on the API server PATH.');
|
||||||
|
lines.push('Windows: set Config > PowerShell Binary to "powershell.exe" or the full path "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe".');
|
||||||
|
lines.push('PowerShell 7: set it to "pwsh.exe" or the full pwsh install path.');
|
||||||
|
}
|
||||||
|
lines.push(`API process platform: ${process.platform}; PATH: ${process.env.PATH || '(empty)'}`);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
export function executeRunPlan(runplanId, userId) {
|
export function executeRunPlan(runplanId, userId) {
|
||||||
// Create the durable job record before async execution so the UI can attach to logs immediately.
|
// 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);
|
const runplan = db.prepare('SELECT * FROM runplans WHERE id = ?').get(runplanId);
|
||||||
@@ -214,8 +233,11 @@ function spawnPowerShell(jobId, host, file, secret, executionContext = {}) {
|
|||||||
// Secrets are passed through process environment variables and are never written to job logs.
|
// Secrets are passed through process environment variables and are never written to job logs.
|
||||||
const runtimeEnv = Object.fromEntries(runtimeVariableValues(host, executionContext).map((variable) => [variable.name, String(variable.value ?? '')]));
|
const runtimeEnv = Object.fromEntries(runtimeVariableValues(host, executionContext).map((variable) => [variable.name, String(variable.value ?? '')]));
|
||||||
const assetManifest = JSON.stringify(executionContext.assets || []);
|
const assetManifest = JSON.stringify(executionContext.assets || []);
|
||||||
|
const executable = resolvePowerShellExecutable();
|
||||||
|
const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file];
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn(config.powershellBin, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file], {
|
appendLog(jobId, host.id, 'system', `Launching PowerShell executable "${executable}"`);
|
||||||
|
const child = spawn(executable, args, {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
...runtimeEnv,
|
...runtimeEnv,
|
||||||
@@ -229,19 +251,26 @@ function spawnPowerShell(jobId, host, file, secret, executionContext = {}) {
|
|||||||
},
|
},
|
||||||
shell: false
|
shell: false
|
||||||
});
|
});
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
function settle(status, exitCode) {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
finishHost(jobId, host.id, status, exitCode);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
|
||||||
child.stdout.on('data', (chunk) => appendChunk(jobId, host.id, 'stdout', chunk));
|
child.stdout.on('data', (chunk) => appendChunk(jobId, host.id, 'stdout', chunk));
|
||||||
child.stderr.on('data', (chunk) => appendChunk(jobId, host.id, 'stderr', chunk));
|
child.stderr.on('data', (chunk) => appendChunk(jobId, host.id, 'stderr', chunk));
|
||||||
child.on('error', (error) => {
|
child.on('error', (error) => {
|
||||||
appendLog(jobId, host.id, 'stderr', `Unable to start PowerShell: ${error.message}`);
|
for (const line of powerShellSpawnFailureLines(error, executable)) appendLog(jobId, host.id, 'stderr', line);
|
||||||
finishHost(jobId, host.id, 'failed', 127);
|
settle('failed', 127);
|
||||||
resolve();
|
|
||||||
});
|
});
|
||||||
child.on('close', (code, signal) => {
|
child.on('close', (code, signal) => {
|
||||||
|
if (settled) return;
|
||||||
if (signal) appendLog(jobId, host.id, 'stderr', `PowerShell terminated by signal ${signal}`);
|
if (signal) appendLog(jobId, host.id, 'stderr', `PowerShell terminated by signal ${signal}`);
|
||||||
const exitCode = code ?? 1;
|
const exitCode = code ?? 1;
|
||||||
finishHost(jobId, host.id, exitCode === 0 ? 'completed' : 'failed', exitCode);
|
settle(exitCode === 0 ? 'completed' : 'failed', exitCode);
|
||||||
resolve();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { db, load } from './setup.mjs';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
const { getSetting, getBooleanSetting, updateSettings, effectiveTrustedOrigins } = await load('../models/settingsModel.js');
|
const { getSetting, getBooleanSetting, getStringSetting, updateSettings, effectiveTrustedOrigins } = await load('../models/settingsModel.js');
|
||||||
|
const { resolvePowerShellExecutable, powerShellSpawnFailureLines } = await load('../services/powershellRunner.js');
|
||||||
|
|
||||||
test('unpinned settings seed as editable defaults', () => {
|
test('unpinned settings seed as editable defaults', () => {
|
||||||
// No SERVER_FQDN env var is set in the test environment, so server_fqdn is a
|
// No SERVER_FQDN env var is set in the test environment, so server_fqdn is a
|
||||||
@@ -30,6 +31,19 @@ test('allow_script_execution is readable as an effective boolean', () => {
|
|||||||
assert.equal(getBooleanSetting('allow_script_execution', false), true);
|
assert.equal(getBooleanSetting('allow_script_execution', false), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('powershell_bin config is used by the runner at execution time', () => {
|
||||||
|
updateSettings({ powershell_bin: 'powershell.exe' });
|
||||||
|
assert.equal(getStringSetting('powershell_bin', 'pwsh'), 'powershell.exe');
|
||||||
|
assert.equal(resolvePowerShellExecutable(), 'powershell.exe');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PowerShell spawn failures include operator guidance', () => {
|
||||||
|
const lines = powerShellSpawnFailureLines({ code: 'ENOENT', message: 'spawn pwsh ENOENT' }, 'pwsh');
|
||||||
|
assert.ok(lines.some((line) => line.includes('configured PowerShell executable "pwsh"')));
|
||||||
|
assert.ok(lines.some((line) => line.includes('Config > PowerShell Binary')));
|
||||||
|
assert.ok(lines.some((line) => line.includes('powershell.exe')));
|
||||||
|
});
|
||||||
|
|
||||||
test('effectiveTrustedOrigins merges admin edits with built-in defaults', () => {
|
test('effectiveTrustedOrigins merges admin edits with built-in defaults', () => {
|
||||||
updateSettings({ trusted_origins: 'https://ops.example.com' });
|
updateSettings({ trusted_origins: 'https://ops.example.com' });
|
||||||
const origins = effectiveTrustedOrigins();
|
const origins = effectiveTrustedOrigins();
|
||||||
|
|||||||
Reference in New Issue
Block a user