Files
system_update/server/services/execute.ts
T
gilles 434a149f1f fix(execute): refresh snapshot après apt upgrade/full-upgrade (amelioration #3)
Après une action APT appliquée avec succès, relance refreshMachine pour
que la webui reflète l'état réel des paquets. Échec de refresh = event
warning non bloquant (post_action_refresh_failed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:54:38 +02:00

269 lines
11 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 } 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";
const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
apt_full_upgrade: "apt/full-upgrade.sh.tpl",
apt_upgrade: "apt/upgrade.sh.tpl",
apt_autoremove: "apt/autoremove.sh.tpl",
apt_clean: "apt/clean.sh.tpl",
reboot: "apt/reboot.sh.tpl",
reboot_verified: "apt/reboot.sh.tpl",
// SJ-4 Docker (passif)
docker_scan: "docker/scan-compose.sh.tpl",
docker_inspect_current: "docker/inspect-compose.sh.tpl",
};
export async function runAction(machineId: string, action: ActionType): 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;
}
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
const rel = TEMPLATE_FOR[action];
if (!rel) throw new Error("Action sans template: " + action);
const script = renderTemplate(rel, { aptProxy: proxy });
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();
}