feat(docker): apply/prune/down + socle action_requests (tâche 2 SJ-6)

- migration 0005 : tables docker_image_events + action_requests
- templates apply-compose (up -d --remove-orphans), prune-images (safe/agressif),
  down-compose (sans volumes/rmi)
- dockerApply: parsers TDD (apply recreated/running/exited, prune images+bytes,
  down removed, parseHumanBytes) + orchestration applyStack/pruneImages/downStack
  réservée aux stacks enabled, insère docker_image_events
- actionRequests: create/approve/reject/list — actions destructives validées
  explicitement (Hermes propose, opérateur approuve, run en arrière-plan) ;
  hors API directe (POST /:id/actions reste passif uniquement)
- routes /machines/:id/action-requests + /action-requests/:id[/approve|/reject]
- execute: RunActionOpts.aggressive, branches apply/prune/down, helper
  archiveExecution mutualisant le boilerplate d'archivage

tsc 0 erreur · 91 tests · build OK · boot OK (migrations 0000→0005).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 06:05:59 +02:00
parent b1c81ba518
commit edb22a59c7
15 changed files with 3045 additions and 1 deletions
+132
View File
@@ -32,6 +32,74 @@ const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
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(
@@ -201,6 +269,70 @@ export async function runAction(
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}`] });
}
}
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
const rel = TEMPLATE_FOR[action];
if (!rel) throw new Error("Action sans template: " + action);