Files
poshmanager/server/services/systemJobService.js
2026-06-25 20:57:09 -05:00

266 lines
8.8 KiB
JavaScript

import { db, now } from '../db.js';
import { camelJob } from '../models/mappers.js';
import { listSystemJobActions, recordSystemJobAction } from '../models/systemJobModel.js';
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();
const BACKGROUND_JOBS = [
{
id: 'worker:host-group-sync',
name: 'Dynamic Host Group Sync',
category: 'Inventory',
description: 'Reconciles rule-based Host Groups against the current Host Library.',
run: async ({ signal } = {}) => syncDynamicHostGroups({ signal })
},
{
id: 'worker:catalog-auto-check',
name: 'Application Catalog Auto-Check',
category: 'Applications',
description: 'Checks opted-in application records for newer upstream versions.',
run: async ({ signal } = {}) => runCatalogChecks({ signal })
},
{
id: 'worker:intune-auto-promote',
name: 'Intune Auto-Promotion',
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 })
}
];
export function listSystemJobs() {
return {
jobs: [...executionJobs(), ...backgroundJobs()],
actions: listSystemJobActions(200),
activeExecutionJobIds: activeExecutionJobIds()
};
}
export async function runSystemJob(targetId, actorUserId) {
const definition = BACKGROUND_JOBS.find((job) => job.id === targetId);
if (!definition) {
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'run-now',
status: 'failed',
message: 'System job not found.',
actorUserId
});
return { ok: false, action, error: 'System job not found.' };
}
if (backgroundRuns.get(targetId)?.running) {
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'run-now',
status: 'skipped',
message: `${definition.name} is already running.`,
actorUserId
});
return { ok: false, action, error: `${definition.name} is already running.` };
}
const controller = new AbortController();
backgroundRuns.set(targetId, {
running: true,
cancelRequested: false,
startedAt: now(),
lastError: '',
lastSummary: null,
controller
});
const startAction = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'run-now',
status: 'started',
message: `${definition.name} started by operator.`,
actorUserId
});
try {
const summary = await definition.run({ signal: controller.signal });
const canceled = Boolean(backgroundRuns.get(targetId)?.cancelRequested || controller.signal.aborted);
backgroundRuns.set(targetId, {
running: false,
cancelRequested: false,
startedAt: '',
lastRunAt: now(),
lastError: canceled ? 'Canceled by operator.' : '',
lastSummary: summary
});
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'run-now',
status: canceled ? 'canceled' : 'success',
message: canceled ? `${definition.name} canceled by operator.` : `${definition.name} completed.`,
actorUserId,
metadata: { summary, startActionId: startAction.id }
});
return { ok: !canceled, action, summary, canceled };
} catch (error) {
const canceled = Boolean(backgroundRuns.get(targetId)?.cancelRequested || controller.signal.aborted);
backgroundRuns.set(targetId, {
running: false,
cancelRequested: false,
startedAt: '',
lastRunAt: now(),
lastError: canceled ? 'Canceled by operator.' : error.message,
lastSummary: null
});
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'run-now',
status: canceled ? 'canceled' : 'failed',
message: canceled ? `${definition.name} canceled by operator.` : error.message,
actorUserId,
metadata: { startActionId: startAction.id }
});
return { ok: false, action, error: canceled ? 'Canceled by operator.' : error.message, canceled };
}
}
export function cancelSystemJob(targetId, actorUserId, reason = '') {
if (targetId.startsWith('worker:')) {
const definition = BACKGROUND_JOBS.find((job) => job.id === targetId);
const state = backgroundRuns.get(targetId);
if (!definition || !state?.running) {
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'cancel',
status: 'failed',
message: definition ? `${definition.name} is not running.` : 'System job not found.',
actorUserId,
metadata: { reason }
});
return { ok: false, action, error: action.message };
}
state.cancelRequested = true;
state.controller?.abort?.(new Error(reason || 'Canceled by operator.'));
backgroundRuns.set(targetId, state);
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'cancel',
status: 'success',
message: `Cancel requested for ${definition.name}.`,
actorUserId,
metadata: { reason }
});
return { ok: true, action, result: { canceled: true, targetType: 'background' } };
}
if (!targetId.startsWith('job_')) {
const action = recordSystemJobAction({
targetType: 'system',
targetId,
action: 'cancel',
status: 'failed',
message: 'Only running script execution jobs can be canceled.',
actorUserId,
metadata: { reason }
});
return { ok: false, action, error: 'Only running script execution jobs can be canceled.' };
}
const result = cancelExecutionJob(targetId, { actorUserId, reason });
const status = result.canceled ? 'success' : 'failed';
const message = result.canceled
? `Cancel requested; signaled ${result.signaledProcesses || 0} running process(es).`
: result.reason;
const action = recordSystemJobAction({
jobId: targetId,
targetType: 'execution',
targetId,
action: 'cancel',
status,
message,
actorUserId,
metadata: { reason, result }
});
if (result.canceled) appendJobLog(targetId, 'system', `Job management action logged: cancel (${action.id}).`, null);
return { ok: result.canceled, action, result, error: result.canceled ? null : result.reason };
}
function executionJobs() {
const rows = db.prepare(`
SELECT j.*, r.name AS runplan_name, s.name AS script_name,
COUNT(jh.id) AS host_count,
SUM(CASE WHEN jh.status = 'running' THEN 1 ELSE 0 END) AS running_hosts,
SUM(CASE WHEN jh.status = 'queued' THEN 1 ELSE 0 END) AS queued_hosts
FROM jobs j
LEFT JOIN runplans r ON r.id = j.runplan_id
LEFT JOIN scripts s ON s.id = j.script_id
LEFT JOIN job_hosts jh ON jh.job_id = j.id
GROUP BY j.id
ORDER BY j.created_at DESC
LIMIT 200
`).all();
return rows.map((row) => {
const job = camelJob(row);
const running = ['queued', 'running'].includes(job.status);
return {
id: job.id,
type: 'execution',
category: 'Script Execution',
name: job.runplanName || job.scriptName || job.id,
description: job.runplanName ? `RunPlan ${job.runplanName}` : `Script test ${job.scriptName || job.id}`,
status: job.status,
canCancel: running,
canRunNow: false,
startedAt: job.startedAt || '',
endedAt: job.endedAt || '',
createdAt: job.createdAt,
hostCount: row.host_count || 0,
runningHosts: row.running_hosts || 0,
queuedHosts: row.queued_hosts || 0,
metadata: {
runplanId: job.runplanId,
scriptId: job.scriptId,
triggeredBy: job.triggeredBy
}
};
});
}
function backgroundJobs() {
return BACKGROUND_JOBS.map((job) => {
const state = backgroundRuns.get(job.id) || {};
return {
id: job.id,
type: 'background',
category: job.category,
name: job.name,
description: job.description,
status: state.cancelRequested ? 'canceling' : state.running ? 'running' : 'idle',
canCancel: Boolean(state.running),
canRunNow: !state.running,
startedAt: state.startedAt || '',
endedAt: '',
createdAt: '',
hostCount: 0,
runningHosts: 0,
queuedHosts: 0,
metadata: {
lastRunAt: state.lastRunAt || '',
lastError: state.lastError || '',
lastSummary: state.lastSummary || null
}
};
});
}