bafb085995
- templates proxmox/ (update-analyze: dépôts PVE ; full-upgrade) et raspbian/ (update-analyze: espace disque ; full-upgrade) - execute résout les actions APT par profil OS (resolveTemplate) → proxmox/ raspbian si dispo, sinon fallback apt/ (non-régression debian/ubuntu vérifiée) - machine_probe (lecture seule) : template + parseProbe/proposeCorrections (TDD) → propose os_family/machine_kind/virtualization, persiste machine_hardware, n'applique jamais auto ; branche execute + allowlist route - apt_proxy_persistent : ActionType + template idempotent (/etc/apt/apt.conf.d/ 01proxy, backup) + TemplateVars.aptProxyUrl + allowlist route tsc 0 · 95 tests · build OK · résolution OS vérifiée. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
522 lines
24 KiB
TypeScript
522 lines
24 KiB
TypeScript
// server/services/execute.ts
|
|
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { mkdirSync, writeFileSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { db, schema } from "../db/client.js";
|
|
import { env } from "../env.js";
|
|
import { getMachineRow, getCreds } from "./machines.js";
|
|
import { renderTemplate, resolveTemplate } from "../templates/render.js";
|
|
import { reduceAptLines } from "../templates/aptReduce.js";
|
|
import { runScriptSudo } from "../ssh/client.js";
|
|
import { parseRebootRequired, buildAptExecutionResult } from "./aptParse.js";
|
|
import { parseBootIdBefore, verifyReboot } from "./rebootVerify.js";
|
|
import type { RebootResult } from "@shared/types.js";
|
|
import { extractSection, refreshMachine } from "./refresh.js";
|
|
import { buildReportMarkdown } from "./report.js";
|
|
import { outputHub } from "../ws/outputHub.js";
|
|
import { upsertMachineState, recordEvent } from "./machineState.js";
|
|
import type { ActionType, AptExecutionResult, ExecutionResult, ExecutionStatus } from "@shared/types.js";
|
|
|
|
// Actions APT/système résolues par profil OS (resolveTemplate → proxmox/raspbian si dispo,
|
|
// sinon fallback apt/). La valeur est le basename d'action (sans dossier ni extension).
|
|
const APT_ACTION_FILE: Partial<Record<ActionType, string>> = {
|
|
apt_full_upgrade: "full-upgrade",
|
|
apt_upgrade: "upgrade",
|
|
apt_autoremove: "autoremove",
|
|
apt_clean: "clean",
|
|
reboot: "reboot",
|
|
reboot_verified: "reboot",
|
|
apt_proxy_persistent: "apt-proxy-persistent",
|
|
};
|
|
|
|
export interface RunActionOpts {
|
|
stackId?: string;
|
|
aggressive?: boolean; // docker_prune_images
|
|
}
|
|
|
|
/**
|
|
* Archive une exécution terminée (log brut + rapport + lignes DB + état machine +
|
|
* event) et renvoie l'ExecutionResult. Mutualise le boilerplate des branches Docker.
|
|
*/
|
|
function archiveExecution(args: {
|
|
machineId: string;
|
|
machineName: string;
|
|
executionId: string;
|
|
action: ActionType;
|
|
startedAt: string;
|
|
status: ExecutionStatus;
|
|
raw: string;
|
|
importantLines: string[];
|
|
docker?: ExecutionResult["docker"];
|
|
errors?: ExecutionResult["errors"];
|
|
}): ExecutionResult {
|
|
const { machineId, machineName, executionId, action, startedAt, status, raw, importantLines } = args;
|
|
const finishedAt = new Date().toISOString();
|
|
const dir = join(env.reportsDir, machineId);
|
|
mkdirSync(dir, { recursive: true });
|
|
const rawLogPath = join(dir, `${executionId}.log`);
|
|
const reportPath = join(dir, `${executionId}.md`);
|
|
writeFileSync(rawLogPath, raw || importantLines.join("\n") + "\n", "utf8");
|
|
const result: ExecutionResult = {
|
|
executionId, machineId, startedAt, finishedAt, mode: "manual", action, status,
|
|
rebootRequiredAfterRun: false,
|
|
importantLogLines: importantLines,
|
|
rawLogRef: rawLogPath, reportRef: reportPath,
|
|
...(args.docker ? { docker: args.docker } : {}),
|
|
...(args.errors && args.errors.length ? { errors: args.errors } : {}),
|
|
};
|
|
writeFileSync(reportPath, buildReportMarkdown(result, machineName), "utf8");
|
|
const reportId = randomUUID();
|
|
db.update(schema.executions).set({
|
|
finishedAt, status, schemaVersion: 1,
|
|
resultJson: JSON.stringify(result), importantJson: JSON.stringify(importantLines),
|
|
reportPath, rawLogPath, reportId,
|
|
exitCode: status === "ok" ? 0 : 1,
|
|
errorKind: status === "error" ? "execution_failed" : null,
|
|
errorMessage: status === "error" ? (importantLines.at(-1) ?? null) : null,
|
|
}).where(eq(schema.executions.id, executionId)).run();
|
|
db.update(schema.machines).set({ status: status === "error" ? "error" : "unknown" })
|
|
.where(eq(schema.machines.id, machineId)).run();
|
|
db.insert(schema.reports).values({
|
|
id: reportId, machineId, executionId, kind: "machine",
|
|
title: `${machineName} — ${action}`, path: reportPath, createdAt: finishedAt,
|
|
}).run();
|
|
db.insert(schema.rawArtifacts).values({
|
|
id: randomUUID(), machineId, kind: "raw_log", path: rawLogPath,
|
|
bytes: statSync(rawLogPath).size, createdAt: finishedAt,
|
|
retentionPolicy: status === "error" ? "failed" : "default",
|
|
}).run();
|
|
upsertMachineState(machineId, {
|
|
status: status === "error" ? "error" : "unknown",
|
|
runningJobId: null,
|
|
lastErrorKind: status === "error" ? "execution_failed" : null,
|
|
lastErrorMessage: status === "error" ? (importantLines.at(-1) ?? null) : null,
|
|
});
|
|
recordEvent({
|
|
machineId, eventType: `action_${action}`,
|
|
severity: status === "error" ? "error" : "info",
|
|
executionId, message: `Action ${action} : ${status}`,
|
|
});
|
|
outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`);
|
|
return result;
|
|
}
|
|
|
|
export async function runAction(
|
|
machineId: string,
|
|
action: ActionType,
|
|
opts?: RunActionOpts,
|
|
): Promise<ExecutionResult> {
|
|
const m = getMachineRow(machineId);
|
|
if (!m) throw new Error("Machine introuvable");
|
|
|
|
const executionId = `exec_${Date.now()}_${randomUUID().slice(0, 8)}`;
|
|
const startedAt = new Date().toISOString();
|
|
outputHub.clear(machineId);
|
|
db.update(schema.machines).set({ status: "running" }).where(eq(schema.machines.id, machineId)).run();
|
|
db.insert(schema.executions).values({
|
|
id: executionId, machineId, action, mode: "manual", startedAt, status: "running",
|
|
}).run();
|
|
upsertMachineState(machineId, { status: "running", runningJobId: executionId });
|
|
|
|
// --- SJ-4 : docker_scan délégué au service dédié (évite un double rendu sans racines) ---
|
|
if (action === "docker_scan") {
|
|
const { scanDockerStacks } = await import("./dockerScan.js");
|
|
const startedAtDocker = startedAt;
|
|
let scanStatus: ExecutionStatus = "ok";
|
|
let scanSummaryLines: string[] = [];
|
|
try {
|
|
const parsed = await scanDockerStacks(machineId);
|
|
scanSummaryLines = [
|
|
`docker_scan: ${parsed.stacks.length} stacks trouvées (${parsed.stacks.filter((s) => s.valid).length} valides)`,
|
|
...parsed.stacks.map((s) => ` ${s.valid ? "OK" : "INVALID"} ${s.workingDir}`),
|
|
...parsed.active.map((a) => ` ACTIVE project=${a.project} dir=${a.workingDir}`),
|
|
];
|
|
outputHub.publish(machineId, `\n===SU:DONE status=ok stacks=${parsed.stacks.length}===\n`);
|
|
} catch (err) {
|
|
scanStatus = "error";
|
|
scanSummaryLines = [`[ERREUR] ${(err as Error).message}`];
|
|
outputHub.publish(machineId, `\n[ERREUR] ${(err as Error).message}\n`);
|
|
}
|
|
const finishedAtDocker = new Date().toISOString();
|
|
const rawDocker = scanSummaryLines.join("\n") + "\n";
|
|
const dirDocker = join(env.reportsDir, machineId);
|
|
mkdirSync(dirDocker, { recursive: true });
|
|
const rawLogPathDocker = join(dirDocker, `${executionId}.log`);
|
|
const reportPathDocker = join(dirDocker, `${executionId}.md`);
|
|
writeFileSync(rawLogPathDocker, rawDocker, "utf8");
|
|
const resultDocker: ExecutionResult = {
|
|
executionId, machineId, startedAt: startedAtDocker, finishedAt: finishedAtDocker,
|
|
mode: "manual", action, status: scanStatus,
|
|
rebootRequiredAfterRun: false,
|
|
importantLogLines: scanSummaryLines,
|
|
rawLogRef: rawLogPathDocker, reportRef: reportPathDocker,
|
|
};
|
|
writeFileSync(reportPathDocker, buildReportMarkdown(resultDocker, m.name), "utf8");
|
|
const reportIdDocker = randomUUID();
|
|
db.update(schema.executions).set({
|
|
finishedAt: finishedAtDocker, status: scanStatus, schemaVersion: 1,
|
|
resultJson: JSON.stringify(resultDocker), importantJson: JSON.stringify(scanSummaryLines),
|
|
reportPath: reportPathDocker, rawLogPath: rawLogPathDocker, reportId: reportIdDocker,
|
|
exitCode: scanStatus === "ok" ? 0 : 1,
|
|
errorKind: scanStatus === "error" ? "execution_failed" : null,
|
|
errorMessage: scanStatus === "error" ? (scanSummaryLines.at(-1) ?? null) : null,
|
|
}).where(eq(schema.executions.id, executionId)).run();
|
|
db.update(schema.machines).set({ status: scanStatus === "error" ? "error" : "unknown" })
|
|
.where(eq(schema.machines.id, machineId)).run();
|
|
db.insert(schema.reports).values({
|
|
id: reportIdDocker, machineId, executionId, kind: "machine",
|
|
title: `${m.name} — docker_scan`, path: reportPathDocker, createdAt: finishedAtDocker,
|
|
}).run();
|
|
db.insert(schema.rawArtifacts).values({
|
|
id: randomUUID(), machineId, kind: "raw_log", path: rawLogPathDocker,
|
|
bytes: statSync(rawLogPathDocker).size,
|
|
createdAt: finishedAtDocker,
|
|
retentionPolicy: scanStatus === "error" ? "failed" : "default",
|
|
}).run();
|
|
upsertMachineState(machineId, {
|
|
status: scanStatus === "error" ? "error" : "unknown",
|
|
runningJobId: null,
|
|
lastErrorKind: scanStatus === "error" ? "execution_failed" : null,
|
|
lastErrorMessage: scanStatus === "error" ? (scanSummaryLines.at(-1) ?? null) : null,
|
|
});
|
|
recordEvent({
|
|
machineId, eventType: "action_docker_scan",
|
|
severity: scanStatus === "error" ? "error" : "info",
|
|
executionId, message: `Action docker_scan : ${scanStatus}`,
|
|
});
|
|
return resultDocker;
|
|
}
|
|
|
|
// --- SJ-5 : docker_pull_check délégué au service dédié (pull + comparaison + persistance) ---
|
|
if (action === "docker_pull_check") {
|
|
if (!opts?.stackId) throw new Error("docker_pull_check requiert un stackId");
|
|
const { pullCheckStack, dockerDedupKey } = await import("./dockerPull.js");
|
|
let rawPull = "";
|
|
let pullStatus: ExecutionStatus = "ok";
|
|
let importantPull: string[] = [];
|
|
let dockerExec: ExecutionResult["docker"];
|
|
try {
|
|
const outcome = await pullCheckStack(machineId, opts.stackId, (c) => {
|
|
rawPull += c;
|
|
});
|
|
rawPull = outcome.raw;
|
|
const r = outcome.result;
|
|
pullStatus = r.status === "error" ? "error" : r.status === "warning" ? "warning" : "ok";
|
|
const changes = r.changes.map((ch) => ({
|
|
...ch,
|
|
dedupKey: dockerDedupKey(ch.imageRef ?? ch.stack, ch.fromDigest ?? null, ch.toDigest ?? null, ch.fromImageId ?? null, ch.toImageId ?? null),
|
|
}));
|
|
dockerExec = { pull: { changes, ...(r.errors.length ? { errors: r.errors } : {}) } };
|
|
importantPull = [
|
|
`docker_pull_check ${outcome.stackName} : ${r.changes.length} image(s) mise(s) à jour (${r.services.length} service(s))`,
|
|
...r.services.map((s) => ` ${s.status} ${s.image}`),
|
|
...r.errors.map((e) => ` [${e.kind}] ${e.message}`),
|
|
];
|
|
outputHub.publish(machineId, `\n===SU:DONE status=${pullStatus} changes=${r.changes.length}===\n`);
|
|
} catch (err) {
|
|
pullStatus = "error";
|
|
importantPull = [`[ERREUR] ${(err as Error).message}`];
|
|
rawPull += `\n[ERREUR] ${(err as Error).message}\n`;
|
|
outputHub.publish(machineId, `\n[ERREUR] ${(err as Error).message}\n`);
|
|
}
|
|
const finishedAtPull = new Date().toISOString();
|
|
const dirPull = join(env.reportsDir, machineId);
|
|
mkdirSync(dirPull, { recursive: true });
|
|
const rawLogPathPull = join(dirPull, `${executionId}.log`);
|
|
const reportPathPull = join(dirPull, `${executionId}.md`);
|
|
writeFileSync(rawLogPathPull, rawPull || importantPull.join("\n") + "\n", "utf8");
|
|
const resultPull: ExecutionResult = {
|
|
executionId, machineId, startedAt, finishedAt: finishedAtPull,
|
|
mode: "manual", action, status: pullStatus,
|
|
rebootRequiredAfterRun: false,
|
|
importantLogLines: importantPull,
|
|
rawLogRef: rawLogPathPull, reportRef: reportPathPull,
|
|
...(dockerExec ? { docker: dockerExec } : {}),
|
|
};
|
|
writeFileSync(reportPathPull, buildReportMarkdown(resultPull, m.name), "utf8");
|
|
const reportIdPull = randomUUID();
|
|
db.update(schema.executions).set({
|
|
finishedAt: finishedAtPull, status: pullStatus, schemaVersion: 1,
|
|
resultJson: JSON.stringify(resultPull), importantJson: JSON.stringify(importantPull),
|
|
reportPath: reportPathPull, rawLogPath: rawLogPathPull, reportId: reportIdPull,
|
|
exitCode: pullStatus === "ok" ? 0 : 1,
|
|
errorKind: pullStatus === "error" ? "execution_failed" : null,
|
|
errorMessage: pullStatus === "error" ? (importantPull.at(-1) ?? null) : null,
|
|
}).where(eq(schema.executions.id, executionId)).run();
|
|
db.update(schema.machines).set({ status: pullStatus === "error" ? "error" : "unknown" })
|
|
.where(eq(schema.machines.id, machineId)).run();
|
|
db.insert(schema.reports).values({
|
|
id: reportIdPull, machineId, executionId, kind: "machine",
|
|
title: `${m.name} — docker_pull_check`, path: reportPathPull, createdAt: finishedAtPull,
|
|
}).run();
|
|
db.insert(schema.rawArtifacts).values({
|
|
id: randomUUID(), machineId, kind: "raw_log", path: rawLogPathPull,
|
|
bytes: statSync(rawLogPathPull).size, createdAt: finishedAtPull,
|
|
retentionPolicy: pullStatus === "error" ? "failed" : "default",
|
|
}).run();
|
|
upsertMachineState(machineId, {
|
|
status: pullStatus === "error" ? "error" : "unknown",
|
|
runningJobId: null,
|
|
lastErrorKind: pullStatus === "error" ? "execution_failed" : null,
|
|
lastErrorMessage: pullStatus === "error" ? (importantPull.at(-1) ?? null) : null,
|
|
});
|
|
recordEvent({
|
|
machineId, eventType: "action_docker_pull_check",
|
|
severity: pullStatus === "error" ? "error" : "info",
|
|
executionId, message: `Action docker_pull_check : ${pullStatus}`,
|
|
});
|
|
return resultPull;
|
|
}
|
|
|
|
// --- SJ-6 : actions Docker destructives (apply / prune / down) ---
|
|
if (action === "docker_compose_apply") {
|
|
if (!opts?.stackId) throw new Error("docker_compose_apply requiert un stackId");
|
|
const { applyStack } = await import("./dockerApply.js");
|
|
try {
|
|
const o = await applyStack(machineId, opts.stackId, executionId, (c) => outputHub.publish(machineId, c));
|
|
const p = o.parsed;
|
|
const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok";
|
|
const important = [
|
|
`docker_compose_apply ${o.stackName} : ${p.recreated.length} recréé(s), ${p.running.length} running, ${p.exited.length} exited`,
|
|
...p.recreated.map((n) => ` recreated ${n}`),
|
|
...p.exited.map((n) => ` exited ${n}`),
|
|
...p.errors.map((e) => ` [${e.kind}] ${e.message}`),
|
|
];
|
|
return archiveExecution({
|
|
machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw,
|
|
importantLines: important, docker: { up: { recreated: p.recreated, running: p.running, exited: p.exited, ...(p.errors.length ? { errors: p.errors } : {}) } },
|
|
});
|
|
} catch (err) {
|
|
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
|
|
}
|
|
}
|
|
|
|
if (action === "docker_prune_images") {
|
|
const { pruneImages } = await import("./dockerApply.js");
|
|
try {
|
|
const o = await pruneImages(machineId, executionId, !!opts?.aggressive, (c) => outputHub.publish(machineId, c));
|
|
const p = o.parsed;
|
|
const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok";
|
|
const mb = (p.bytesReclaimed / 1e6).toFixed(1);
|
|
const important = [
|
|
`docker_prune_images (${opts?.aggressive ? "agressif" : "safe"}) : ${p.imagesDeleted.length} image(s), ${mb} Mo récupérés`,
|
|
...p.errors.map((e) => ` [${e.kind}] ${e.message}`),
|
|
];
|
|
return archiveExecution({
|
|
machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw,
|
|
importantLines: important, docker: { prune: { imagesDeleted: p.imagesDeleted, bytesReclaimed: p.bytesReclaimed, ...(p.errors.length ? { errors: p.errors } : {}) } },
|
|
});
|
|
} catch (err) {
|
|
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
|
|
}
|
|
}
|
|
|
|
if (action === "docker_compose_down") {
|
|
if (!opts?.stackId) throw new Error("docker_compose_down requiert un stackId");
|
|
const { downStack } = await import("./dockerApply.js");
|
|
try {
|
|
const o = await downStack(machineId, opts.stackId, (c) => outputHub.publish(machineId, c));
|
|
const p = o.parsed;
|
|
const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok";
|
|
const important = [
|
|
`docker_compose_down ${o.stackName} : ${p.removed.length} conteneur(s) retiré(s)`,
|
|
...p.removed.map((n) => ` removed ${n}`),
|
|
...p.errors.map((e) => ` [${e.kind}] ${e.message}`),
|
|
];
|
|
return archiveExecution({
|
|
machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw,
|
|
importantLines: important, errors: p.errors,
|
|
});
|
|
} catch (err) {
|
|
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
|
|
}
|
|
}
|
|
|
|
// --- SJ-7 : sonde machine (lecture seule) déléguée au service dédié ---
|
|
if (action === "machine_probe") {
|
|
const { runProbe } = await import("./machineProbe.js");
|
|
try {
|
|
const o = await runProbe(machineId, () => {});
|
|
const important = [
|
|
`machine_probe : os=${o.probe.osId ?? "?"} ${o.probe.osVersion ?? ""} arch=${o.probe.arch ?? "?"} virt=${o.probe.virt ?? "?"}`,
|
|
`proposition : os_family=${o.proposal.osFamily} machine_kind=${o.proposal.machineKind} virtualization=${o.proposal.virtualization}`,
|
|
...(o.changes.length ? ["corrections proposées (non appliquées) :", ...o.changes.map((c) => ` ${c}`)] : ["aucune correction proposée"]),
|
|
];
|
|
outputHub.publish(machineId, `\n===SU:DONE status=ok===\n`);
|
|
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "ok", raw: o.raw, importantLines: important });
|
|
} catch (err) {
|
|
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
|
|
}
|
|
}
|
|
|
|
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
|
|
// Résolution du template : Docker inspect = chemin direct ; sinon résolution par profil OS.
|
|
let rel: string;
|
|
if (action === "docker_inspect_current") {
|
|
rel = "docker/inspect-compose.sh.tpl";
|
|
} else {
|
|
const file = APT_ACTION_FILE[action];
|
|
if (!file) throw new Error("Action sans template: " + action);
|
|
rel = resolveTemplate(file, m.osFamily);
|
|
}
|
|
// Docker inspect par-stack : injecter stackDir ; ignoré par les templates APT.
|
|
let stackDir: string | null = null;
|
|
if (opts?.stackId) {
|
|
const st = db.select().from(schema.dockerComposeStacks).where(eq(schema.dockerComposeStacks.id, opts.stackId)).get();
|
|
stackDir = st?.workingDir ?? null;
|
|
}
|
|
// Proxy persistant : l'URL est passée comme variable de template (jamais un secret).
|
|
const aptProxyUrl = action === "apt_proxy_persistent" ? m.aptProxyUrl : null;
|
|
const script = renderTemplate(rel, { aptProxy: proxy, stackDir, aptProxyUrl });
|
|
|
|
const inactivity = action === "reboot" ? 0 : 600000;
|
|
|
|
let raw = "";
|
|
let status: ExecutionStatus = "ok";
|
|
try {
|
|
const res = await runScriptSudo(getCreds(m), script, (c) => {
|
|
raw += c;
|
|
outputHub.publish(machineId, c);
|
|
}, inactivity);
|
|
raw = res.stdout;
|
|
if (/===SU:EXIT=\d+===/.test(raw) && !/===SU:EXIT=0===/.test(raw)) {
|
|
status = "error";
|
|
}
|
|
} catch (err) {
|
|
status = "error";
|
|
raw += `\n[ERREUR] ${(err as Error).message}\n`;
|
|
}
|
|
|
|
// Vérification réseau du reboot (nouvelle action reboot_verified, jalon SJ-3).
|
|
let rebootResult: RebootResult | undefined;
|
|
if (action === "reboot_verified") {
|
|
const beforeBootId = parseBootIdBefore(raw);
|
|
outputHub.publish(machineId, "\n[reboot] attente du redémarrage...\n");
|
|
rebootResult = await verifyReboot(getCreds(m), { beforeBootId, requestedAt: startedAt });
|
|
if (rebootResult.status !== "ok") status = "error";
|
|
if (rebootResult.status === "ok") {
|
|
recordEvent({
|
|
machineId,
|
|
eventType: "reboot_verified",
|
|
severity: "info",
|
|
executionId,
|
|
message: `Reboot vérifié en ${rebootResult.waitedSeconds}s (boot_id changé)`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const finishedAt = new Date().toISOString();
|
|
const rebootRequired = parseRebootRequired(extractSection(raw, "===SU:REBOOT===", "===SU:EXIT") || raw);
|
|
|
|
// Diff dpkg réel (si le template a émis DPKG_BEFORE + DPKG_AFTER).
|
|
let aptResult: AptExecutionResult | undefined;
|
|
if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
|
|
const afterBeforeMarker =
|
|
raw.includes("===SU:APT_FULLUPGRADE===") ? "===SU:APT_FULLUPGRADE===" :
|
|
raw.includes("===SU:APT_UPGRADE===") ? "===SU:APT_UPGRADE===" :
|
|
"===SU:APT_AUTOREMOVE===";
|
|
aptResult = buildAptExecutionResult(
|
|
extractSection(raw, "===SU:DPKG_BEFORE===", afterBeforeMarker),
|
|
extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
|
|
extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
|
|
);
|
|
}
|
|
|
|
// Archivage log brut + rapport.
|
|
const dir = join(env.reportsDir, machineId);
|
|
mkdirSync(dir, { recursive: true });
|
|
const rawLogPath = join(dir, `${executionId}.log`);
|
|
const reportPath = join(dir, `${executionId}.md`);
|
|
writeFileSync(rawLogPath, raw, "utf8");
|
|
|
|
const result: ExecutionResult = {
|
|
executionId, machineId, startedAt, finishedAt, mode: "manual", action, status,
|
|
rebootRequiredAfterRun: rebootRequired,
|
|
importantLogLines: reduceAptLines(raw),
|
|
rawLogRef: rawLogPath, reportRef: reportPath,
|
|
...(aptResult ? { apt: aptResult } : {}),
|
|
...(rebootResult ? { reboot: rebootResult } : {}),
|
|
};
|
|
writeFileSync(reportPath, buildReportMarkdown(result, m.name), "utf8");
|
|
|
|
const reportId = randomUUID();
|
|
const exitMatch = /===SU:EXIT=(\d+)===/.exec(raw);
|
|
db.update(schema.executions).set({
|
|
finishedAt,
|
|
status,
|
|
schemaVersion: 1,
|
|
resultJson: JSON.stringify(result),
|
|
importantJson: JSON.stringify(result.importantLogLines),
|
|
reportPath,
|
|
rawLogPath,
|
|
reportId,
|
|
exitCode: exitMatch ? Number(exitMatch[1]) : null,
|
|
errorKind: status === "error" ? "execution_failed" : null,
|
|
errorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
|
|
}).where(eq(schema.executions.id, executionId)).run();
|
|
db.update(schema.machines).set({ status: status === "error" ? "error" : "unknown" })
|
|
.where(eq(schema.machines.id, machineId)).run();
|
|
|
|
db.insert(schema.reports).values({
|
|
id: reportId,
|
|
machineId,
|
|
executionId,
|
|
kind: "machine",
|
|
title: `${m.name} — ${action}`,
|
|
path: reportPath,
|
|
createdAt: finishedAt,
|
|
}).run();
|
|
|
|
db.insert(schema.rawArtifacts).values({
|
|
id: randomUUID(),
|
|
machineId,
|
|
kind: "raw_log",
|
|
path: rawLogPath,
|
|
bytes: statSync(rawLogPath).size,
|
|
createdAt: finishedAt,
|
|
retentionPolicy: status === "error" ? "failed" : "default",
|
|
}).run();
|
|
|
|
upsertMachineState(machineId, {
|
|
status: status === "error" ? "error" : "unknown",
|
|
runningJobId: null,
|
|
lastErrorKind: status === "error" ? "execution_failed" : null,
|
|
lastErrorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
|
|
});
|
|
|
|
const execSeverity: "info" | "warning" | "error" =
|
|
status === "error" ? "error" : (status as string) === "warning" ? "warning" : "info";
|
|
recordEvent({
|
|
machineId,
|
|
eventType: `action_${action}`,
|
|
severity: execSeverity,
|
|
executionId,
|
|
message: `Action ${action} : ${status}`,
|
|
});
|
|
|
|
outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`);
|
|
|
|
// Après une action APT qui modifie l'état des paquets, régénérer le snapshot
|
|
// pour que la webUI reflète les mises à jour restantes (retour amelioration.md #3).
|
|
const REFRESH_AFTER: ActionType[] = ["apt_full_upgrade", "apt_upgrade", "apt_dist_upgrade", "apt_autoremove"];
|
|
if (status !== "error" && REFRESH_AFTER.includes(action)) {
|
|
try {
|
|
await refreshMachine(machineId);
|
|
} catch (err) {
|
|
// Refresh best-effort : ne pas faire échouer l'action si la ré-analyse échoue.
|
|
recordEvent({ machineId, eventType: "post_action_refresh_failed", severity: "warning", executionId,
|
|
message: `Refresh post-${action} échoué : ${(err as Error).message}` });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function listExecutions(machineId: string) {
|
|
return db.select().from(schema.executions).where(eq(schema.executions.machineId, machineId)).all();
|
|
}
|
|
|
|
export function getExecution(executionId: string) {
|
|
return db.select().from(schema.executions).where(eq(schema.executions.id, executionId)).get();
|
|
}
|