Initial
This commit is contained in:
55
server/controllers/jobOutputController.js
Normal file
55
server/controllers/jobOutputController.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
getJobOutputFile,
|
||||
listJobOutputs,
|
||||
listJobOutputsForJob
|
||||
} from '../models/jobOutputModel.js';
|
||||
import { getJob } from '../models/jobModel.js';
|
||||
import { generateJobOutputs } from '../services/jobOutputService.js';
|
||||
|
||||
function isAdmin(req) {
|
||||
return req.user.role === 'admin';
|
||||
}
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listJobOutputs(req.user.id, isAdmin(req)));
|
||||
}
|
||||
|
||||
export function byJob(req, res) {
|
||||
res.json(listJobOutputsForJob(req.params.jobId, req.user.id, isAdmin(req)));
|
||||
}
|
||||
|
||||
export async function generate(req, res) {
|
||||
const job = getJob(req.params.jobId, req.user.id, isAdmin(req));
|
||||
if (!job) return res.status(404).json({ error: 'Job not found' });
|
||||
await generateJobOutputs(req.params.jobId);
|
||||
const outputs = listJobOutputsForJob(req.params.jobId, req.user.id, isAdmin(req));
|
||||
if (!outputs.length) return res.status(409).json({ error: 'Job is not ready for output generation' });
|
||||
res.json(outputs);
|
||||
}
|
||||
|
||||
export function content(req, res) {
|
||||
const result = getJobOutputFile(req.params.id, req.user.id, isAdmin(req));
|
||||
if (!result) return res.status(404).json({ error: 'Job output not found' });
|
||||
if (result.output.mimeType === 'application/json') {
|
||||
return res.json({ output: result.output, table: JSON.parse(fs.readFileSync(result.filePath, 'utf8')) });
|
||||
}
|
||||
if (result.output.mimeType.startsWith('text/')) {
|
||||
return res.json({ output: result.output, text: fs.readFileSync(result.filePath, 'utf8') });
|
||||
}
|
||||
return res.json({ output: result.output });
|
||||
}
|
||||
|
||||
export function view(req, res) {
|
||||
const result = getJobOutputFile(req.params.id, req.user.id, isAdmin(req));
|
||||
if (!result) return res.status(404).json({ error: 'Job output not found' });
|
||||
res.type(result.output.mimeType || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(result.output.name)}"`);
|
||||
res.sendFile(result.filePath);
|
||||
}
|
||||
|
||||
export function download(req, res) {
|
||||
const result = getJobOutputFile(req.params.id, req.user.id, isAdmin(req));
|
||||
if (!result) return res.status(404).json({ error: 'Job output not found' });
|
||||
res.download(result.filePath, result.output.name);
|
||||
}
|
||||
80
server/controllers/runPlanScheduleController.js
Normal file
80
server/controllers/runPlanScheduleController.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { camelJob } from '../models/mappers.js';
|
||||
import {
|
||||
createRunPlanSchedule,
|
||||
deleteRunPlanSchedule,
|
||||
getRunPlanSchedule,
|
||||
listRunPlanScheduleRuns,
|
||||
listRunPlanSchedules,
|
||||
previewRunPlanSchedule,
|
||||
recordRunPlanScheduleDispatch,
|
||||
setRunPlanScheduleEnabled,
|
||||
updateRunPlanSchedule
|
||||
} from '../models/runPlanScheduleModel.js';
|
||||
import { executeRunPlan } from '../services/powershellRunner.js';
|
||||
|
||||
function isAdmin(req) {
|
||||
return req.user?.role === 'admin';
|
||||
}
|
||||
|
||||
export function index(req, res) {
|
||||
res.json({
|
||||
schedules: listRunPlanSchedules(req.user.id, isAdmin(req)),
|
||||
history: listRunPlanScheduleRuns(req.user.id, isAdmin(req))
|
||||
});
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
try {
|
||||
res.status(201).json(createRunPlanSchedule(req.body, req.user.id));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export function update(req, res) {
|
||||
try {
|
||||
const schedule = updateRunPlanSchedule(req.params.id, req.body, req.user.id, isAdmin(req));
|
||||
if (!schedule) return res.status(404).json({ error: 'Schedule not found' });
|
||||
res.json(schedule);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export function destroy(req, res) {
|
||||
if (!deleteRunPlanSchedule(req.params.id, req.user.id, isAdmin(req))) {
|
||||
return res.status(404).json({ error: 'Schedule not found' });
|
||||
}
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function toggle(req, res) {
|
||||
const schedule = setRunPlanScheduleEnabled(req.params.id, Boolean(req.body?.enabled), req.user.id, isAdmin(req));
|
||||
if (!schedule) return res.status(404).json({ error: 'Schedule not found' });
|
||||
res.json(schedule);
|
||||
}
|
||||
|
||||
export function preview(req, res) {
|
||||
try {
|
||||
res.json({ occurrences: previewRunPlanSchedule(req.body) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export function runNow(req, res) {
|
||||
const schedule = getRunPlanSchedule(req.params.id, req.user.id, isAdmin(req));
|
||||
if (!schedule) return res.status(404).json({ error: 'Schedule not found' });
|
||||
try {
|
||||
const job = executeRunPlan(schedule.runplanId, req.user.id);
|
||||
recordRunPlanScheduleDispatch(schedule, {
|
||||
jobId: job.id,
|
||||
plannedFor: new Date().toISOString(),
|
||||
status: 'manual',
|
||||
message: `Operator launched schedule ${schedule.name} manually.`
|
||||
});
|
||||
res.status(202).json(camelJob(job));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ const operationsArticles = [
|
||||
'Dashboard shows metrics, charts, recent jobs, and configurable widgets.',
|
||||
'Scripts contains the VS Code-style Monaco PowerShell editor, script library, variable studio, and metadata panel.',
|
||||
'PSADT contains the deployment factory, verifier, migration wizard, and Intune Publishing Wizard.',
|
||||
'Hosts, Credential Vault, RunPlans, Logs, Users & Groups, and Config are separate operational surfaces.'
|
||||
'Hosts, Credential Vault, RunPlans, Scheduling, Logs, Job Output, Users & Groups, and Config are separate operational surfaces.'
|
||||
])
|
||||
], ['dashboard', 'operations', 'navigation']),
|
||||
article('ops-scripts', 'Operations', 'Scripts And Variables', 'Create folders, author scripts, use variables, and keep versions.', [
|
||||
@@ -75,9 +75,15 @@ const operationsArticles = [
|
||||
'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('Scheduling', [
|
||||
'Scheduling automates RunPlan execution with one-time, interval, daily, weekly, monthly, and yearly recurrence modes.',
|
||||
'The Scheduling screen has calendar, Gantt-style timeline, and datatable views. Create and edit schedules in modal flows so the operational view stays clean.',
|
||||
'The API worker scans for due schedules using runplan_schedule_interval_minutes / RUNPLAN_SCHEDULE_INTERVAL_MINUTES. Set it to 0 to disable automated dispatch.',
|
||||
'Scheduled runs use the same RunPlan execution service as manual runs, so logs and Job Output artifacts are generated the same way.'
|
||||
]),
|
||||
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.',
|
||||
'Use Run now for supported background workers such as dynamic Host Group sync, RunPlan schedule dispatch, 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', [
|
||||
@@ -132,10 +138,14 @@ const apiArticles = [
|
||||
apiExample('Get', '/api/runplans/rp_123/compatibility', 'Analyze a RunPlan script against its current target OS mix before execution.'),
|
||||
apiExample('Post', '/api/runplans', 'Create a RunPlan. hostIds should contain existing host ids.', '@{ name = "Patch Validation"; description = "Run validation script"; scriptId = "scr_123"; visibility = "shared"; parallel = $true; hostIds = @("hst_123") }'),
|
||||
apiExample('Post', '/api/runplans/rp_123/execute', 'Execute a RunPlan and create a job. Replace rp_123 with the RunPlan id.'),
|
||||
apiExample('Get', '/api/schedules', 'List visible RunPlan schedules and recent scheduler dispatch history.'),
|
||||
apiExample('Post', '/api/schedules', 'Create a recurring RunPlan schedule.', '@{ name = "Engineering patch validation"; runplanId = "run_123"; scheduleType = "weekly"; startAt = "2026-06-29T14:00:00.000Z"; timeOfDay = "09:00"; daysOfWeek = @(1,3); enabled = $true; visibility = "shared" }'),
|
||||
apiExample('Post', '/api/schedules/sched_123/run-now', 'Queue the RunPlan behind a saved schedule immediately.'),
|
||||
apiExample('Post', '/api/schedules/sched_123/toggle', 'Enable or pause a schedule.', '@{ enabled = $false }'),
|
||||
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/worker%3Ahost-group-sync/run', 'Run a supported background worker immediately. Other worker ids include worker:runplan-schedule-dispatch, 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.')
|
||||
,
|
||||
|
||||
63
server/db.js
63
server/db.js
@@ -442,6 +442,32 @@ export function migrate() {
|
||||
PRIMARY KEY (runplan_id, host_group_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runplan_schedules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
runplan_id TEXT NOT NULL REFERENCES runplans(id) ON DELETE CASCADE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
schedule_type TEXT NOT NULL DEFAULT 'once',
|
||||
start_at TEXT NOT NULL,
|
||||
end_at TEXT,
|
||||
time_of_day TEXT,
|
||||
interval_value INTEGER,
|
||||
interval_unit TEXT,
|
||||
days_of_week TEXT NOT NULL DEFAULT '[]',
|
||||
day_of_month INTEGER,
|
||||
month_of_year INTEGER,
|
||||
next_run_at TEXT,
|
||||
last_run_at TEXT,
|
||||
last_job_id TEXT REFERENCES jobs(id) ON DELETE SET NULL,
|
||||
visibility TEXT NOT NULL DEFAULT 'personal',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
runplan_id TEXT REFERENCES runplans(id) ON DELETE SET NULL,
|
||||
@@ -472,6 +498,32 @@ export function migrate() {
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_outputs (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
storage_path TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
checksum TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(job_id, kind)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runplan_schedule_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
schedule_id TEXT NOT NULL REFERENCES runplan_schedules(id) ON DELETE CASCADE,
|
||||
runplan_id TEXT REFERENCES runplans(id) ON DELETE SET NULL,
|
||||
job_id TEXT REFERENCES jobs(id) ON DELETE SET NULL,
|
||||
planned_for TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_job_actions (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT,
|
||||
@@ -531,6 +583,7 @@ export function migrate() {
|
||||
ensureColumn('vcenter_connections', 'target_type', "TEXT NOT NULL DEFAULT 'vcenter'");
|
||||
ensureColumn('vcenter_connections', 'credential_id', 'TEXT REFERENCES credentials(id) ON DELETE SET NULL');
|
||||
ensureColumn('system_job_actions', 'metadata_json', "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn('job_outputs', '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');
|
||||
@@ -601,9 +654,17 @@ export function seed() {
|
||||
VALUES (?, ?, 'default', ?)
|
||||
ON CONFLICT(key) DO NOTHING
|
||||
`);
|
||||
const releaseEnvSource = db.prepare(`
|
||||
UPDATE settings
|
||||
SET value = ?, source = 'default', updated_at = ?
|
||||
WHERE key = ? AND source = 'env'
|
||||
`);
|
||||
for (const def of settingDefinitions()) {
|
||||
if (def.pinned) pinned.run(def.key, def.value, now());
|
||||
else defaulted.run(def.key, def.value, now());
|
||||
else {
|
||||
defaulted.run(def.key, def.value, now());
|
||||
releaseEnvSource.run(def.value, now(), def.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -367,6 +367,24 @@ const runPlanPayloadSchema = z.object({
|
||||
hostGroupIds: z.array(z.string()).optional()
|
||||
});
|
||||
|
||||
const runPlanSchedulePayloadSchema = z.object({
|
||||
name: z.string().min(1).max(180),
|
||||
description: z.string().max(800).optional().default(''),
|
||||
runplanId: z.string().min(1),
|
||||
enabled: z.boolean().or(z.number()).optional().default(true),
|
||||
scheduleType: z.enum(['once', 'interval', 'daily', 'weekly', 'monthly', 'yearly']).default('once'),
|
||||
startAt: z.string().min(1),
|
||||
endAt: z.string().optional().default(''),
|
||||
timeOfDay: z.string().regex(/^\d{2}:\d{2}$/).optional().or(z.literal('')).default(''),
|
||||
intervalValue: z.coerce.number().int().min(1).max(100000).optional().default(1),
|
||||
intervalUnit: z.enum(['minutes', 'hours', 'days', 'months', 'years']).optional().default('hours'),
|
||||
daysOfWeek: z.array(z.coerce.number().int().min(0).max(6)).optional().default([]),
|
||||
dayOfMonth: z.coerce.number().int().min(1).max(31).nullable().optional(),
|
||||
monthOfYear: z.coerce.number().int().min(1).max(12).nullable().optional(),
|
||||
visibility: visibilitySchema.default('personal'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const scriptCompatibilitySchema = z.object({
|
||||
hostId: z.string().optional().default(''),
|
||||
hostGroupId: z.string().optional().default(''),
|
||||
@@ -399,3 +417,24 @@ export function normalizeRunPlanPayload(body) {
|
||||
hostGroupIds: parsed.hostGroupIds || []
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRunPlanSchedulePayload(body) {
|
||||
const parsed = runPlanSchedulePayloadSchema.parse(body);
|
||||
return {
|
||||
name: parsed.name,
|
||||
description: parsed.description || '',
|
||||
runplanId: parsed.runplanId,
|
||||
enabled: Boolean(parsed.enabled),
|
||||
scheduleType: parsed.scheduleType,
|
||||
startAt: parsed.startAt,
|
||||
endAt: parsed.endAt || '',
|
||||
timeOfDay: parsed.timeOfDay || '',
|
||||
intervalValue: parsed.intervalValue || 1,
|
||||
intervalUnit: parsed.intervalUnit || 'hours',
|
||||
daysOfWeek: [...new Set(parsed.daysOfWeek || [])].sort((a, b) => a - b),
|
||||
dayOfMonth: parsed.dayOfMonth || null,
|
||||
monthOfYear: parsed.monthOfYear || null,
|
||||
visibility: parsed.visibility,
|
||||
groupId: parsed.visibility === 'group' ? parsed.groupId ?? null : null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { effectiveTrustedOrigins } from './models/settingsModel.js';
|
||||
import { startCatalogWorker } from './services/catalogWorker.js';
|
||||
import { startPromotionWorker } from './services/promotionWorker.js';
|
||||
import { startHostGroupWorker } from './services/hostGroupWorker.js';
|
||||
import { startRunPlanScheduleWorker } from './services/runPlanScheduleWorker.js';
|
||||
|
||||
// Fail fast in production rather than ship with well-known default secrets.
|
||||
// These defaults are fine for local development but are publicly known, so a
|
||||
@@ -65,4 +66,5 @@ app.listen(config.port, () => {
|
||||
startCatalogWorker();
|
||||
startPromotionWorker();
|
||||
startHostGroupWorker();
|
||||
startRunPlanScheduleWorker();
|
||||
});
|
||||
|
||||
76
server/models/jobOutputModel.js
Normal file
76
server/models/jobOutputModel.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { config } from '../config.js';
|
||||
import { db, parseJson } from '../db.js';
|
||||
import { camelJobOutput } from './mappers.js';
|
||||
import { isPathInside, path } from '../utils/pathUtils.js';
|
||||
|
||||
// Keep output artifact visibility identical to job visibility so logs and
|
||||
// generated files do not leak across users.
|
||||
function visibleJobClause() {
|
||||
return `(
|
||||
j.triggered_by = ?
|
||||
OR r.visibility = 'shared'
|
||||
OR r.owner_user_id = ?
|
||||
OR r.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
)`;
|
||||
}
|
||||
|
||||
function outputSelect() {
|
||||
return `
|
||||
SELECT jo.*, j.status AS job_status, j.runplan_id, j.script_id, j.triggered_by,
|
||||
r.name AS runplan_name, s.name AS script_name
|
||||
FROM job_outputs jo
|
||||
JOIN jobs j ON j.id = jo.job_id
|
||||
LEFT JOIN runplans r ON r.id = j.runplan_id
|
||||
LEFT JOIN scripts s ON s.id = j.script_id
|
||||
`;
|
||||
}
|
||||
|
||||
export function listJobOutputs(userId, isAdmin = false) {
|
||||
const rows = isAdmin
|
||||
? db.prepare(`
|
||||
${outputSelect()}
|
||||
ORDER BY jo.updated_at DESC
|
||||
LIMIT 250
|
||||
`).all()
|
||||
: db.prepare(`
|
||||
${outputSelect()}
|
||||
WHERE ${visibleJobClause()}
|
||||
ORDER BY jo.updated_at DESC
|
||||
LIMIT 250
|
||||
`).all(userId, userId, userId);
|
||||
return rows.map(camelJobOutput);
|
||||
}
|
||||
|
||||
export function listJobOutputsForJob(jobId, userId, isAdmin = false) {
|
||||
const rows = isAdmin
|
||||
? db.prepare(`
|
||||
${outputSelect()}
|
||||
WHERE jo.job_id = ?
|
||||
ORDER BY jo.kind
|
||||
`).all(jobId)
|
||||
: db.prepare(`
|
||||
${outputSelect()}
|
||||
WHERE jo.job_id = ? AND ${visibleJobClause()}
|
||||
ORDER BY jo.kind
|
||||
`).all(jobId, userId, userId, userId);
|
||||
return rows.map(camelJobOutput);
|
||||
}
|
||||
|
||||
export function getJobOutput(outputId, userId, isAdmin = false) {
|
||||
const row = isAdmin
|
||||
? db.prepare(`${outputSelect()} WHERE jo.id = ?`).get(outputId)
|
||||
: db.prepare(`${outputSelect()} WHERE jo.id = ? AND ${visibleJobClause()}`).get(outputId, userId, userId, userId);
|
||||
return row ? camelJobOutput(row) : null;
|
||||
}
|
||||
|
||||
export function getJobOutputFile(outputId, userId, isAdmin = false) {
|
||||
const row = isAdmin
|
||||
? db.prepare(`${outputSelect()} WHERE jo.id = ?`).get(outputId)
|
||||
: db.prepare(`${outputSelect()} WHERE jo.id = ? AND ${visibleJobClause()}`).get(outputId, userId, userId, userId);
|
||||
if (!row) return null;
|
||||
const filePath = path.resolve(row.storage_path);
|
||||
if (!isPathInside(config.fileLockerDir, filePath)) {
|
||||
throw new Error('Job output path is outside the configured FileLocker directory');
|
||||
}
|
||||
return { output: camelJobOutput(row), filePath, metadata: parseJson(row.metadata_json, {}) };
|
||||
}
|
||||
@@ -200,6 +200,55 @@ export function camelRunPlan(row) {
|
||||
};
|
||||
}
|
||||
|
||||
export function camelRunPlanSchedule(row) {
|
||||
const occurrences = parseJson(row.preview_json, []);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
runplanId: row.runplan_id,
|
||||
runplanName: row.runplan_name || null,
|
||||
scriptId: row.script_id || null,
|
||||
scriptName: row.script_name || null,
|
||||
enabled: Boolean(row.enabled),
|
||||
scheduleType: row.schedule_type || 'once',
|
||||
startAt: row.start_at,
|
||||
endAt: row.end_at || '',
|
||||
timeOfDay: row.time_of_day || '',
|
||||
intervalValue: row.interval_value || 1,
|
||||
intervalUnit: row.interval_unit || 'hours',
|
||||
daysOfWeek: parseJson(row.days_of_week, []),
|
||||
dayOfMonth: row.day_of_month || null,
|
||||
monthOfYear: row.month_of_year || null,
|
||||
nextRunAt: row.next_run_at || '',
|
||||
lastRunAt: row.last_run_at || '',
|
||||
lastJobId: row.last_job_id || '',
|
||||
visibility: row.visibility || 'personal',
|
||||
ownerUserId: row.owner_user_id || null,
|
||||
groupId: row.group_id || null,
|
||||
groupName: row.group_name || null,
|
||||
preview: occurrences,
|
||||
createdBy: row.created_by || null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelRunPlanScheduleRun(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
scheduleId: row.schedule_id,
|
||||
runplanId: row.runplan_id,
|
||||
runplanName: row.runplan_name || null,
|
||||
jobId: row.job_id || '',
|
||||
jobStatus: row.job_status || '',
|
||||
plannedFor: row.planned_for,
|
||||
status: row.status,
|
||||
message: row.message || '',
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelCustomVariable(row, includeValue = false) {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -403,6 +452,29 @@ export function camelJobLog(row) {
|
||||
};
|
||||
}
|
||||
|
||||
export function camelJobOutput(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
jobId: row.job_id,
|
||||
kind: row.kind,
|
||||
name: row.name,
|
||||
mimeType: row.mime_type,
|
||||
sizeBytes: row.size_bytes || 0,
|
||||
checksum: row.checksum,
|
||||
metadata: parseJson(row.metadata_json, {}),
|
||||
runplanId: row.runplan_id || null,
|
||||
runplanName: row.runplan_name || null,
|
||||
scriptId: row.script_id || null,
|
||||
scriptName: row.script_name || null,
|
||||
jobStatus: row.job_status || row.status || '',
|
||||
triggeredBy: row.triggered_by || null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
downloadUrl: `/api/job-output/${row.id}/download`,
|
||||
viewUrl: `/api/job-output/${row.id}/view`
|
||||
};
|
||||
}
|
||||
|
||||
export function camelSystemJobAction(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
330
server/models/runPlanScheduleModel.js
Normal file
330
server/models/runPlanScheduleModel.js
Normal file
@@ -0,0 +1,330 @@
|
||||
import { db, id, json, now, parseJson, visibleClause } from '../db.js';
|
||||
import { normalizeRunPlanSchedulePayload } from '../forms/schemas.js';
|
||||
import { camelRunPlanSchedule, camelRunPlanScheduleRun } from './mappers.js';
|
||||
import { loadVisibleRunPlan } from './runPlanModel.js';
|
||||
|
||||
const MAX_PREVIEW_OCCURRENCES = 12;
|
||||
|
||||
export function listRunPlanSchedules(userId, isAdmin = false) {
|
||||
const scope = isAdmin ? '1=1' : visibleClause('rs');
|
||||
return db.prepare(`
|
||||
SELECT rs.*, r.name AS runplan_name, r.script_id, s.name AS script_name, g.name AS group_name
|
||||
FROM runplan_schedules rs
|
||||
JOIN runplans r ON r.id = rs.runplan_id
|
||||
LEFT JOIN scripts s ON s.id = r.script_id
|
||||
LEFT JOIN groups g ON g.id = rs.group_id
|
||||
WHERE ${scope}
|
||||
ORDER BY COALESCE(rs.next_run_at, rs.updated_at) ASC, rs.name ASC
|
||||
`).all(...(isAdmin ? [] : [userId, userId])).map(withPreview).map(camelRunPlanSchedule);
|
||||
}
|
||||
|
||||
export function getRunPlanSchedule(scheduleId, userId, isAdmin = false) {
|
||||
const scope = isAdmin ? '1=1' : visibleClause('rs');
|
||||
const row = db.prepare(`
|
||||
SELECT rs.*, r.name AS runplan_name, r.script_id, s.name AS script_name, g.name AS group_name
|
||||
FROM runplan_schedules rs
|
||||
JOIN runplans r ON r.id = rs.runplan_id
|
||||
LEFT JOIN scripts s ON s.id = r.script_id
|
||||
LEFT JOIN groups g ON g.id = rs.group_id
|
||||
WHERE rs.id = ? AND ${scope}
|
||||
`).get(scheduleId, ...(isAdmin ? [] : [userId, userId]));
|
||||
return row ? camelRunPlanSchedule(withPreview(row)) : null;
|
||||
}
|
||||
|
||||
export function createRunPlanSchedule(body, userId) {
|
||||
const payload = normalizeRunPlanSchedulePayload(body);
|
||||
if (!loadVisibleRunPlan(payload.runplanId, userId)) throw new Error('RunPlan not found or not visible');
|
||||
const scheduleId = id('sched');
|
||||
const nextRunAt = payload.enabled ? computeNextRun(payload, new Date(Date.now() - 1000)) : '';
|
||||
db.prepare(`
|
||||
INSERT INTO runplan_schedules (
|
||||
id, name, description, runplan_id, enabled, schedule_type, start_at, end_at, time_of_day,
|
||||
interval_value, interval_unit, days_of_week, day_of_month, month_of_year, next_run_at,
|
||||
visibility, owner_user_id, group_id, created_by, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
scheduleId,
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.runplanId,
|
||||
payload.enabled ? 1 : 0,
|
||||
payload.scheduleType,
|
||||
payload.startAt,
|
||||
payload.endAt || null,
|
||||
payload.timeOfDay || null,
|
||||
payload.intervalValue,
|
||||
payload.intervalUnit,
|
||||
json(payload.daysOfWeek, []),
|
||||
payload.dayOfMonth,
|
||||
payload.monthOfYear,
|
||||
nextRunAt,
|
||||
payload.visibility,
|
||||
userId,
|
||||
payload.groupId,
|
||||
userId,
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
return getRunPlanSchedule(scheduleId, userId, false);
|
||||
}
|
||||
|
||||
export function updateRunPlanSchedule(scheduleId, body, userId, isAdmin = false) {
|
||||
const existing = getRawVisibleSchedule(scheduleId, userId, isAdmin);
|
||||
if (!existing) return null;
|
||||
const payload = normalizeRunPlanSchedulePayload({
|
||||
...camelRunPlanSchedule(withPreview(existing)),
|
||||
...body,
|
||||
runplanId: body.runplanId || body.runplan_id || existing.runplan_id
|
||||
});
|
||||
if (!loadVisibleRunPlan(payload.runplanId, userId) && !isAdmin) throw new Error('RunPlan not found or not visible');
|
||||
const nextRunAt = payload.enabled ? computeNextRun(payload, new Date(Date.now() - 1000)) : '';
|
||||
db.prepare(`
|
||||
UPDATE runplan_schedules
|
||||
SET name = ?, description = ?, runplan_id = ?, enabled = ?, schedule_type = ?, start_at = ?,
|
||||
end_at = ?, time_of_day = ?, interval_value = ?, interval_unit = ?, days_of_week = ?,
|
||||
day_of_month = ?, month_of_year = ?, next_run_at = ?, visibility = ?, group_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.runplanId,
|
||||
payload.enabled ? 1 : 0,
|
||||
payload.scheduleType,
|
||||
payload.startAt,
|
||||
payload.endAt || null,
|
||||
payload.timeOfDay || null,
|
||||
payload.intervalValue,
|
||||
payload.intervalUnit,
|
||||
json(payload.daysOfWeek, []),
|
||||
payload.dayOfMonth,
|
||||
payload.monthOfYear,
|
||||
nextRunAt,
|
||||
payload.visibility,
|
||||
payload.groupId,
|
||||
now(),
|
||||
scheduleId
|
||||
);
|
||||
return getRunPlanSchedule(scheduleId, userId, isAdmin);
|
||||
}
|
||||
|
||||
export function deleteRunPlanSchedule(scheduleId, userId, isAdmin = false) {
|
||||
const existing = getRawVisibleSchedule(scheduleId, userId, isAdmin);
|
||||
if (!existing) return false;
|
||||
db.prepare('DELETE FROM runplan_schedules WHERE id = ?').run(scheduleId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setRunPlanScheduleEnabled(scheduleId, enabled, userId, isAdmin = false) {
|
||||
const existing = getRawVisibleSchedule(scheduleId, userId, isAdmin);
|
||||
if (!existing) return null;
|
||||
const nextRunAt = enabled ? computeNextRun(rowToSchedule(existing), new Date(Date.now() - 1000)) : '';
|
||||
db.prepare('UPDATE runplan_schedules SET enabled = ?, next_run_at = ?, updated_at = ? WHERE id = ?')
|
||||
.run(enabled ? 1 : 0, nextRunAt, now(), scheduleId);
|
||||
return getRunPlanSchedule(scheduleId, userId, isAdmin);
|
||||
}
|
||||
|
||||
export function listDueRunPlanSchedules(nowIso = now()) {
|
||||
return db.prepare(`
|
||||
SELECT rs.*, r.name AS runplan_name, r.script_id, s.name AS script_name
|
||||
FROM runplan_schedules rs
|
||||
JOIN runplans r ON r.id = rs.runplan_id
|
||||
LEFT JOIN scripts s ON s.id = r.script_id
|
||||
WHERE rs.enabled = 1
|
||||
AND rs.next_run_at IS NOT NULL
|
||||
AND rs.next_run_at != ''
|
||||
AND rs.next_run_at <= ?
|
||||
ORDER BY rs.next_run_at ASC
|
||||
LIMIT 50
|
||||
`).all(nowIso).map((row) => camelRunPlanSchedule(withPreview(row)));
|
||||
}
|
||||
|
||||
export function recordRunPlanScheduleDispatch(schedule, { jobId = '', plannedFor = '', status = 'started', message = '' } = {}) {
|
||||
const raw = getRawSchedule(schedule.id);
|
||||
if (!raw) return null;
|
||||
const effectivePlannedFor = plannedFor || raw.next_run_at || now();
|
||||
const nextRunAt = computeNextRun(rowToSchedule(raw), new Date(Date.now() + 1000));
|
||||
const nextEnabled = raw.schedule_type === 'once' && !nextRunAt ? 0 : raw.enabled;
|
||||
db.prepare(`
|
||||
INSERT INTO runplan_schedule_runs (id, schedule_id, runplan_id, job_id, planned_for, status, message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id('srn'), raw.id, raw.runplan_id, jobId || null, effectivePlannedFor, status, message, now());
|
||||
db.prepare(`
|
||||
UPDATE runplan_schedules
|
||||
SET last_run_at = ?, last_job_id = ?, next_run_at = ?, enabled = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(now(), jobId || null, nextRunAt, nextEnabled, now(), raw.id);
|
||||
return getRawSchedule(raw.id);
|
||||
}
|
||||
|
||||
export function listRunPlanScheduleRuns(userId, isAdmin = false, limit = 100) {
|
||||
const scope = isAdmin ? '1=1' : visibleClause('rs');
|
||||
return db.prepare(`
|
||||
SELECT sr.*, r.name AS runplan_name, j.status AS job_status
|
||||
FROM runplan_schedule_runs sr
|
||||
JOIN runplan_schedules rs ON rs.id = sr.schedule_id
|
||||
LEFT JOIN runplans r ON r.id = sr.runplan_id
|
||||
LEFT JOIN jobs j ON j.id = sr.job_id
|
||||
WHERE ${scope}
|
||||
ORDER BY sr.created_at DESC
|
||||
LIMIT ?
|
||||
`).all(...(isAdmin ? [limit] : [userId, userId, limit])).map(camelRunPlanScheduleRun);
|
||||
}
|
||||
|
||||
export function previewRunPlanSchedule(body) {
|
||||
const payload = normalizeRunPlanSchedulePayload(body);
|
||||
return previewScheduleOccurrences(payload);
|
||||
}
|
||||
|
||||
export function previewScheduleOccurrences(schedule, count = MAX_PREVIEW_OCCURRENCES, fromDate = new Date(Date.now() - 1000)) {
|
||||
const occurrences = [];
|
||||
let cursor = new Date(fromDate);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const next = computeNextRun(schedule, cursor);
|
||||
if (!next) break;
|
||||
occurrences.push(next);
|
||||
cursor = new Date(Date.parse(next) + 1000);
|
||||
}
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
export function computeNextRun(schedule, fromDate = new Date()) {
|
||||
const start = validDate(schedule.startAt || schedule.start_at);
|
||||
if (!start) return '';
|
||||
const end = validDate(schedule.endAt || schedule.end_at);
|
||||
const from = validDate(fromDate) || new Date();
|
||||
const type = schedule.scheduleType || schedule.schedule_type || 'once';
|
||||
const after = new Date(Math.max(from.getTime(), start.getTime() - 1000));
|
||||
let next = null;
|
||||
|
||||
if (type === 'once') next = start > after ? start : null;
|
||||
else if (type === 'interval') next = nextIntervalRun(schedule, start, after);
|
||||
else if (type === 'daily') next = nextDailyRun(schedule, start, after);
|
||||
else if (type === 'weekly') next = nextWeeklyRun(schedule, start, after);
|
||||
else if (type === 'monthly') next = nextMonthlyRun(schedule, start, after);
|
||||
else if (type === 'yearly') next = nextYearlyRun(schedule, start, after);
|
||||
|
||||
if (!next) return '';
|
||||
if (end && next > end) return '';
|
||||
return next.toISOString();
|
||||
}
|
||||
|
||||
function nextIntervalRun(schedule, start, after) {
|
||||
const value = Math.max(1, Number(schedule.intervalValue ?? schedule.interval_value) || 1);
|
||||
const unit = schedule.intervalUnit || schedule.interval_unit || 'hours';
|
||||
let candidate = new Date(start);
|
||||
for (let i = 0; i < 10000 && candidate <= after; i += 1) candidate = addInterval(candidate, value, unit);
|
||||
return candidate > after ? candidate : null;
|
||||
}
|
||||
|
||||
function nextDailyRun(schedule, start, after) {
|
||||
return nextByDayScan(schedule, start, after, () => true);
|
||||
}
|
||||
|
||||
function nextWeeklyRun(schedule, start, after) {
|
||||
const days = parseScheduleDays(schedule);
|
||||
const daySet = new Set(days.length ? days : [start.getDay()]);
|
||||
return nextByDayScan(schedule, start, after, (date) => daySet.has(date.getDay()), 370);
|
||||
}
|
||||
|
||||
function nextMonthlyRun(schedule, start, after) {
|
||||
const day = Math.min(31, Math.max(1, Number(schedule.dayOfMonth ?? schedule.day_of_month) || start.getDate()));
|
||||
let cursor = startOfDay(new Date(Math.max(start.getTime(), after.getTime())));
|
||||
for (let i = 0; i < 240; i += 1) {
|
||||
const candidate = withTime(clampMonthDate(cursor.getFullYear(), cursor.getMonth(), day), schedule, start);
|
||||
if (candidate >= start && candidate > after) return candidate;
|
||||
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nextYearlyRun(schedule, start, after) {
|
||||
const month = Math.min(12, Math.max(1, Number(schedule.monthOfYear ?? schedule.month_of_year) || start.getMonth() + 1)) - 1;
|
||||
const day = Math.min(31, Math.max(1, Number(schedule.dayOfMonth ?? schedule.day_of_month) || start.getDate()));
|
||||
const firstYear = Math.max(start.getFullYear(), after.getFullYear());
|
||||
for (let year = firstYear; year < firstYear + 25; year += 1) {
|
||||
const candidate = withTime(clampMonthDate(year, month, day), schedule, start);
|
||||
if (candidate >= start && candidate > after) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nextByDayScan(schedule, start, after, matches, maxDays = 730) {
|
||||
let cursor = startOfDay(new Date(Math.max(start.getTime(), after.getTime())));
|
||||
for (let i = 0; i < maxDays; i += 1) {
|
||||
if (matches(cursor)) {
|
||||
const candidate = withTime(cursor, schedule, start);
|
||||
if (candidate >= start && candidate > after) return candidate;
|
||||
}
|
||||
cursor = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseScheduleDays(schedule) {
|
||||
const raw = schedule.daysOfWeek ?? schedule.days_of_week ?? [];
|
||||
const days = Array.isArray(raw) ? raw : parseJson(raw, []);
|
||||
return [...new Set(days.map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function addInterval(date, value, unit) {
|
||||
const next = new Date(date);
|
||||
if (unit === 'minutes') next.setMinutes(next.getMinutes() + value);
|
||||
else if (unit === 'hours') next.setHours(next.getHours() + value);
|
||||
else if (unit === 'days') next.setDate(next.getDate() + value);
|
||||
else if (unit === 'months') next.setMonth(next.getMonth() + value);
|
||||
else if (unit === 'years') next.setFullYear(next.getFullYear() + value);
|
||||
return next;
|
||||
}
|
||||
|
||||
function withTime(date, schedule, fallback) {
|
||||
const time = schedule.timeOfDay || schedule.time_of_day || `${String(fallback.getHours()).padStart(2, '0')}:${String(fallback.getMinutes()).padStart(2, '0')}`;
|
||||
const [hours, minutes] = String(time).split(':').map(Number);
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), Number.isFinite(hours) ? hours : 0, Number.isFinite(minutes) ? minutes : 0, 0, 0);
|
||||
}
|
||||
|
||||
function clampMonthDate(year, month, day) {
|
||||
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||
return new Date(year, month, Math.min(day, lastDay));
|
||||
}
|
||||
|
||||
function startOfDay(date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
function validDate(value) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function withPreview(row) {
|
||||
return {
|
||||
...row,
|
||||
preview_json: json(previewScheduleOccurrences(rowToSchedule(row)), [])
|
||||
};
|
||||
}
|
||||
|
||||
function rowToSchedule(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
scheduleType: row.schedule_type,
|
||||
startAt: row.start_at,
|
||||
endAt: row.end_at || '',
|
||||
timeOfDay: row.time_of_day || '',
|
||||
intervalValue: row.interval_value || 1,
|
||||
intervalUnit: row.interval_unit || 'hours',
|
||||
daysOfWeek: parseJson(row.days_of_week, []),
|
||||
dayOfMonth: row.day_of_month || null,
|
||||
monthOfYear: row.month_of_year || null
|
||||
};
|
||||
}
|
||||
|
||||
function getRawVisibleSchedule(scheduleId, userId, isAdmin = false) {
|
||||
const scope = isAdmin ? '1=1' : visibleClause();
|
||||
return db.prepare(`SELECT * FROM runplan_schedules WHERE id = ? AND ${scope}`)
|
||||
.get(scheduleId, ...(isAdmin ? [] : [userId, userId]));
|
||||
}
|
||||
|
||||
function getRawSchedule(scheduleId) {
|
||||
return db.prepare('SELECT * FROM runplan_schedules WHERE id = ?').get(scheduleId);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { db, now } from '../db.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const runtimeEditableEnvKeys = new Set(['powershell_bin']);
|
||||
|
||||
export function getSetting(key) {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||
return row ? row.value : undefined;
|
||||
@@ -45,7 +47,7 @@ export function updateSettings(payload) {
|
||||
for (const [key, value] of entries) {
|
||||
// Env-pinned settings are owned by the deployment environment; ignore
|
||||
// attempts to override them so the UI and process never disagree.
|
||||
if (sourceOf.get(key)?.source === 'env') continue;
|
||||
if (sourceOf.get(key)?.source === 'env' && !runtimeEditableEnvKeys.has(key)) continue;
|
||||
stmt.run(key, String(value ?? ''), now());
|
||||
}
|
||||
return settingsPayload();
|
||||
|
||||
@@ -11,10 +11,12 @@ import { graphRoutes } from './graphRoutes.js';
|
||||
import { helpRoutes } from './helpRoutes.js';
|
||||
import { hostRoutes } from './hostRoutes.js';
|
||||
import { jobRoutes } from './jobRoutes.js';
|
||||
import { jobOutputRoutes } from './jobOutputRoutes.js';
|
||||
import { logRoutes } from './logRoutes.js';
|
||||
import { packagingRoutes } from './packagingRoutes.js';
|
||||
import { psadtRoutes } from './psadtRoutes.js';
|
||||
import { runPlanRoutes } from './runPlanRoutes.js';
|
||||
import { runPlanScheduleRoutes } from './runPlanScheduleRoutes.js';
|
||||
import { settingsRoutes } from './settingsRoutes.js';
|
||||
import { systemJobRoutes } from './systemJobRoutes.js';
|
||||
import { userRoutes } from './userRoutes.js';
|
||||
@@ -41,6 +43,8 @@ apiRoutes.use('/packaging', packagingRoutes);
|
||||
apiRoutes.use('/intune/applications', applicationRoutes);
|
||||
apiRoutes.use('/intune', governanceRoutes);
|
||||
apiRoutes.use('/runplans', runPlanRoutes);
|
||||
apiRoutes.use('/schedules', runPlanScheduleRoutes);
|
||||
apiRoutes.use('/jobs', jobRoutes);
|
||||
apiRoutes.use('/job-output', jobOutputRoutes);
|
||||
apiRoutes.use('/system-jobs', systemJobRoutes);
|
||||
apiRoutes.use('/logs', logRoutes);
|
||||
|
||||
13
server/routes/jobOutputRoutes.js
Normal file
13
server/routes/jobOutputRoutes.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { byJob, content, download, generate, index, view } from '../controllers/jobOutputController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const jobOutputRoutes = Router();
|
||||
|
||||
// Job Output artifacts are generated by the backend and stored in FileLocker.
|
||||
jobOutputRoutes.get('/', requireAuth, index);
|
||||
jobOutputRoutes.get('/jobs/:jobId', requireAuth, byJob);
|
||||
jobOutputRoutes.post('/jobs/:jobId/generate', requireAuth, generate);
|
||||
jobOutputRoutes.get('/:id/content', requireAuth, content);
|
||||
jobOutputRoutes.get('/:id/view', requireAuth, view);
|
||||
jobOutputRoutes.get('/:id/download', requireAuth, download);
|
||||
15
server/routes/runPlanScheduleRoutes.js
Normal file
15
server/routes/runPlanScheduleRoutes.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import { create, destroy, index, preview, runNow, toggle, update } from '../controllers/runPlanScheduleController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const runPlanScheduleRoutes = Router();
|
||||
|
||||
// API-first RunPlan automation. The browser only defines schedules; the API
|
||||
// owns recurrence evaluation, dispatch, history, and execution.
|
||||
runPlanScheduleRoutes.get('/', requireAuth, index);
|
||||
runPlanScheduleRoutes.post('/', requireAuth, create);
|
||||
runPlanScheduleRoutes.post('/preview', requireAuth, preview);
|
||||
runPlanScheduleRoutes.put('/:id', requireAuth, update);
|
||||
runPlanScheduleRoutes.delete('/:id', requireAuth, destroy);
|
||||
runPlanScheduleRoutes.post('/:id/toggle', requireAuth, toggle);
|
||||
runPlanScheduleRoutes.post('/:id/run-now', requireAuth, runNow);
|
||||
307
server/services/jobOutputService.js
Normal file
307
server/services/jobOutputService.js
Normal file
@@ -0,0 +1,307 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { config } from '../config.js';
|
||||
import { db, id, json, now } from '../db.js';
|
||||
import { path, sanitizeFileName } from '../utils/pathUtils.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const terminalStatuses = new Set(['completed', 'failed', 'canceled']);
|
||||
|
||||
function stripAnsi(value = '') {
|
||||
return String(value).replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
|
||||
}
|
||||
|
||||
function artifactDir(jobId) {
|
||||
return path.join(config.fileLockerDir, 'job-output', sanitizeFileName(jobId, 'job'));
|
||||
}
|
||||
|
||||
function checksumBuffer(buffer) {
|
||||
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
function safeArtifactName(job, suffix) {
|
||||
const script = sanitizeFileName(job.script_name || 'script', 'script');
|
||||
return sanitizeFileName(`${job.id}-${script}-${suffix}`, `${job.id}-${suffix}`);
|
||||
}
|
||||
|
||||
function getJobRecord(jobId) {
|
||||
return db.prepare(`
|
||||
SELECT j.*, r.name AS runplan_name, s.name AS script_name
|
||||
FROM jobs j
|
||||
LEFT JOIN runplans r ON r.id = j.runplan_id
|
||||
LEFT JOIN scripts s ON s.id = j.script_id
|
||||
WHERE j.id = ?
|
||||
`).get(jobId);
|
||||
}
|
||||
|
||||
function getHostRows(jobId) {
|
||||
return db.prepare(`
|
||||
SELECT jh.*, h.name AS host_name, h.address AS host_address
|
||||
FROM job_hosts jh
|
||||
LEFT JOIN hosts h ON h.id = jh.host_id
|
||||
WHERE jh.job_id = ?
|
||||
ORDER BY COALESCE(h.name, jh.host_id, jh.id)
|
||||
`).all(jobId);
|
||||
}
|
||||
|
||||
function getLogs(jobId) {
|
||||
return db.prepare(`
|
||||
SELECT jl.*, h.name AS host_name
|
||||
FROM job_logs jl
|
||||
LEFT JOIN hosts h ON h.id = jl.host_id
|
||||
WHERE jl.job_id = ?
|
||||
ORDER BY jl.created_at, jl.id
|
||||
`).all(jobId);
|
||||
}
|
||||
|
||||
function stdoutByHost(hosts, logs) {
|
||||
const buckets = new Map(hosts.map((host) => [host.host_id || host.id, {
|
||||
hostId: host.host_id || '',
|
||||
hostName: host.host_name || host.host_id || 'Unassigned host',
|
||||
hostAddress: host.host_address || '',
|
||||
status: host.status,
|
||||
exitCode: host.exit_code,
|
||||
lines: []
|
||||
}]));
|
||||
for (const log of logs.filter((entry) => entry.stream === 'stdout')) {
|
||||
const key = log.host_id || 'job';
|
||||
if (!buckets.has(key)) {
|
||||
buckets.set(key, {
|
||||
hostId: log.host_id || '',
|
||||
hostName: log.host_name || 'Job',
|
||||
hostAddress: '',
|
||||
status: '',
|
||||
exitCode: null,
|
||||
lines: []
|
||||
});
|
||||
}
|
||||
buckets.get(key).lines.push(stripAnsi(log.line));
|
||||
}
|
||||
return [...buckets.values()];
|
||||
}
|
||||
|
||||
function terminalTranscript(logs) {
|
||||
return logs.map((log) => {
|
||||
const host = log.host_name || log.host_id || 'system';
|
||||
return `[${log.created_at}] [${String(log.stream || '').toUpperCase()}] [${host}] ${stripAnsi(log.line)}`;
|
||||
}).join('\n') + '\n';
|
||||
}
|
||||
|
||||
function jsonRows(lines) {
|
||||
const text = lines.join('\n').trim();
|
||||
if (!text) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
||||
if (rows.every((row) => row && typeof row === 'object' && !Array.isArray(row))) return { rows, parser: 'json' };
|
||||
} catch {
|
||||
// Try line-delimited JSON next.
|
||||
}
|
||||
const parsedLines = [];
|
||||
for (const line of lines.map((item) => item.trim()).filter(Boolean)) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
parsedLines.push(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parsedLines.length ? { rows: parsedLines, parser: 'json-lines' } : null;
|
||||
}
|
||||
|
||||
function splitDelimited(line, delimiter) {
|
||||
if (delimiter === '\t') return line.split('\t').map((part) => part.trim());
|
||||
return line.split(delimiter).map((part) => part.trim());
|
||||
}
|
||||
|
||||
function delimitedRows(lines) {
|
||||
const clean = lines.map((line) => line.trim()).filter(Boolean);
|
||||
if (clean.length < 2) return null;
|
||||
const delimiter = ['\t', ',', '|'].find((candidate) => splitDelimited(clean[0], candidate).length > 1);
|
||||
if (!delimiter) return null;
|
||||
const headers = splitDelimited(clean[0], delimiter).filter(Boolean);
|
||||
if (headers.length < 2) return null;
|
||||
const rows = clean.slice(1).map((line) => {
|
||||
const values = splitDelimited(line, delimiter);
|
||||
if (values.length < 2) return null;
|
||||
return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']));
|
||||
}).filter(Boolean);
|
||||
return rows.length ? { rows, parser: delimiter === '\t' ? 'tsv' : delimiter === ',' ? 'csv' : 'pipe-delimited' } : null;
|
||||
}
|
||||
|
||||
function powershellTableRows(lines) {
|
||||
const clean = lines.map((line) => line.replace(/\s+$/g, '')).filter((line) => line.trim());
|
||||
if (clean.length < 3) return null;
|
||||
const separatorIndex = clean.findIndex((line, index) => index > 0 && /^[-\s]+$/.test(line) && line.trim().includes('-'));
|
||||
if (separatorIndex < 1) return null;
|
||||
const headers = clean[separatorIndex - 1].trim().split(/\s{2,}/).filter(Boolean);
|
||||
if (headers.length < 2) return null;
|
||||
const rows = clean.slice(separatorIndex + 1).map((line) => {
|
||||
const values = line.trim().split(/\s{2,}/);
|
||||
if (!values.length) return null;
|
||||
return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']));
|
||||
}).filter(Boolean);
|
||||
return rows.length ? { rows, parser: 'powershell-table' } : null;
|
||||
}
|
||||
|
||||
function powershellObjectRows(lines) {
|
||||
const clean = lines.map((line) => line.trim()).filter(Boolean);
|
||||
if (!clean.length || !clean.every((line) => /^[^:]+:\s*.*$/.test(line))) return null;
|
||||
const row = {};
|
||||
for (const line of clean) {
|
||||
const index = line.indexOf(':');
|
||||
const key = line.slice(0, index).trim();
|
||||
if (!key) return null;
|
||||
row[key] = line.slice(index + 1).trim();
|
||||
}
|
||||
return Object.keys(row).length > 1 ? { rows: [row], parser: 'powershell-object' } : null;
|
||||
}
|
||||
|
||||
function normalizeCellValue(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function parseHostRows(host) {
|
||||
const parsed = jsonRows(host.lines) || delimitedRows(host.lines) || powershellTableRows(host.lines) || powershellObjectRows(host.lines);
|
||||
if (parsed) {
|
||||
return {
|
||||
parser: parsed.parser,
|
||||
rows: parsed.rows.map((row) => ({
|
||||
Host: host.hostName,
|
||||
HostStatus: host.status || '',
|
||||
...Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCellValue(value)]))
|
||||
}))
|
||||
};
|
||||
}
|
||||
const lastLine = [...host.lines].reverse().find((line) => line.trim()) || '';
|
||||
return {
|
||||
parser: 'stdout-last-line',
|
||||
rows: [{
|
||||
Host: host.hostName,
|
||||
HostStatus: host.status || '',
|
||||
Output: lastLine
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
function tablePayload(job, hosts) {
|
||||
const parsedHosts = hosts.map(parseHostRows);
|
||||
const rows = parsedHosts.flatMap((entry) => entry.rows).map((row, index) => ({ id: `row-${index + 1}`, ...row }));
|
||||
const keys = [...new Set(rows.flatMap((row) => Object.keys(row).filter((key) => key !== 'id')))];
|
||||
const preferred = ['Host', 'HostStatus', 'Output'];
|
||||
const ordered = [...preferred.filter((key) => keys.includes(key)), ...keys.filter((key) => !preferred.includes(key))];
|
||||
return {
|
||||
jobId: job.id,
|
||||
runplanName: job.runplan_name || '',
|
||||
scriptName: job.script_name || '',
|
||||
generatedAt: now(),
|
||||
parsers: [...new Set(parsedHosts.map((entry) => entry.parser))],
|
||||
columns: ordered.map((key) => ({ key, label: key })),
|
||||
rows
|
||||
};
|
||||
}
|
||||
|
||||
async function pdfBuffer(job, hosts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
const doc = new PDFDocument({ autoFirstPage: false, margins: { top: 54, bottom: 48, left: 48, right: 48 } });
|
||||
doc.on('data', (chunk) => chunks.push(chunk));
|
||||
doc.on('error', reject);
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
|
||||
doc.addPage();
|
||||
doc.font('Helvetica-Bold').fontSize(18).fillColor('#101827').text('POSHManager Job STDOUT Report');
|
||||
doc.moveDown(0.35);
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#45556c')
|
||||
.text(`Job: ${job.id}`)
|
||||
.text(`Script: ${job.script_name || 'Unknown script'}`)
|
||||
.text(`RunPlan: ${job.runplan_name || 'Script test / ad hoc run'}`)
|
||||
.text(`Status: ${job.status}`)
|
||||
.text(`Generated: ${new Date().toISOString()}`);
|
||||
doc.moveDown(1);
|
||||
|
||||
for (const host of hosts) {
|
||||
if (doc.y > 680) doc.addPage();
|
||||
doc.font('Helvetica-Bold').fontSize(12).fillColor('#101827').text(host.hostName || 'Host');
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#64748b')
|
||||
.text(`Address: ${host.hostAddress || '-'} Status: ${host.status || '-'} Exit code: ${host.exitCode ?? '-'}`);
|
||||
doc.moveDown(0.4);
|
||||
const lines = host.lines.length ? host.lines : ['(No STDOUT captured for this host.)'];
|
||||
doc.font('Courier').fontSize(8).fillColor('#111827');
|
||||
for (const line of lines) {
|
||||
if (doc.y > 730) {
|
||||
doc.addPage();
|
||||
doc.font('Courier').fontSize(8).fillColor('#111827');
|
||||
}
|
||||
doc.text(line || ' ', { width: 500 });
|
||||
}
|
||||
doc.moveDown(0.9);
|
||||
}
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
|
||||
function writeArtifact(job, kind, name, mimeType, content, metadata) {
|
||||
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8');
|
||||
const dir = artifactDir(job.id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const storagePath = path.join(dir, sanitizeFileName(name));
|
||||
fs.writeFileSync(storagePath, buffer);
|
||||
const checksum = checksumBuffer(buffer);
|
||||
const timestamp = now();
|
||||
const outputId = db.prepare('SELECT id FROM job_outputs WHERE job_id = ? AND kind = ?').get(job.id, kind)?.id || id('jout');
|
||||
db.prepare(`
|
||||
INSERT INTO job_outputs (id, job_id, kind, name, mime_type, storage_path, size_bytes, checksum, metadata_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(job_id, kind) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
mime_type = excluded.mime_type,
|
||||
storage_path = excluded.storage_path,
|
||||
size_bytes = excluded.size_bytes,
|
||||
checksum = excluded.checksum,
|
||||
metadata_json = excluded.metadata_json,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(outputId, job.id, kind, name, mimeType, storagePath, buffer.length, checksum, json(metadata, {}), timestamp, timestamp);
|
||||
}
|
||||
|
||||
export async function generateJobOutputs(jobId) {
|
||||
const job = getJobRecord(jobId);
|
||||
if (!job || !terminalStatuses.has(job.status)) return [];
|
||||
const hosts = stdoutByHost(getHostRows(jobId), getLogs(jobId));
|
||||
const logs = getLogs(jobId);
|
||||
const table = tablePayload(job, hosts);
|
||||
const pdf = await pdfBuffer(job, hosts);
|
||||
const stdoutLineCount = hosts.reduce((sum, host) => sum + host.lines.length, 0);
|
||||
const metadata = {
|
||||
hostCount: hosts.length,
|
||||
stdoutLineCount,
|
||||
generatedAt: now()
|
||||
};
|
||||
|
||||
writeArtifact(job, 'stdout-pdf', safeArtifactName(job, 'stdout.pdf'), 'application/pdf', pdf, metadata);
|
||||
writeArtifact(job, 'datatable', safeArtifactName(job, 'datatable.json'), 'application/json', JSON.stringify(table, null, 2), {
|
||||
...metadata,
|
||||
rowCount: table.rows.length,
|
||||
columns: table.columns.map((column) => column.key),
|
||||
parsers: table.parsers
|
||||
});
|
||||
writeArtifact(job, 'terminal', safeArtifactName(job, 'terminal.log'), 'text/plain', terminalTranscript(logs), {
|
||||
...metadata,
|
||||
logLineCount: logs.length
|
||||
});
|
||||
|
||||
return db.prepare('SELECT * FROM job_outputs WHERE job_id = ? ORDER BY kind').all(jobId);
|
||||
}
|
||||
|
||||
export async function generateJobOutputsSafe(jobId) {
|
||||
try {
|
||||
return await generateJobOutputs(jobId);
|
||||
} catch (error) {
|
||||
logger.error('failed to generate job outputs', { jobId, error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
71
server/services/runPlanScheduleWorker.js
Normal file
71
server/services/runPlanScheduleWorker.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getStringSetting } from '../models/settingsModel.js';
|
||||
import {
|
||||
listDueRunPlanSchedules,
|
||||
recordRunPlanScheduleDispatch
|
||||
} from '../models/runPlanScheduleModel.js';
|
||||
import { executeRunPlan } from './powershellRunner.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
let timer = null;
|
||||
let running = false;
|
||||
|
||||
export function runPlanScheduleIntervalMinutes() {
|
||||
const minutes = Number(getStringSetting('runplan_schedule_interval_minutes', process.env.RUNPLAN_SCHEDULE_INTERVAL_MINUTES || '1'));
|
||||
return Number.isFinite(minutes) ? minutes : 1;
|
||||
}
|
||||
|
||||
export async function dispatchDueRunPlanSchedules({ nowIso = new Date().toISOString(), signal } = {}) {
|
||||
const due = listDueRunPlanSchedules(nowIso);
|
||||
const summary = { evaluated: due.length, dispatched: 0, skipped: 0, errors: 0 };
|
||||
for (const schedule of due) {
|
||||
if (signal?.aborted) {
|
||||
summary.skipped += 1;
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const job = executeRunPlan(schedule.runplanId, schedule.createdBy || schedule.ownerUserId || null);
|
||||
recordRunPlanScheduleDispatch(schedule, {
|
||||
jobId: job.id,
|
||||
plannedFor: schedule.nextRunAt || nowIso,
|
||||
status: 'queued',
|
||||
message: `Scheduled RunPlan ${schedule.runplanName || schedule.runplanId} queued as ${job.id}.`
|
||||
});
|
||||
summary.dispatched += 1;
|
||||
} catch (error) {
|
||||
recordRunPlanScheduleDispatch(schedule, {
|
||||
plannedFor: schedule.nextRunAt || nowIso,
|
||||
status: 'failed',
|
||||
message: error.message
|
||||
});
|
||||
summary.errors += 1;
|
||||
logger.error('runplan schedule dispatch failed', { scheduleId: schedule.id, error: error.message });
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function startRunPlanScheduleWorker() {
|
||||
const minutes = runPlanScheduleIntervalMinutes();
|
||||
if (minutes <= 0) return null;
|
||||
running = true;
|
||||
scheduleNext(minutes);
|
||||
logger.info(`runplan schedule dispatcher scheduled every ${minutes} minute(s)`);
|
||||
return timer;
|
||||
}
|
||||
|
||||
export function stopRunPlanScheduleWorker() {
|
||||
running = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function scheduleNext(minutes) {
|
||||
if (!running || minutes <= 0) return;
|
||||
timer = setTimeout(() => {
|
||||
dispatchDueRunPlanSchedules()
|
||||
.then((summary) => logger.info('runplan schedule dispatch complete', summary))
|
||||
.catch((error) => logger.error('runplan schedule dispatch failed', { error: error.message }))
|
||||
.finally(() => scheduleNext(runPlanScheduleIntervalMinutes()));
|
||||
}, minutes * 60 * 1000);
|
||||
timer.unref?.();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { listSystemJobActions, recordSystemJobAction } from '../models/systemJob
|
||||
import { syncDynamicHostGroups } from '../models/hostGroupModel.js';
|
||||
import { runCatalogChecks } from './catalogWorker.js';
|
||||
import { runAutoPromotions } from './promotionWorker.js';
|
||||
import { dispatchDueRunPlanSchedules } from './runPlanScheduleWorker.js';
|
||||
import { cancelExecutionJob, appendJobLog, activeExecutionJobIds } from './powershellRunner.js';
|
||||
|
||||
const backgroundRuns = new Map();
|
||||
@@ -29,6 +30,13 @@ const BACKGROUND_JOBS = [
|
||||
category: 'Intune',
|
||||
description: 'Promotes deployment rings when configured soak windows elapse.',
|
||||
run: async ({ signal } = {}) => runAutoPromotions({ signal })
|
||||
},
|
||||
{
|
||||
id: 'worker:runplan-schedule-dispatch',
|
||||
name: 'RunPlan Schedule Dispatch',
|
||||
category: 'Scheduling',
|
||||
description: 'Queues due automated RunPlan schedules and records dispatch history.',
|
||||
run: async ({ signal } = {}) => dispatchDueRunPlanSchedules({ signal })
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -784,6 +784,35 @@ function soapRefElement(name, ref, fallbackType) {
|
||||
return `<${name} type="${xmlEscape(ref?.type || fallbackType)}">${xmlEscape(ref?.value || '')}</${name}>`;
|
||||
}
|
||||
|
||||
function soapBoolElement(name, value) {
|
||||
return `<${name}>${value ? 'true' : 'false'}</${name}>`;
|
||||
}
|
||||
|
||||
function propertySpecXml(type, paths = []) {
|
||||
return `<propSet>` +
|
||||
`<type>${xmlEscape(type)}</type>` +
|
||||
soapBoolElement('all', false) +
|
||||
paths.map((pathSet) => `<pathSet>${xmlEscape(pathSet)}</pathSet>`).join('') +
|
||||
`</propSet>`;
|
||||
}
|
||||
|
||||
function objectSpecXml(ref, { skip = false, selectSet = '' } = {}) {
|
||||
return `<objectSet>` +
|
||||
soapRefElement('obj', ref, ref?.type || 'ManagedObjectReference') +
|
||||
soapBoolElement('skip', skip) +
|
||||
selectSet +
|
||||
`</objectSet>`;
|
||||
}
|
||||
|
||||
function traversalSpecXml() {
|
||||
return `<selectSet xsi:type="TraversalSpec">` +
|
||||
`<name>containerViewTraversal</name>` +
|
||||
`<type>ContainerView</type>` +
|
||||
`<path>view</path>` +
|
||||
soapBoolElement('skip', false) +
|
||||
`</selectSet>`;
|
||||
}
|
||||
|
||||
async function connectStandaloneSoap(settings, trace = null) {
|
||||
pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, {
|
||||
mode: 'web-services',
|
||||
@@ -826,7 +855,7 @@ async function createVmContainerView(settings, refs, trace = null) {
|
||||
return extractManagedObject(response, 'returnval', 'ContainerView');
|
||||
}
|
||||
|
||||
function retrievePropertiesBody(propertyCollector, viewRef, limit = 100) {
|
||||
export function retrievePropertiesBody(propertyCollector, viewRef, limit = 100) {
|
||||
const paths = [
|
||||
'name',
|
||||
'guest.hostName',
|
||||
@@ -841,12 +870,10 @@ function retrievePropertiesBody(propertyCollector, viewRef, limit = 100) {
|
||||
'summary.runtime.powerState'
|
||||
];
|
||||
return `<RetrievePropertiesEx xmlns="urn:vim25" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
|
||||
`${soapRefElement('_this', propertyCollector, 'PropertyCollector')}` +
|
||||
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>` +
|
||||
propertySpecXml('VirtualMachine', paths) +
|
||||
objectSpecXml(viewRef, { skip: true, selectSet: traversalSpecXml() }) +
|
||||
`</specSet>` +
|
||||
`<options><maxObjects>${Math.max(1, Math.min(Number(limit) || 100, 500))}</maxObjects></options>` +
|
||||
`</RetrievePropertiesEx>`;
|
||||
@@ -888,9 +915,11 @@ 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>` +
|
||||
soapRefElement('_this', refs.propertyCollector, 'PropertyCollector') +
|
||||
`<specSet>` +
|
||||
propertySpecXml('VirtualMachine', ['runtime.powerState']) +
|
||||
objectSpecXml(vmRef, { skip: false }) +
|
||||
`</specSet>` +
|
||||
`<options><maxObjects>1</maxObjects></options></RetrievePropertiesEx>`;
|
||||
const response = await vSphereSoapFetch(settings, 'RetrievePropertiesEx', body, trace, { cookie: refs.sessionCookie, endpoint: refs.endpoint });
|
||||
const object = parseVmPropertyObjects(response)[0];
|
||||
|
||||
@@ -33,9 +33,10 @@ export function settingDefinitions() {
|
||||
{ key: 'entra_client_id', value: config.entra.clientId, pinned: envProvided('ENTRA_CLIENT_ID') },
|
||||
{ key: 'entra_callback_url', value: config.entra.callbackUrl, pinned: envProvided('ENTRA_CALLBACK_URL') },
|
||||
{ key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') },
|
||||
{ key: 'powershell_bin', value: config.powershellBin, pinned: envProvided('POWERSHELL_BIN') },
|
||||
{ key: 'powershell_bin', value: config.powershellBin, pinned: false },
|
||||
{ key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') },
|
||||
{ key: 'host_group_sync_interval_minutes', value: process.env.HOST_GROUP_SYNC_INTERVAL_MINUTES || '60', pinned: envProvided('HOST_GROUP_SYNC_INTERVAL_MINUTES') },
|
||||
{ key: 'runplan_schedule_interval_minutes', value: process.env.RUNPLAN_SCHEDULE_INTERVAL_MINUTES || '1', pinned: envProvided('RUNPLAN_SCHEDULE_INTERVAL_MINUTES') },
|
||||
{ key: 'vcenter_enabled', value: String(config.vcenter.enabled), pinned: envProvided('VCENTER_ENABLED') },
|
||||
{ key: 'vcenter_base_url', value: config.vcenter.baseUrl, pinned: envProvided('VCENTER_BASE_URL') },
|
||||
{ key: 'vcenter_username', value: config.vcenter.username, pinned: envProvided('VCENTER_USERNAME') },
|
||||
|
||||
159
server/test/job-output.test.mjs
Normal file
159
server/test/job-output.test.mjs
Normal file
@@ -0,0 +1,159 @@
|
||||
import { db, load, makeUser } from './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const { createHost } = await load('../models/hostModel.js');
|
||||
const { createCredential } = await load('../models/credentialModel.js');
|
||||
const { updateSettings } = await load('../models/settingsModel.js');
|
||||
const { generateJobOutputs } = await load('../services/jobOutputService.js');
|
||||
const { getJobOutputFile, listJobOutputs, listJobOutputsForJob } = await load('../models/jobOutputModel.js');
|
||||
const { executeScriptTest } = await load('../services/powershellRunner.js');
|
||||
|
||||
const owner = makeUser({ email: 'job-output-owner@test.local' });
|
||||
const stranger = makeUser({ email: 'job-output-stranger@test.local' });
|
||||
const ts = new Date().toISOString();
|
||||
|
||||
function addLog(jobId, hostId, stream, line, offset = 0) {
|
||||
db.prepare(`
|
||||
INSERT INTO job_logs (id, job_id, host_id, stream, line, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(`log_output_${jobId}_${hostId}_${stream}_${offset}`, jobId, hostId, stream, line, new Date(Date.parse(ts) + offset).toISOString());
|
||||
}
|
||||
|
||||
test('job output generation writes stdout PDF, datatable JSON, and terminal transcript artifacts', async () => {
|
||||
const scriptId = 'scr_job_output_test';
|
||||
const jobId = 'job_output_test';
|
||||
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
|
||||
VALUES (?, 'Inventory Table.ps1', 'Get-Process', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts);
|
||||
const hostA = createHost({ name: 'Output Host A', address: 'host-a.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
|
||||
const hostB = createHost({ name: 'Output Host B', address: 'host-b.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
|
||||
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, ended_at, created_at)
|
||||
VALUES (?, NULL, ?, 'completed', ?, ?, ?, ?)`).run(jobId, scriptId, owner, ts, ts, ts);
|
||||
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
|
||||
VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_output_a', jobId, hostA.id, ts, ts);
|
||||
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
|
||||
VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_output_b', jobId, hostB.id, ts, ts);
|
||||
addLog(jobId, hostA.id, 'stdout', 'Name,Status', 1);
|
||||
addLog(jobId, hostA.id, 'stdout', 'Spooler,Running', 2);
|
||||
addLog(jobId, hostB.id, 'stdout', 'Name,Status', 3);
|
||||
addLog(jobId, hostB.id, 'stdout', 'WinRM,Running', 4);
|
||||
addLog(jobId, hostB.id, 'stderr', 'Ignored by stdout PDF', 5);
|
||||
|
||||
await generateJobOutputs(jobId);
|
||||
const outputs = listJobOutputsForJob(jobId, owner, false);
|
||||
const kinds = outputs.map((output) => output.kind).sort();
|
||||
|
||||
assert.deepEqual(kinds, ['datatable', 'stdout-pdf', 'terminal']);
|
||||
assert.equal(listJobOutputs(stranger, false).some((output) => output.jobId === jobId), false);
|
||||
|
||||
const tableOutput = outputs.find((output) => output.kind === 'datatable');
|
||||
const tableFile = getJobOutputFile(tableOutput.id, owner, false);
|
||||
const table = JSON.parse(fs.readFileSync(tableFile.filePath, 'utf8'));
|
||||
assert.deepEqual(table.columns.map((column) => column.key), ['Host', 'HostStatus', 'Name', 'Status']);
|
||||
assert.equal(table.rows.length, 2);
|
||||
assert.ok(table.rows.some((row) => row.Host === 'Output Host A' && row.HostStatus === 'completed' && row.Name === 'Spooler' && row.Status === 'Running'));
|
||||
assert.ok(table.rows.some((row) => row.Host === 'Output Host B' && row.HostStatus === 'completed' && row.Name === 'WinRM' && row.Status === 'Running'));
|
||||
|
||||
const pdfOutput = outputs.find((output) => output.kind === 'stdout-pdf');
|
||||
const pdfFile = getJobOutputFile(pdfOutput.id, owner, false);
|
||||
assert.equal(pdfOutput.mimeType, 'application/pdf');
|
||||
assert.equal(fs.readFileSync(pdfFile.filePath).subarray(0, 4).toString(), '%PDF');
|
||||
|
||||
const terminalOutput = outputs.find((output) => output.kind === 'terminal');
|
||||
const terminalFile = getJobOutputFile(terminalOutput.id, owner, false);
|
||||
const transcript = fs.readFileSync(terminalFile.filePath, 'utf8');
|
||||
assert.match(transcript, /Ignored by stdout PDF/);
|
||||
});
|
||||
|
||||
test('script test runs generate the same FileLocker job output artifacts', async () => {
|
||||
const scriptOwner = makeUser({ email: 'job-output-script-test@test.local' });
|
||||
const scriptId = 'scr_job_output_script_test';
|
||||
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
|
||||
VALUES (?, 'Script Test Output.ps1', 'Write-Output test', 'personal', ?, 1, ?, ?)`).run(scriptId, scriptOwner, ts, ts);
|
||||
const credential = createCredential({
|
||||
name: 'Output Test Credential',
|
||||
kind: 'username_password',
|
||||
username: 'TEST\\svc',
|
||||
secret: 'secret',
|
||||
visibility: 'personal'
|
||||
}, scriptOwner);
|
||||
const host = createHost({
|
||||
name: 'Output Test Host',
|
||||
address: 'output-test.local',
|
||||
transport: 'local',
|
||||
osFamily: 'linux',
|
||||
credentialId: credential.id,
|
||||
visibility: 'personal'
|
||||
}, scriptOwner);
|
||||
updateSettings({ allow_script_execution: 'false' });
|
||||
|
||||
const job = executeScriptTest(scriptId, { hostId: host.id, credentialId: credential.id }, scriptOwner);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const outputs = listJobOutputsForJob(job.id, scriptOwner, false);
|
||||
const kinds = outputs.map((output) => output.kind).sort();
|
||||
|
||||
assert.deepEqual(kinds, ['datatable', 'stdout-pdf', 'terminal']);
|
||||
updateSettings({ allow_script_execution: 'true' });
|
||||
});
|
||||
|
||||
test('job output datatable uses one last-stdout row per host for unstructured output', async () => {
|
||||
const scriptId = 'scr_job_output_last_line_test';
|
||||
const jobId = 'job_output_last_line_test';
|
||||
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
|
||||
VALUES (?, 'Last Line.ps1', 'Write-Output lines', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts);
|
||||
const hostA = createHost({ name: 'Last Host A', address: 'last-a.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
|
||||
const hostB = createHost({ name: 'Last Host B', address: 'last-b.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
|
||||
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, ended_at, created_at)
|
||||
VALUES (?, NULL, ?, 'completed', ?, ?, ?, ?)`).run(jobId, scriptId, owner, ts, ts, ts);
|
||||
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
|
||||
VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_last_a', jobId, hostA.id, ts, ts);
|
||||
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
|
||||
VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_last_b', jobId, hostB.id, ts, ts);
|
||||
addLog(jobId, hostA.id, 'stdout', 'starting host A', 1);
|
||||
addLog(jobId, hostA.id, 'stdout', 'final host A', 2);
|
||||
addLog(jobId, hostB.id, 'stdout', 'starting host B', 3);
|
||||
addLog(jobId, hostB.id, 'stdout', '', 4);
|
||||
addLog(jobId, hostB.id, 'stdout', 'final host B', 5);
|
||||
|
||||
await generateJobOutputs(jobId);
|
||||
const tableOutput = listJobOutputsForJob(jobId, owner, false).find((output) => output.kind === 'datatable');
|
||||
const tableFile = getJobOutputFile(tableOutput.id, owner, false);
|
||||
const table = JSON.parse(fs.readFileSync(tableFile.filePath, 'utf8'));
|
||||
|
||||
assert.deepEqual(table.parsers, ['stdout-last-line']);
|
||||
assert.deepEqual(table.columns.map((column) => column.key), ['Host', 'HostStatus', 'Output']);
|
||||
assert.equal(table.rows.length, 2);
|
||||
assert.ok(table.rows.some((row) => row.Host === 'Last Host A' && row.Output === 'final host A'));
|
||||
assert.ok(table.rows.some((row) => row.Host === 'Last Host B' && row.Output === 'final host B'));
|
||||
assert.equal(table.rows.some((row) => row.Output === 'starting host A' || row.Output === 'starting host B'), false);
|
||||
});
|
||||
|
||||
test('job output datatable carries fields for structured object and array stdout', async () => {
|
||||
const scriptId = 'scr_job_output_structured_test';
|
||||
const jobId = 'job_output_structured_test';
|
||||
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
|
||||
VALUES (?, 'Structured.ps1', 'ConvertTo-Json', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts);
|
||||
const hostA = createHost({ name: 'Structured Host A', address: 'structured-a.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
|
||||
const hostB = createHost({ name: 'Structured Host B', address: 'structured-b.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
|
||||
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, ended_at, created_at)
|
||||
VALUES (?, NULL, ?, 'completed', ?, ?, ?, ?)`).run(jobId, scriptId, owner, ts, ts, ts);
|
||||
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
|
||||
VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_structured_a', jobId, hostA.id, ts, ts);
|
||||
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
|
||||
VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_structured_b', jobId, hostB.id, ts, ts);
|
||||
addLog(jobId, hostA.id, 'stdout', '{"Name":"SvcA","Status":"Running","Version":1}', 1);
|
||||
addLog(jobId, hostB.id, 'stdout', '[{"Name":"SvcB","Status":"Running"},{"Name":"SvcC","Status":"Stopped"}]', 2);
|
||||
|
||||
await generateJobOutputs(jobId);
|
||||
const tableOutput = listJobOutputsForJob(jobId, owner, false).find((output) => output.kind === 'datatable');
|
||||
const tableFile = getJobOutputFile(tableOutput.id, owner, false);
|
||||
const table = JSON.parse(fs.readFileSync(tableFile.filePath, 'utf8'));
|
||||
|
||||
assert.deepEqual(table.parsers, ['json']);
|
||||
assert.deepEqual(table.columns.map((column) => column.key), ['Host', 'HostStatus', 'Name', 'Status', 'Version']);
|
||||
assert.equal(table.rows.length, 3);
|
||||
assert.ok(table.rows.some((row) => row.Host === 'Structured Host A' && row.Name === 'SvcA' && row.Version === '1'));
|
||||
assert.ok(table.rows.some((row) => row.Host === 'Structured Host B' && row.Name === 'SvcB' && row.Status === 'Running'));
|
||||
assert.ok(table.rows.some((row) => row.Host === 'Structured Host B' && row.Name === 'SvcC' && row.Status === 'Stopped'));
|
||||
});
|
||||
@@ -4,6 +4,10 @@ import assert from 'node:assert/strict';
|
||||
|
||||
const { listJobs, getJob } = await load('../models/jobModel.js');
|
||||
const { cancelSystemJob, listSystemJobs, runSystemJob } = await load('../services/systemJobService.js');
|
||||
const { createCredential } = await load('../models/credentialModel.js');
|
||||
const { createHost } = await load('../models/hostModel.js');
|
||||
const { updateSettings } = await load('../models/settingsModel.js');
|
||||
const { buildWrapper, executeScriptTest } = await load('../services/powershellRunner.js');
|
||||
|
||||
const owner = makeUser({ email: 'job-owner@test.local' });
|
||||
const stranger = makeUser({ email: 'job-stranger@test.local' });
|
||||
@@ -52,6 +56,68 @@ test('admins can see any job', () => {
|
||||
assert.ok(getJob(jobId, admin, true));
|
||||
});
|
||||
|
||||
test('executeScriptTest creates job host rows before runner starts', async () => {
|
||||
const scriptOwner = makeUser({ email: 'script-test-owner@test.local' });
|
||||
const localScriptId = 'scr_execute_test_1';
|
||||
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
|
||||
VALUES (?, 'Script Execute Test', 'Write-Output test', 'personal', ?, 1, ?, ?)`).run(localScriptId, scriptOwner, ts, ts);
|
||||
const credential = createCredential({
|
||||
name: 'Script Test Credential',
|
||||
kind: 'username_password',
|
||||
username: 'TEST\\svc',
|
||||
secret: 'secret',
|
||||
visibility: 'personal'
|
||||
}, scriptOwner);
|
||||
const host = createHost({
|
||||
name: 'Script Test Host',
|
||||
address: 'script-test.local',
|
||||
fqdn: 'script-test.local',
|
||||
osFamily: 'windows',
|
||||
transport: 'winrm',
|
||||
credentialId: credential.id,
|
||||
visibility: 'personal',
|
||||
tags: []
|
||||
}, scriptOwner);
|
||||
updateSettings({ allow_script_execution: 'false' });
|
||||
|
||||
const job = executeScriptTest(localScriptId, { hostId: host.id, credentialId: credential.id }, scriptOwner);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
const jobHost = db.prepare('SELECT * FROM job_hosts WHERE job_id = ? AND host_id = ?').get(job.id, host.id);
|
||||
const errorLog = db.prepare('SELECT * FROM job_logs WHERE job_id = ? AND stream = ? AND line LIKE ?').get(job.id, 'stderr', '%Script execution is disabled%');
|
||||
assert.equal(jobHost.status, 'failed');
|
||||
assert.ok(errorLog);
|
||||
updateSettings({ allow_script_execution: 'true' });
|
||||
});
|
||||
|
||||
test('PowerShell wrappers fail the host when Invoke-Command writes a terminating error', () => {
|
||||
const wrapper = buildWrapper('Write-Output "remote smoke"', {
|
||||
id: 'hst_wrapper_winrm',
|
||||
name: 'Wrapper WinRM',
|
||||
address: 'dallas.pucknet.local',
|
||||
transport: 'winrm'
|
||||
});
|
||||
|
||||
assert.match(wrapper, /\$ErrorActionPreference = "Stop"/);
|
||||
assert.match(wrapper, /Invoke-Command -ComputerName 'dallas\.pucknet\.local'.*-ErrorAction Stop/);
|
||||
assert.match(wrapper, /catch \{/);
|
||||
assert.match(wrapper, /exit 1/);
|
||||
assert.doesNotMatch(wrapper, /\$ErrorActionPreference = "Continue"/);
|
||||
});
|
||||
|
||||
test('local PowerShell wrappers propagate native command failures', () => {
|
||||
const wrapper = buildWrapper('Write-Output "local smoke"', {
|
||||
id: 'hst_wrapper_local',
|
||||
name: 'Wrapper Local',
|
||||
address: 'localhost',
|
||||
transport: 'local'
|
||||
});
|
||||
|
||||
assert.match(wrapper, /\$global:LASTEXITCODE/);
|
||||
assert.match(wrapper, /exit \$global:LASTEXITCODE/);
|
||||
assert.match(wrapper, /exit 0/);
|
||||
});
|
||||
|
||||
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)
|
||||
|
||||
89
server/test/runplan-schedules.test.mjs
Normal file
89
server/test/runplan-schedules.test.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import { db, load, makeUser } from './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { createCredential } = await load('../models/credentialModel.js');
|
||||
const { createHost } = await load('../models/hostModel.js');
|
||||
const { updateSettings } = await load('../models/settingsModel.js');
|
||||
const {
|
||||
computeNextRun,
|
||||
createRunPlanSchedule,
|
||||
listDueRunPlanSchedules,
|
||||
listRunPlanScheduleRuns,
|
||||
previewScheduleOccurrences
|
||||
} = await load('../models/runPlanScheduleModel.js');
|
||||
const { dispatchDueRunPlanSchedules } = await load('../services/runPlanScheduleWorker.js');
|
||||
|
||||
const owner = makeUser({ email: 'schedule-owner@test.local' });
|
||||
const ts = new Date().toISOString();
|
||||
|
||||
function createRunnableRunPlan() {
|
||||
const scriptId = 'scr_schedule_test';
|
||||
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
|
||||
VALUES (?, 'Scheduled Script.ps1', 'Write-Output scheduled', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts);
|
||||
const credential = createCredential({
|
||||
name: 'Schedule Credential',
|
||||
kind: 'username_password',
|
||||
username: 'TEST\\svc',
|
||||
secret: 'secret',
|
||||
visibility: 'personal'
|
||||
}, owner);
|
||||
const host = createHost({
|
||||
name: 'Schedule Host',
|
||||
address: 'schedule.local',
|
||||
transport: 'local',
|
||||
osFamily: 'linux',
|
||||
credentialId: credential.id,
|
||||
visibility: 'personal'
|
||||
}, owner);
|
||||
const runplanId = 'run_schedule_test';
|
||||
db.prepare(`INSERT INTO runplans (id, name, script_id, visibility, owner_user_id, parallel, created_at, updated_at)
|
||||
VALUES (?, 'Scheduled RunPlan', ?, 'personal', ?, 1, ?, ?)`).run(runplanId, scriptId, owner, ts, ts);
|
||||
db.prepare('INSERT INTO runplan_hosts (runplan_id, host_id) VALUES (?, ?)').run(runplanId, host.id);
|
||||
return runplanId;
|
||||
}
|
||||
|
||||
test('recurrence preview covers weekly and interval schedules', () => {
|
||||
const weekly = {
|
||||
scheduleType: 'weekly',
|
||||
startAt: '2026-06-01T14:00:00.000Z',
|
||||
timeOfDay: '09:30',
|
||||
daysOfWeek: [1, 3]
|
||||
};
|
||||
const preview = previewScheduleOccurrences(weekly, 3, new Date('2026-06-02T00:00:00.000Z'));
|
||||
assert.equal(preview.length, 3);
|
||||
assert.ok(preview.every((date) => [1, 3].includes(new Date(date).getDay())));
|
||||
|
||||
const intervalNext = computeNextRun({
|
||||
scheduleType: 'interval',
|
||||
startAt: '2026-06-01T00:00:00.000Z',
|
||||
intervalValue: 6,
|
||||
intervalUnit: 'hours'
|
||||
}, new Date('2026-06-01T07:00:00.000Z'));
|
||||
assert.equal(intervalNext, '2026-06-01T12:00:00.000Z');
|
||||
});
|
||||
|
||||
test('due RunPlan schedules dispatch jobs and record scheduler history', async () => {
|
||||
updateSettings({ allow_script_execution: 'false' });
|
||||
const runplanId = createRunnableRunPlan();
|
||||
const schedule = createRunPlanSchedule({
|
||||
name: 'Every minute schedule',
|
||||
runplanId,
|
||||
scheduleType: 'interval',
|
||||
startAt: '2026-01-01T00:00:00.000Z',
|
||||
intervalValue: 1,
|
||||
intervalUnit: 'minutes',
|
||||
visibility: 'personal'
|
||||
}, owner);
|
||||
db.prepare('UPDATE runplan_schedules SET next_run_at = ? WHERE id = ?').run('2026-01-01T00:02:00.000Z', schedule.id);
|
||||
|
||||
assert.ok(listDueRunPlanSchedules('2026-01-01T00:02:00.000Z').some((row) => row.id === schedule.id));
|
||||
const summary = await dispatchDueRunPlanSchedules({ nowIso: '2026-01-01T00:02:00.000Z' });
|
||||
|
||||
assert.equal(summary.dispatched, 1);
|
||||
const job = db.prepare('SELECT * FROM jobs WHERE runplan_id = ? ORDER BY created_at DESC').get(runplanId);
|
||||
assert.ok(job);
|
||||
const history = listRunPlanScheduleRuns(owner, false, 20);
|
||||
assert.ok(history.some((event) => event.scheduleId === schedule.id && event.jobId === job.id));
|
||||
updateSettings({ allow_script_execution: 'true' });
|
||||
});
|
||||
@@ -2,8 +2,9 @@ import { db, load } from './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { seed } = await load('../db.js');
|
||||
const { getSetting, getBooleanSetting, getStringSetting, updateSettings, effectiveTrustedOrigins } = await load('../models/settingsModel.js');
|
||||
const { resolvePowerShellExecutable, powerShellSpawnFailureLines } = await load('../services/powershellRunner.js');
|
||||
const { resolvePowerShellExecutable, powerShellSignalFailureLines, powerShellSpawnFailureLines } = await load('../services/powershellRunner.js');
|
||||
|
||||
test('unpinned settings seed as editable defaults', () => {
|
||||
// No SERVER_FQDN env var is set in the test environment, so server_fqdn is a
|
||||
@@ -37,6 +38,24 @@ test('powershell_bin config is used by the runner at execution time', () => {
|
||||
assert.equal(resolvePowerShellExecutable(), 'powershell.exe');
|
||||
});
|
||||
|
||||
test('powershell_bin can be edited even when older databases marked it env-sourced', () => {
|
||||
db.prepare('UPDATE settings SET value = ?, source = ? WHERE key = ?').run('pwsh', 'env', 'powershell_bin');
|
||||
const result = updateSettings({ powershell_bin: 'powershell' });
|
||||
|
||||
assert.equal(result.powershell_bin.value, 'powershell');
|
||||
assert.equal(result.powershell_bin.source, 'db');
|
||||
assert.equal(resolvePowerShellExecutable(), 'powershell');
|
||||
});
|
||||
|
||||
test('seed releases older env-sourced powershell_bin rows to the current default', () => {
|
||||
db.prepare('UPDATE settings SET value = ?, source = ? WHERE key = ?').run('old-pwsh', 'env', 'powershell_bin');
|
||||
seed();
|
||||
const row = db.prepare('SELECT value, source FROM settings WHERE key = ?').get('powershell_bin');
|
||||
|
||||
assert.equal(row.value, 'pwsh');
|
||||
assert.equal(row.source, 'default');
|
||||
});
|
||||
|
||||
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"')));
|
||||
@@ -44,6 +63,13 @@ test('PowerShell spawn failures include operator guidance', () => {
|
||||
assert.ok(lines.some((line) => line.includes('powershell.exe')));
|
||||
});
|
||||
|
||||
test('PowerShell signal exits include crash diagnostics', () => {
|
||||
const lines = powerShellSignalFailureLines('SIGABRT', 'pwsh');
|
||||
assert.ok(lines.some((line) => line.includes('terminated by signal SIGABRT')));
|
||||
assert.ok(lines.some((line) => line.includes('PowerShell/.NET process aborted')));
|
||||
assert.ok(lines.some((line) => line.includes('pwsh -NoProfile')));
|
||||
});
|
||||
|
||||
test('effectiveTrustedOrigins merges admin edits with built-in defaults', () => {
|
||||
updateSettings({ trusted_origins: 'https://ops.example.com' });
|
||||
const origins = effectiveTrustedOrigins();
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
normalizeBaseUrl,
|
||||
normalizeVCenterVm,
|
||||
previewVCenterHosts,
|
||||
retrievePropertiesBody,
|
||||
settingsFromVCenterConnection,
|
||||
vmMatchesFilter
|
||||
} = {
|
||||
@@ -155,8 +156,22 @@ test('standalone VMware host connections use the Web Services profile', async ()
|
||||
assert.equal(preview.candidates[0].powerState, 'POWERED_ON');
|
||||
});
|
||||
|
||||
test('standalone VMware RetrievePropertiesEx SOAP closes boolean fields before paths', () => {
|
||||
const body = retrievePropertiesBody(
|
||||
{ type: 'PropertyCollector', value: 'ha-property-collector' },
|
||||
{ type: 'ContainerView', value: 'session-vm-view' },
|
||||
5
|
||||
);
|
||||
|
||||
assert.match(body, /<all>false<\/all><pathSet>name<\/pathSet>/);
|
||||
assert.match(body, /<skip>true<\/skip><selectSet/);
|
||||
assert.match(body, /<skip>false<\/skip><\/selectSet>/);
|
||||
assert.doesNotMatch(body, /<all>false><pathSet/);
|
||||
assert.doesNotMatch(body, /<skip>false><\/selectSet>/);
|
||||
});
|
||||
|
||||
test('standalone VMware VM candidates build managed host payloads', async () => {
|
||||
const { restore } = installStandaloneEsxiSoapStub();
|
||||
const { calls, restore } = installStandaloneEsxiSoapStub();
|
||||
const connection = {
|
||||
id: 'vc_host',
|
||||
name: 'ESXi 01',
|
||||
@@ -187,6 +202,10 @@ test('standalone VMware VM candidates build managed host payloads', async () =>
|
||||
assert.ok(payload.tags.includes('esxi-vm:vim.VirtualMachine:101'));
|
||||
assert.equal(power.checked, true);
|
||||
assert.equal(power.state, 'POWERED_ON');
|
||||
const powerBody = calls.filter((call) => call.body.includes('<RetrievePropertiesEx')).at(-1)?.body || '';
|
||||
assert.match(powerBody, /<all>false<\/all><pathSet>runtime\.powerState<\/pathSet>/);
|
||||
assert.match(powerBody, /<skip>false<\/skip><\/objectSet>/);
|
||||
assert.doesNotMatch(powerBody, /<all>false><pathSet/);
|
||||
});
|
||||
|
||||
test('standalone VMware SOAP retries alternate endpoint paths after /sdk 404', async () => {
|
||||
|
||||
Reference in New Issue
Block a user