308 lines
11 KiB
JavaScript
308 lines
11 KiB
JavaScript
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 [];
|
|
}
|
|
}
|